Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
ef88f95
ADFA-4934: Introduce shared PLUGIN_ARCHIVE_EXTENSION constant
davidschachterADFA Aug 16, 2026
fe29e05
ADFA-4934: Install a .cgp or .cgt file opened from outside the app
davidschachterADFA Aug 16, 2026
fbe3855
ADFA-4934: Split file:// intent-filter into typed/untyped variants
davidschachterADFA Aug 16, 2026
7642a99
ADFA-4934: Rebuild the .cgt install dialogs in Jetpack Compose
davidschachterADFA Aug 16, 2026
3670377
ADFA-4934: Dedupe FileProvider authority; fix manifest case/coverage …
davidschachterADFA Aug 16, 2026
d7b9161
ADFA-4934: Fix correctness bugs found by code review (PR #1682)
davidschachterADFA Aug 16, 2026
859365e
ADFA-4934: Fix correctness/reuse findings from max-effort code review
davidschachterADFA Aug 16, 2026
ce36e12
ADFA-4934: Address architecture-review findings
davidschachterADFA Aug 16, 2026
f33b811
ADFA-4934: Address CodeRabbit review findings
davidschachterADFA Aug 16, 2026
f896c47
ADFA-4934: Address remaining CodeRabbit findings on PR #1682
davidschachterADFA Aug 17, 2026
5e8043a
ADFA-4934: Fix findings from fresh CodeRabbit review on PR #1682
davidschachterADFA Aug 17, 2026
6fbf4c1
ADFA-4934: Fix findings from max-effort code review of PR #1682
davidschachterADFA Aug 17, 2026
0d899a7
ADFA-4934: Fix findings from high-effort code review of PR #1682
davidschachterADFA Aug 17, 2026
65c7003
ADFA-4934: Fix findings from second high-effort code review of PR #1682
davidschachterADFA Aug 17, 2026
007ce66
ADFA-4934: Fix findings from third high-effort code review of PR #1682
davidschachterADFA Aug 17, 2026
08202f8
ADFA-4934: Fix findings from fourth high-effort code review of PR #1682
davidschachterADFA Aug 17, 2026
e4d8012
Merge branch 'stage' into davidschachter/ADFA-4934
davidschachterADFA Aug 17, 2026
90c30b9
ADFA-4934: Fix findings from fifth high-effort code review of PR #1682
davidschachterADFA Aug 17, 2026
8bfe68e
ADFA-4934: Fix ktlint line-length wrap in ExternalFileInstallUiModels
davidschachterADFA Aug 17, 2026
ae77791
ADFA-4934: Fix findings from sixth high-effort code review of PR #1682
davidschachterADFA Aug 17, 2026
074575a
ADFA-4934: Address new CodeRabbit findings on PR #1682
davidschachterADFA Aug 17, 2026
d6eb6d0
ADFA-4934: Fix a self-inflicted stuck-dialog regression from the sixt…
davidschachterADFA Aug 17, 2026
400e767
ADFA-4934: Stack the name-conflict dialog's three buttons vertically
davidschachterADFA Aug 17, 2026
e8f5d92
Merge branch 'stage' into davidschachter/ADFA-4934
davidschachterADFA Aug 18, 2026
dfb0840
ADFA-4934: Add a mimeType-only manifest fallback for opaque content:/…
davidschachterADFA Aug 18, 2026
73bff54
Merge branch 'stage' into davidschachter/ADFA-4934
davidschachterADFA Aug 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ data class PluginManagerUiState(
sealed class PluginManagerUiEvent {
object LoadPlugins : PluginManagerUiEvent()
data class EnablePlugin(val pluginId: String) : PluginManagerUiEvent()
data class InstallPlugin(val uri: Uri, val deleteSourceAfterInstall: Boolean) : PluginManagerUiEvent()
data class InstallPlugin(val source: PluginInstallSource, val deleteSourceAfterInstall: Boolean) : PluginManagerUiEvent()
// ...
}

Expand Down
16 changes: 16 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ plugins {
// Sentry gradle plugin; the SDK it wires up reports to our GlitchTip backend.
alias(libs.plugins.sentry)
alias(libs.plugins.google.services)
alias(libs.plugins.kotlin.compose)
}

fun propOrEnv(name: String): String =
Expand Down Expand Up @@ -102,6 +103,10 @@ android {
generateLocaleConfig = true
}

buildFeatures {
compose = true
}

sourceSets {
getByName("androidTest") {
manifest.srcFile("src/androidTest/AndroidManifest.xml")
Expand Down Expand Up @@ -241,6 +246,17 @@ dependencies {
// Git
implementation(libs.git.jgit)

// Compose (ADR 0009 - new IDE dialogs/screens are Compose)
implementation(platform(libs.compose.bom))
implementation(libs.compose.runtime)
implementation(libs.compose.ui)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.activity)
implementation(libs.compose.lifecycle.runtime)
implementation(libs.compose.ui.tooling.preview)
debugImplementation(libs.compose.ui.tooling)

// AndroidX
implementation(libs.androidx.splashscreen)
implementation(libs.androidx.annotation)
Expand Down
113 changes: 112 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,19 @@
<activity
android:name=".activities.PreferencesActivity"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize" />
<!-- uiMode/locale/fontScale/density/keyboard/keyboardHidden/navigation are handled here
(not just orientation etc.) because a recreation mid-dialog (e.g. a system dark/light
auto-switch, or attaching/detaching a hardware keyboard) would otherwise dismiss the
forwarded-install dialog while the ViewModel's one-shot "already handled" guard
(markPendingInstallHandled) silently blocks it from ever being shown again - ADFA-4934.
Accepted tradeoff: since the Activity isn't recreated for these axes and
onConfigurationChanged() isn't overridden either, already-inflated view text/theme
(e.g. a title set once in onCreate(), or day/night-themed colors) can look stale
until the user backs out and re-enters - dialog continuity was judged more important
than instant re-theming for this one screen. -->
<activity
android:name=".activities.PluginManagerActivity"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize" />
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|uiMode|locale|fontScale|density|keyboard|keyboardHidden|navigation" />
<activity
android:name=".activities.PluginScreenActivity"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize"
Expand All @@ -116,6 +126,107 @@
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<!-- Receives a .cgp/.cgt file opened from outside the app (e.g. an email attachment).
Neither extension has a registered MIME type, so the incoming intent may be typed
(e.g. "application/octet-stream") or untyped (a bare Uri with no type) depending on
the sending app. Within a single <intent-filter>, a mimeType on any <data> element
applies to the WHOLE filter (verified via `dumpsys package`: every <data> tag in a
filter reports the same "StaticType", even ones with no android:mimeType attribute
of their own) - so a mimeType-bearing and a mimeType-less variant can't share one
filter without the mimeType contaminating the mimeType-less one too. `content` gets
OS leniency and matches even when the incoming intent has no explicit type, so one
filter with mimeType="*/*" covers both typed and untyped content:// intents; `file`
gets no such leniency and needs its typed and untyped cases in separate filters.
Both extensions are combined into each filter below (safe: every <data> tag within
a given filter shares the same mimeType-or-not shape, so there's no cross-extension
contamination), and each extension gets both a lowercase and an UPPERCASE pathPattern
- android:pathPattern has no case-insensitive mode, and some senders (e.g. archives
re-exported from Windows tools) produce uppercase extensions.
android:host="*" is required alongside pathPattern - the manifest matcher only
evaluates pathPattern when host is also present.
Known limitations:
(1) a sender whose content:// Uri path never carries the filename/extension at all -
the common case in practice, not an edge case: Storage Access Framework providers
(Android's Downloads app, most file managers) hand out opaque document IDs like
content://com.android.providers.downloads.documents/document/msf%3A19, and some email
providers' attachment Uris look like
content://.../message_attachment/12345/0/ATTACHMENT/false - can't match a
pathPattern-based filter regardless of type (confirmed via ADFA-4934's QA pass: the
OS fell through to an unrelated app that happened to declare a broader, unconstrained
VIEW+content+application/octet-stream filter, since ours never matched at all).
Mitigated (not fully fixed) below by a fourth, pathPattern-less filter matching only
the small set of mimeTypes such senders actually use for a binary/zip attachment
(application/octet-stream, application/zip, application/x-zip-compressed) - narrower
than the mimeType="*/*" this same reasoning previously rejected (that would register
this app as a candidate handler for every file view intent on the device), but still
broader than just .cgp/.cgt: any octet-stream/zip content from any app now offers this
app as an "Open with" candidate. Accepted since the real extension is re-validated
from DISPLAY_NAME once opened (ExternalFileInstallViewModel.onReceived) - a mismatched
file is rejected with msg_unsupported_file_type, not a crash.
(2) only the fully-lowercase and fully-UPPERCASE forms are covered per extension -
a mixed-case extension (e.g. "Plugin.Cgp") matches neither, since pathPattern's
matcher supports only literal characters, '.', and '*' (no character classes), and
exhaustively enumerating every case permutation (2^3 per 3-letter extension, times
two extensions, times three filters below) would bloat this manifest far out of
proportion to how rare a genuinely mixed-case sender is in practice (real senders
observed so far are consistently either all-lowercase or Windows-style
all-UPPERCASE). -->
<!-- uiMode/locale/fontScale/density/keyboard/keyboardHidden/navigation are handled here
(not just orientation etc.) because a recreation mid-dialog (e.g. a system dark/light
auto-switch, or attaching/detaching a hardware keyboard) would otherwise reset the
Compose dialog state to nothing while the ViewModel's one-shot receivedUriGate guard
silently blocks the effect from ever being resent - ADFA-4934. Since this screen is
Compose (unlike PluginManagerActivity), content reading LocalConfiguration/
stringResource/isSystemInDarkTheme() still recomposes reactively on these axes even
without an Activity recreation, so the same "stale until back-and-return" tradeoff is
narrower here than on the View-based screen. -->
<activity
android:name=".activities.ExternalFileInstallActivity"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|uiMode|locale|fontScale|density|keyboard|keyboardHidden|navigation"
android:excludeFromRecents="true"
android:exported="true"
android:launchMode="singleTask"
android:theme="@style/Theme.AndroidIDE">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" android:host="*" android:mimeType="*/*" android:pathPattern=".*\\.cgp" />
<data android:scheme="content" android:host="*" android:mimeType="*/*" android:pathPattern=".*\\.CGP" />
<data android:scheme="content" android:host="*" android:mimeType="*/*" android:pathPattern=".*\\.cgt" />
<data android:scheme="content" android:host="*" android:mimeType="*/*" android:pathPattern=".*\\.CGT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="file" android:host="*" android:mimeType="*/*" android:pathPattern=".*\\.cgp" />
<data android:scheme="file" android:host="*" android:mimeType="*/*" android:pathPattern=".*\\.CGP" />
<data android:scheme="file" android:host="*" android:mimeType="*/*" android:pathPattern=".*\\.cgt" />
<data android:scheme="file" android:host="*" android:mimeType="*/*" android:pathPattern=".*\\.CGT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="file" android:host="*" android:pathPattern=".*\\.cgp" />
<data android:scheme="file" android:host="*" android:pathPattern=".*\\.CGP" />
<data android:scheme="file" android:host="*" android:pathPattern=".*\\.cgt" />
<data android:scheme="file" android:host="*" android:pathPattern=".*\\.CGT" />
</intent-filter>
<!-- pathPattern-less fallback for content:// senders whose document Uri never carries
the filename at all (see "Known limitations" (1) above) - matched by mimeType only,
so it still finds real .cgp/.cgt attachments from Downloads/email/file-manager
providers that the pathPattern-based filter above can never reach. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" android:host="*" android:mimeType="application/octet-stream" />
<data android:scheme="content" android:host="*" android:mimeType="application/zip" />
<data android:scheme="content" android:host="*" android:mimeType="application/x-zip-compressed" />
</intent-filter>
</activity>
<activity android:name=".activities.AboutActivity" />
<activity
android:name=".activities.editor.FAQActivity"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,43 +28,57 @@ import com.itsaky.androidide.utils.DialogUtils
import com.itsaky.androidide.utils.flashError
import com.itsaky.androidide.utils.flashSuccess
import kotlinx.coroutines.launch
import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION
import org.koin.core.context.GlobalContext

class InstallFileAction(context: Context, override val order: Int) : FileTabAction() {
class InstallFileAction(
context: Context,
override val order: Int,
) : FileTabAction() {
override val id: String = "ide.editor.fileTab.install"

override val id: String = "ide.editor.fileTab.install"
init {
label = context.getString(R.string.action_install)
}

init {
label = context.getString(R.string.action_install)
}
override fun prepare(data: ActionData) {
super.prepare(data)
if (!visible) return
val activity =
data.getActivity() ?: run {
markInvisible()
return
}
val currentFile = activity.editorViewModel.getCurrentFile()
visible = currentFile?.extension?.lowercase() in setOf("apk", PLUGIN_ARCHIVE_EXTENSION)
enabled = visible
}

override fun prepare(data: ActionData) {
super.prepare(data)
if (!visible) return
val activity = data.getActivity() ?: run { markInvisible(); return }
val currentFile = activity.editorViewModel.getCurrentFile()
visible = currentFile?.extension?.lowercase() in setOf("apk", "cgp")
enabled = visible
}
override fun EditorHandlerActivity.doAction(data: ActionData): Boolean {
val file = editorViewModel.getCurrentFile() ?: return false
when (file.extension.lowercase()) {
"apk" -> {
apkInstallationViewModel.installApk(
context = this,
apk = file,
launchInDebugMode = false,
)
}

override fun EditorHandlerActivity.doAction(data: ActionData): Boolean {
val file = editorViewModel.getCurrentFile() ?: return false
when (file.extension.lowercase()) {
"apk" -> apkInstallationViewModel.installApk(
context = this, apk = file, launchInDebugMode = false
)
"cgp" -> lifecycleScope.launch {
val repo = GlobalContext.get().get<PluginRepository>()
repo.installPluginFromFile(file)
.onSuccess {
flashSuccess(getString(R.string.msg_plugin_installed_restart))
DialogUtils.showRestartPrompt(this@doAction)
}
.onFailure { e ->
flashError(getString(R.string.msg_plugin_install_failed, e.message))
}
}
}
return true
}
PLUGIN_ARCHIVE_EXTENSION -> {
lifecycleScope.launch {
val repo = GlobalContext.get().get<PluginRepository>()
repo
.installPluginFromFile(file)
.onSuccess {
flashSuccess(getString(R.string.msg_plugin_installed_restart))
DialogUtils.showRestartPrompt(this@doAction)
}.onFailure { e ->
flashError(getString(R.string.msg_plugin_install_failed, e.message))
}
}
}
}
return true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.itsaky.androidide.activities

import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.compose.ui.platform.ComposeView
import com.itsaky.androidide.app.IDEActivity
import com.itsaky.androidide.viewmodels.ExternalFileInstallViewModel
import org.koin.androidx.viewmodel.ext.android.viewModel

/**
* Trampoline activity that receives a `.cgp`/`.cgt` file opened from outside the app (e.g. an
* email attachment), prompts to install it, and finishes - it has no content of its own beyond
* the dialogs [ExternalFileInstallScreen] shows.
*
* `singleTask` (manifest) + [onNewIntent] collapse a rapid double-tap on the same external file
* into this one instance/ViewModel, where [ExternalFileInstallViewModel]'s `receivedUriGate`
* already dedupes by Uri - without it, `standard` launch mode would spin up a second
* Activity+ViewModel pair minting an independent temp file, which `PluginManagerViewModel`'s
* path-based dedup guard can't recognize as the same source.
*/
class ExternalFileInstallActivity : IDEActivity() {
private val viewModel: ExternalFileInstallViewModel by viewModel()

override fun bindLayout(): View =
ComposeView(this).apply {
setContent { ExternalFileInstallScreen(viewModel) }
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleIntent()
}

override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleIntent()
}

private fun handleIntent() {
val uri = intent?.data
if (uri == null) {
finish()
return
}

// No savedInstanceState guard here: onReceived() is idempotent per ViewModel instance
// (a rotation, or a re-delivered intent via onNewIntent, keeps the same instance, so this
// is a no-op there), and calling it unconditionally means a process-death-recreated
// instance - which starts fresh and would otherwise never see the restored intent's data -
// still gets processed.
viewModel.onReceived(uri)
}
}
Loading
Loading