|
| 1 | +package com.itsaky.androidide.plugins.aiassistant.fragments |
| 2 | + |
| 3 | +import android.app.Dialog |
| 4 | +import android.content.DialogInterface |
| 5 | +import android.os.Bundle |
| 6 | +import android.view.View |
| 7 | +import androidx.fragment.app.DialogFragment |
| 8 | +import com.google.android.material.dialog.MaterialAlertDialogBuilder |
| 9 | +import com.itsaky.androidide.plugins.aiassistant.AiAssistantPlugin |
| 10 | +import com.itsaky.androidide.plugins.aiassistant.R |
| 11 | +import com.itsaky.androidide.plugins.aiassistant.memory.ModelMemoryGate |
| 12 | +import com.itsaky.androidide.plugins.aiassistant.util.ByteSize |
| 13 | +import com.itsaky.androidide.plugins.aiassistant.viewmodel.ModelMemoryWarning |
| 14 | +import com.itsaky.androidide.plugins.base.PluginFragmentHelper |
| 15 | +import com.itsaky.androidide.plugins.services.IdeTooltipService |
| 16 | + |
| 17 | +/** |
| 18 | + * Warns that a selected model may be too large for this device, offering **Cancel** — the default |
| 19 | + * in every sense visible to the user — or **Proceed anyway** (ADFA-1798). Decisions go to [Host], |
| 20 | + * resolved per call so they still arrive after the framework recreates this on a rotation. |
| 21 | + */ |
| 22 | +class MemoryWarningDialogFragment : DialogFragment() { |
| 23 | + |
| 24 | + private val tooltipService: IdeTooltipService? by lazy { |
| 25 | + try { |
| 26 | + PluginFragmentHelper.getServiceRegistry(AiAssistantPlugin.PLUGIN_ID) |
| 27 | + ?.get(IdeTooltipService::class.java) |
| 28 | + } catch (e: Exception) { |
| 29 | + // Tooltip help is optional; long-press simply shows nothing when it's unavailable. |
| 30 | + AiAssistantPlugin.getContext()?.logger |
| 31 | + ?.warn("MemoryWarningDialogFragment: tooltip service unavailable", e) |
| 32 | + null |
| 33 | + } |
| 34 | + } |
| 35 | + |
| 36 | + /** |
| 37 | + * Receives this dialog's outcome. Implemented by the fragment that shows the dialog, which must |
| 38 | + * be its **parent** fragment (show it with `childFragmentManager`). |
| 39 | + */ |
| 40 | + interface Host { |
| 41 | + /** @param proceed true to load the model anyway, false to abandon the selection. */ |
| 42 | + fun onModelMemoryDecision(proceed: Boolean) |
| 43 | + } |
| 44 | + |
| 45 | + companion object { |
| 46 | + const val TAG = "ModelMemoryWarning" |
| 47 | + |
| 48 | + private const val ARG_MODEL_NAME = "model_name" |
| 49 | + private const val ARG_LOAD_BYTES = "load_bytes" |
| 50 | + private const val ARG_RUN_BYTES = "run_bytes" |
| 51 | + private const val ARG_AVAILABLE_BYTES = "available_bytes" |
| 52 | + private const val ARG_SEVERITY = "severity" |
| 53 | + |
| 54 | + /** |
| 55 | + * Builds the dialog. Everything it needs is in [getArguments], so the framework can recreate |
| 56 | + * it after a configuration change while the answer channel stays on the ViewModel. |
| 57 | + * |
| 58 | + * @param warning the model and the figures to show |
| 59 | + */ |
| 60 | + fun newInstance(warning: ModelMemoryWarning): MemoryWarningDialogFragment = |
| 61 | + MemoryWarningDialogFragment().apply { |
| 62 | + arguments = Bundle().apply { |
| 63 | + putString(ARG_MODEL_NAME, warning.modelName) |
| 64 | + putLong(ARG_LOAD_BYTES, warning.loadBytes) |
| 65 | + putLong(ARG_RUN_BYTES, warning.runBytes) |
| 66 | + putLong(ARG_AVAILABLE_BYTES, warning.availableBytes) |
| 67 | + putString(ARG_SEVERITY, warning.severity.name) |
| 68 | + } |
| 69 | + } |
| 70 | + } |
| 71 | + |
| 72 | + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { |
| 73 | + val args = arguments ?: Bundle.EMPTY |
| 74 | + val messageId = when (severity()) { |
| 75 | + ModelMemoryGate.Severity.INSUFFICIENT -> R.string.llm_memory_warning_insufficient |
| 76 | + ModelMemoryGate.Severity.TIGHT -> R.string.llm_memory_warning_tight |
| 77 | + } |
| 78 | + val message = getString( |
| 79 | + messageId, |
| 80 | + args.getString(ARG_MODEL_NAME).orEmpty(), |
| 81 | + ByteSize.format(args.getLong(ARG_LOAD_BYTES)), |
| 82 | + ByteSize.format(args.getLong(ARG_RUN_BYTES)), |
| 83 | + ByteSize.format(args.getLong(ARG_AVAILABLE_BYTES)), |
| 84 | + ) |
| 85 | + |
| 86 | + val dialog = MaterialAlertDialogBuilder(requireContext()) |
| 87 | + .setTitle(getString(R.string.llm_memory_warning_title)) |
| 88 | + .setMessage(message) |
| 89 | + // Cancel takes the positive slot: Material emphasizes that button. |
| 90 | + .setPositiveButton(getString(R.string.llm_memory_warning_cancel)) { _, _ -> decide(false) } |
| 91 | + .setNegativeButton(getString(R.string.llm_memory_warning_proceed)) { _, _ -> decide(true) } |
| 92 | + .create() |
| 93 | + |
| 94 | + // Bound after show(): the buttons don't exist before it. |
| 95 | + dialog.setOnShowListener { |
| 96 | + val cancelButton = dialog.getButton(Dialog.BUTTON_POSITIVE) |
| 97 | + wireTooltip(cancelButton, AiAssistantPlugin.TOOLTIP_TAG_MEMORY_CANCEL) |
| 98 | + wireTooltip( |
| 99 | + dialog.getButton(Dialog.BUTTON_NEGATIVE), |
| 100 | + AiAssistantPlugin.TOOLTIP_TAG_MEMORY_PROCEED, |
| 101 | + ) |
| 102 | + // So Enter, D-pad and a screen reader all reach the safe choice first. |
| 103 | + cancelButton?.requestFocus() |
| 104 | + } |
| 105 | + |
| 106 | + return dialog |
| 107 | + } |
| 108 | + |
| 109 | + /** |
| 110 | + * Back press and outside tap both mean "don't risk it". Overridden rather than set on the |
| 111 | + * builder: [DialogFragment] replaces a builder cancel listener in `prepareDialog`, so the |
| 112 | + * awaiting caller would never learn the model was declined. |
| 113 | + * |
| 114 | + * @param dialog the dialog that was cancelled |
| 115 | + */ |
| 116 | + override fun onCancel(dialog: DialogInterface) { |
| 117 | + super.onCancel(dialog) |
| 118 | + decide(false) |
| 119 | + } |
| 120 | + |
| 121 | + /** Falls back to the milder wording, so a bad argument can't overstate the risk. */ |
| 122 | + private fun severity(): ModelMemoryGate.Severity = try { |
| 123 | + ModelMemoryGate.Severity.valueOf( |
| 124 | + arguments?.getString(ARG_SEVERITY) ?: ModelMemoryGate.Severity.TIGHT.name |
| 125 | + ) |
| 126 | + } catch (e: IllegalArgumentException) { |
| 127 | + ModelMemoryGate.Severity.TIGHT |
| 128 | + } |
| 129 | + |
| 130 | + /** |
| 131 | + * Delivers the outcome to the host fragment. Resolved on each call rather than captured at |
| 132 | + * construction, so it still works on the instance the framework recreated after a rotation. |
| 133 | + */ |
| 134 | + private fun decide(proceed: Boolean) { |
| 135 | + val host = parentFragment as? Host |
| 136 | + if (host == null) { |
| 137 | + // Nothing else reports this, and the load that raised the warning waits forever on it. |
| 138 | + AiAssistantPlugin.getContext()?.logger |
| 139 | + ?.warn("MemoryWarningDialogFragment: no Host parent; decision dropped") |
| 140 | + return |
| 141 | + } |
| 142 | + host.onModelMemoryDecision(proceed) |
| 143 | + } |
| 144 | + |
| 145 | + /** Shows this plugin's tooltip for [tag] when [view] is long-pressed (Tier 1/2 + guide). */ |
| 146 | + private fun wireTooltip(view: View?, tag: String) { |
| 147 | + view?.setOnLongClickListener { anchor -> |
| 148 | + val service = tooltipService ?: return@setOnLongClickListener false |
| 149 | + service.showTooltip(anchor, AiAssistantPlugin.TOOLTIP_CATEGORY, tag) |
| 150 | + true |
| 151 | + } |
| 152 | + } |
| 153 | +} |
0 commit comments