11package com.itsaky.androidide.plugins.aicore.fragments
22
3+ import android.content.ActivityNotFoundException
34import android.content.ClipData
45import android.content.ClipboardManager
56import android.content.Context
67import android.content.res.Configuration
78import android.graphics.Rect
9+ import android.net.Uri
810import android.os.Bundle
911import android.view.LayoutInflater
1012import android.view.View
1113import android.view.ViewGroup
1214import androidx.activity.OnBackPressedCallback
15+ import androidx.activity.result.contract.ActivityResultContracts
1316import androidx.appcompat.view.ContextThemeWrapper
1417import androidx.core.view.ViewCompat
1518import androidx.core.view.WindowInsetsCompat
@@ -32,6 +35,8 @@ import com.itsaky.androidide.plugins.aicore.logging.AgentTrace
3235import com.itsaky.androidide.plugins.aicore.logging.LOG_PREFIX
3336import com.itsaky.androidide.plugins.aicore.managers.ProjectKey
3437import com.itsaky.androidide.plugins.aicore.models.AgentState
38+ import com.itsaky.androidide.plugins.aicore.models.ChatTranscript
39+ import com.itsaky.androidide.plugins.aicore.models.SessionRow
3540import com.itsaky.androidide.plugins.aicore.models.isRunning
3641import com.itsaky.androidide.plugins.aicore.models.traceLabel
3742import com.itsaky.androidide.plugins.aicore.plugin.AiCorePlugin
@@ -69,6 +74,9 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
6974 private companion object {
7075 /* * Tag the approval dialog is shown under, so it can be found again after recreation. */
7176 const val APPROVAL_DIALOG_TAG = " approval_dialog"
77+
78+ /* * Saved-state key for [pendingExportSessionId], which must outlive the picker's round trip. */
79+ const val STATE_PENDING_EXPORT = " pending_export_session_id"
7280 }
7381
7482 private var _binding : FragmentChatBinding ? = null
@@ -93,6 +101,31 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
93101 }
94102 }
95103
104+ /* *
105+ * The chat Export was picked for, held while the system file picker is up: the picker answers
106+ * with a destination only, and the chat may have grown by the time it does.
107+ */
108+ private var pendingExportSessionId: String? = null
109+
110+ /* *
111+ * The system picker for where an exported chat goes. Registered as a field, since a launcher
112+ * must exist before the fragment is started; a null Uri is the user cancelling.
113+ */
114+ private val exportLauncher = registerForActivityResult(
115+ ActivityResultContracts .CreateDocument (ChatTranscript .MIME_TYPE )
116+ ) { uri ->
117+ val sessionId = pendingExportSessionId
118+ pendingExportSessionId = null
119+ if (uri != null && sessionId != null ) writeExport(sessionId, uri)
120+ }
121+
122+ /* * The system picker for a transcript to import; a null Uri is the user cancelling. */
123+ private val importLauncher = registerForActivityResult(
124+ ActivityResultContracts .OpenDocument ()
125+ ) { uri ->
126+ if (uri != null ) readImport(uri)
127+ }
128+
96129 /* * The message list's layout-declared padding, before any cutout inset is added. */
97130 private val basePadding = Rect ()
98131
@@ -112,11 +145,21 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
112145
113146 /* * Shows this plugin's tooltip for [tag] when [view] is long-pressed (Tier 1/2 + guide). */
114147 private fun wireTooltip (view : View , tag : String ) {
115- view.setOnLongClickListener { anchor ->
116- val service = tooltipService ? : return @setOnLongClickListener false
117- service.showTooltip(anchor, AiCorePlugin .TOOLTIP_CATEGORY , tag)
118- true
119- }
148+ view.setOnLongClickListener { anchor -> showTooltip(anchor, tag) }
149+ }
150+
151+ /* *
152+ * Shows this plugin's tooltip for [tag] on [anchor] now.
153+ *
154+ * [anchor] must sit in an Activity or Dialog window: the host's tooltip is a PopupWindow, and
155+ * one anchored inside another PopupWindow throws BadTokenException and takes the IDE down.
156+ *
157+ * @return false when the tooltip service is unavailable, so the long press is not consumed.
158+ */
159+ private fun showTooltip (anchor : View , tag : String ): Boolean {
160+ val service = tooltipService ? : return false
161+ service.showTooltip(anchor, AiCorePlugin .TOOLTIP_CATEGORY , tag)
162+ return true
120163 }
121164
122165 /* *
@@ -185,6 +228,13 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
185228 super .onSaveInstanceState(outState)
186229 // The process can be killed while backgrounded even though rotation never recreates us.
187230 composer?.saveState(outState)
231+ // The same holds while the export picker is up, which is exactly when the IDE is backgrounded.
232+ outState.putString(STATE_PENDING_EXPORT , pendingExportSessionId)
233+ }
234+
235+ override fun onCreate (savedInstanceState : Bundle ? ) {
236+ super .onCreate(savedInstanceState)
237+ pendingExportSessionId = savedInstanceState?.getString(STATE_PENDING_EXPORT )
188238 }
189239
190240 override fun onViewCreated (view : View , savedInstanceState : Bundle ? ) {
@@ -385,8 +435,12 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
385435 viewModel = viewModel,
386436 scope = viewLifecycleOwner.lifecycleScope,
387437 wireTooltip = ::wireTooltip,
438+ showTooltip = ::showTooltip,
439+ showMessage = ::showInfoSnackbar,
388440 dialogContext = { if (isAdded) themedDialogContext() else null },
389441 onOpenSettings = ::openSettingsFragment,
442+ onExportChat = ::launchExport,
443+ onImportChat = ::launchImport,
390444 onOpenChanged = { open ->
391445 sidebarBackCallback.isEnabled = open
392446 // A panel over the chat with the keyboard still up leaves the list two rows tall.
@@ -412,6 +466,82 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
412466 private fun themedDialogContext (): Context =
413467 ContextThemeWrapper (requireContext(), R .style.PluginTheme )
414468
469+ /* *
470+ * Asks the system file picker where to save [row]'s chat, suggesting a name from its title.
471+ *
472+ * @param row the conversation to export.
473+ */
474+ private fun launchExport (row : SessionRow ) {
475+ pendingExportSessionId = row.id
476+ try {
477+ exportLauncher.launch(ChatTranscript .fileName(row.title))
478+ } catch (e: ActivityNotFoundException ) {
479+ pendingExportSessionId = null
480+ AiCorePlugin .getContext()?.logger?.warn(" ChatFragment: no picker to export with" , e)
481+ showInfoSnackbar(getString(R .string.session_picker_unavailable))
482+ }
483+ }
484+
485+ /* * Asks the system file picker for a transcript to read back as a new chat. */
486+ private fun launchImport () {
487+ try {
488+ // text/* rather than text/plain alone: some providers label a .txt file text/x-log.
489+ importLauncher.launch(arrayOf(" text/*" ))
490+ } catch (e: ActivityNotFoundException ) {
491+ AiCorePlugin .getContext()?.logger?.warn(" ChatFragment: no picker to import with" , e)
492+ showInfoSnackbar(getString(R .string.session_picker_unavailable))
493+ }
494+ }
495+
496+ /* *
497+ * Writes a chat to the file the picker returned and says how that went.
498+ *
499+ * On the fragment's own scope, not the view's: a tab switch mid-write must not abandon it.
500+ *
501+ * @param sessionId the chat to export.
502+ * @param uri where the picker said to write it.
503+ */
504+ private fun writeExport (sessionId : String , uri : Uri ) {
505+ val resolver = requireContext().contentResolver
506+ lifecycleScope.launch {
507+ val message = when (viewModel.exportSession(sessionId, resolver, uri)) {
508+ ChatViewModel .ExportResult .EXPORTED -> getString(R .string.session_exported)
509+ ChatViewModel .ExportResult .TOO_LARGE_TO_IMPORT ->
510+ getString(R .string.session_exported_too_large, importLimitMegabytes())
511+ ChatViewModel .ExportResult .MISSING -> getString(R .string.session_export_missing)
512+ ChatViewModel .ExportResult .FAILED -> getString(R .string.session_export_failed)
513+ }
514+ showInfoSnackbar(message)
515+ }
516+ }
517+
518+ /* *
519+ * Imports the file the picker returned as a new chat, opening it, or reports that it could not.
520+ *
521+ * @param uri the file the user picked.
522+ */
523+ private fun readImport (uri : Uri ) {
524+ val resolver = requireContext().contentResolver
525+ lifecycleScope.launch {
526+ when (viewModel.importTranscript(resolver, uri)) {
527+ ChatViewModel .ImportResult .IMPORTED -> Unit
528+ ChatViewModel .ImportResult .TOO_LARGE -> {
529+ showInfoSnackbar(getString(R .string.session_import_too_large, importLimitMegabytes()))
530+ return @launch
531+ }
532+ ChatViewModel .ImportResult .FAILED -> {
533+ showInfoSnackbar(getString(R .string.session_import_failed))
534+ return @launch
535+ }
536+ }
537+ sidebar?.close()
538+ showInfoSnackbar(getString(R .string.session_imported))
539+ }
540+ }
541+
542+ /* * [ChatTranscript.MAX_IMPORT_BYTES] as the user is told it. */
543+ private fun importLimitMegabytes (): Int = (ChatTranscript .MAX_IMPORT_BYTES / (1024 * 1024 )).toInt()
544+
415545 private fun setupInputArea () {
416546 binding.sendButton.setOnClickListener {
417547 if (viewModel.agentState.value.isRunning) {
0 commit comments