11package com.itsaky.androidide.plugins.aicore.fragments
22
3+ import android.content.ActivityNotFoundException
34import android.content.Context
45import android.content.res.Configuration
56import android.graphics.Rect
7+ import android.net.Uri
68import android.os.Bundle
79import android.view.LayoutInflater
810import android.view.View
911import android.view.ViewGroup
1012import androidx.activity.OnBackPressedCallback
13+ import androidx.activity.result.contract.ActivityResultContracts
1114import androidx.appcompat.view.ContextThemeWrapper
1215import androidx.core.view.ViewCompat
1316import androidx.core.view.WindowInsetsCompat
@@ -30,6 +33,8 @@ import com.itsaky.androidide.plugins.aicore.logging.AgentTrace
3033import com.itsaky.androidide.plugins.aicore.logging.LOG_PREFIX
3134import com.itsaky.androidide.plugins.aicore.managers.ProjectKey
3235import com.itsaky.androidide.plugins.aicore.models.AgentState
36+ import com.itsaky.androidide.plugins.aicore.models.ChatTranscript
37+ import com.itsaky.androidide.plugins.aicore.models.SessionRow
3338import com.itsaky.androidide.plugins.aicore.models.isRunning
3439import com.itsaky.androidide.plugins.aicore.models.traceLabel
3540import com.itsaky.androidide.plugins.aicore.plugin.AiCorePlugin
@@ -55,6 +60,9 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
5560 private companion object {
5661 /* * Tag the approval dialog is shown under, so it can be found again after recreation. */
5762 const val APPROVAL_DIALOG_TAG = " approval_dialog"
63+
64+ /* * Saved-state key for [pendingExportSessionId], which must outlive the picker's round trip. */
65+ const val STATE_PENDING_EXPORT = " pending_export_session_id"
5866 }
5967
6068 private var _binding : FragmentChatBinding ? = null
@@ -79,6 +87,31 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
7987 }
8088 }
8189
90+ /* *
91+ * The chat Export was picked for, held while the system file picker is up: the picker answers
92+ * with a destination only, and the chat may have grown by the time it does.
93+ */
94+ private var pendingExportSessionId: String? = null
95+
96+ /* *
97+ * The system picker for where an exported chat goes. Registered as a field, since a launcher
98+ * must exist before the fragment is started; a null Uri is the user cancelling.
99+ */
100+ private val exportLauncher = registerForActivityResult(
101+ ActivityResultContracts .CreateDocument (ChatTranscript .MIME_TYPE )
102+ ) { uri ->
103+ val sessionId = pendingExportSessionId
104+ pendingExportSessionId = null
105+ if (uri != null && sessionId != null ) writeExport(sessionId, uri)
106+ }
107+
108+ /* * The system picker for a transcript to import; a null Uri is the user cancelling. */
109+ private val importLauncher = registerForActivityResult(
110+ ActivityResultContracts .OpenDocument ()
111+ ) { uri ->
112+ if (uri != null ) readImport(uri)
113+ }
114+
82115 /* * The message list's layout-declared padding, before any cutout inset is added. */
83116 private val basePadding = Rect ()
84117
@@ -98,11 +131,21 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
98131
99132 /* * Shows this plugin's tooltip for [tag] when [view] is long-pressed (Tier 1/2 + guide). */
100133 private fun wireTooltip (view : View , tag : String ) {
101- view.setOnLongClickListener { anchor ->
102- val service = tooltipService ? : return @setOnLongClickListener false
103- service.showTooltip(anchor, AiCorePlugin .TOOLTIP_CATEGORY , tag)
104- true
105- }
134+ view.setOnLongClickListener { anchor -> showTooltip(anchor, tag) }
135+ }
136+
137+ /* *
138+ * Shows this plugin's tooltip for [tag] on [anchor] now.
139+ *
140+ * [anchor] must sit in an Activity or Dialog window: the host's tooltip is a PopupWindow, and
141+ * one anchored inside another PopupWindow throws BadTokenException and takes the IDE down.
142+ *
143+ * @return false when the tooltip service is unavailable, so the long press is not consumed.
144+ */
145+ private fun showTooltip (anchor : View , tag : String ): Boolean {
146+ val service = tooltipService ? : return false
147+ service.showTooltip(anchor, AiCorePlugin .TOOLTIP_CATEGORY , tag)
148+ return true
106149 }
107150
108151 /* *
@@ -171,6 +214,13 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
171214 super .onSaveInstanceState(outState)
172215 // The process can be killed while backgrounded even though rotation never recreates us.
173216 composer?.saveState(outState)
217+ // The same holds while the export picker is up, which is exactly when the IDE is backgrounded.
218+ outState.putString(STATE_PENDING_EXPORT , pendingExportSessionId)
219+ }
220+
221+ override fun onCreate (savedInstanceState : Bundle ? ) {
222+ super .onCreate(savedInstanceState)
223+ pendingExportSessionId = savedInstanceState?.getString(STATE_PENDING_EXPORT )
174224 }
175225
176226 override fun onViewCreated (view : View , savedInstanceState : Bundle ? ) {
@@ -326,8 +376,12 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
326376 viewModel = viewModel,
327377 scope = viewLifecycleOwner.lifecycleScope,
328378 wireTooltip = ::wireTooltip,
379+ showTooltip = ::showTooltip,
380+ showMessage = ::showInfoSnackbar,
329381 dialogContext = { if (isAdded) themedDialogContext() else null },
330382 onOpenSettings = ::openSettingsFragment,
383+ onExportChat = ::launchExport,
384+ onImportChat = ::launchImport,
331385 onOpenChanged = { open ->
332386 sidebarBackCallback.isEnabled = open
333387 // A panel over the chat with the keyboard still up leaves the list two rows tall.
@@ -353,6 +407,70 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
353407 private fun themedDialogContext (): Context =
354408 ContextThemeWrapper (requireContext(), R .style.PluginTheme )
355409
410+ /* *
411+ * Asks the system file picker where to save [row]'s chat, suggesting a name from its title.
412+ *
413+ * @param row the conversation to export.
414+ */
415+ private fun launchExport (row : SessionRow ) {
416+ pendingExportSessionId = row.id
417+ try {
418+ exportLauncher.launch(ChatTranscript .fileName(row.title))
419+ } catch (e: ActivityNotFoundException ) {
420+ pendingExportSessionId = null
421+ AiCorePlugin .getContext()?.logger?.warn(" ChatFragment: no picker to export with" , e)
422+ showInfoSnackbar(getString(R .string.session_picker_unavailable))
423+ }
424+ }
425+
426+ /* * Asks the system file picker for a transcript to read back as a new chat. */
427+ private fun launchImport () {
428+ try {
429+ // text/* rather than text/plain alone: some providers label a .txt file text/x-log.
430+ importLauncher.launch(arrayOf(" text/*" ))
431+ } catch (e: ActivityNotFoundException ) {
432+ AiCorePlugin .getContext()?.logger?.warn(" ChatFragment: no picker to import with" , e)
433+ showInfoSnackbar(getString(R .string.session_picker_unavailable))
434+ }
435+ }
436+
437+ /* *
438+ * Writes a chat to the file the picker returned and says how that went.
439+ *
440+ * On the fragment's own scope, not the view's: a tab switch mid-write must not abandon it.
441+ *
442+ * @param sessionId the chat to export.
443+ * @param uri where the picker said to write it.
444+ */
445+ private fun writeExport (sessionId : String , uri : Uri ) {
446+ val resolver = requireContext().contentResolver
447+ lifecycleScope.launch {
448+ val message = when (viewModel.exportSession(sessionId, resolver, uri)) {
449+ ChatViewModel .ExportResult .EXPORTED -> R .string.session_exported
450+ ChatViewModel .ExportResult .MISSING -> R .string.session_export_missing
451+ ChatViewModel .ExportResult .FAILED -> R .string.session_export_failed
452+ }
453+ showInfoSnackbar(getString(message))
454+ }
455+ }
456+
457+ /* *
458+ * Imports the file the picker returned as a new chat, opening it, or reports that it could not.
459+ *
460+ * @param uri the file the user picked.
461+ */
462+ private fun readImport (uri : Uri ) {
463+ val resolver = requireContext().contentResolver
464+ lifecycleScope.launch {
465+ if (! viewModel.importTranscript(resolver, uri)) {
466+ showInfoSnackbar(getString(R .string.session_import_failed))
467+ return @launch
468+ }
469+ sidebar?.close()
470+ showInfoSnackbar(getString(R .string.session_imported))
471+ }
472+ }
473+
356474 private fun setupInputArea () {
357475 binding.sendButton.setOnClickListener {
358476 if (viewModel.agentState.value.isRunning) {
0 commit comments