Skip to content

Commit 80ca34c

Browse files
Merge branch 'stage' into docs/ADFA-5176-retro
2 parents 26a2562 + 50bd570 commit 80ca34c

41 files changed

Lines changed: 4118 additions & 1572 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎ARCHITECTURE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ data class PluginManagerUiState(
147147
sealed class PluginManagerUiEvent {
148148
object LoadPlugins : PluginManagerUiEvent()
149149
data class EnablePlugin(val pluginId: String) : PluginManagerUiEvent()
150-
data class InstallPlugin(val uri: Uri, val deleteSourceAfterInstall: Boolean) : PluginManagerUiEvent()
150+
data class InstallPlugin(val source: PluginInstallSource, val deleteSourceAfterInstall: Boolean) : PluginManagerUiEvent()
151151
// ...
152152
}
153153

‎app/build.gradle.kts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ plugins {
3434
// Sentry gradle plugin; the SDK it wires up reports to our GlitchTip backend.
3535
alias(libs.plugins.sentry)
3636
alias(libs.plugins.google.services)
37+
alias(libs.plugins.kotlin.compose)
3738
}
3839

3940
fun propOrEnv(name: String): String =
@@ -102,6 +103,10 @@ android {
102103
generateLocaleConfig = true
103104
}
104105

106+
buildFeatures {
107+
compose = true
108+
}
109+
105110
sourceSets {
106111
getByName("androidTest") {
107112
manifest.srcFile("src/androidTest/AndroidManifest.xml")
@@ -241,6 +246,17 @@ dependencies {
241246
// Git
242247
implementation(libs.git.jgit)
243248

249+
// Compose (ADR 0009 - new IDE dialogs/screens are Compose)
250+
implementation(platform(libs.compose.bom))
251+
implementation(libs.compose.runtime)
252+
implementation(libs.compose.ui)
253+
implementation(libs.compose.foundation)
254+
implementation(libs.compose.material3)
255+
implementation(libs.compose.activity)
256+
implementation(libs.compose.lifecycle.runtime)
257+
implementation(libs.compose.ui.tooling.preview)
258+
debugImplementation(libs.compose.ui.tooling)
259+
244260
// AndroidX
245261
implementation(libs.androidx.splashscreen)
246262
implementation(libs.androidx.annotation)

‎app/src/main/AndroidManifest.xml‎

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,19 @@
104104
<activity
105105
android:name=".activities.PreferencesActivity"
106106
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize" />
107+
<!-- uiMode/locale/fontScale/density/keyboard/keyboardHidden/navigation are handled here
108+
(not just orientation etc.) because a recreation mid-dialog (e.g. a system dark/light
109+
auto-switch, or attaching/detaching a hardware keyboard) would otherwise dismiss the
110+
forwarded-install dialog while the ViewModel's one-shot "already handled" guard
111+
(markPendingInstallHandled) silently blocks it from ever being shown again - ADFA-4934.
112+
Accepted tradeoff: since the Activity isn't recreated for these axes and
113+
onConfigurationChanged() isn't overridden either, already-inflated view text/theme
114+
(e.g. a title set once in onCreate(), or day/night-themed colors) can look stale
115+
until the user backs out and re-enters - dialog continuity was judged more important
116+
than instant re-theming for this one screen. -->
107117
<activity
108118
android:name=".activities.PluginManagerActivity"
109-
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize" />
119+
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|uiMode|locale|fontScale|density|keyboard|keyboardHidden|navigation" />
110120
<activity
111121
android:name=".activities.PluginScreenActivity"
112122
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize"
@@ -116,6 +126,107 @@
116126
<category android:name="android.intent.category.DEFAULT" />
117127
</intent-filter>
118128
</activity>
129+
<!-- Receives a .cgp/.cgt file opened from outside the app (e.g. an email attachment).
130+
Neither extension has a registered MIME type, so the incoming intent may be typed
131+
(e.g. "application/octet-stream") or untyped (a bare Uri with no type) depending on
132+
the sending app. Within a single <intent-filter>, a mimeType on any <data> element
133+
applies to the WHOLE filter (verified via `dumpsys package`: every <data> tag in a
134+
filter reports the same "StaticType", even ones with no android:mimeType attribute
135+
of their own) - so a mimeType-bearing and a mimeType-less variant can't share one
136+
filter without the mimeType contaminating the mimeType-less one too. `content` gets
137+
OS leniency and matches even when the incoming intent has no explicit type, so one
138+
filter with mimeType="*/*" covers both typed and untyped content:// intents; `file`
139+
gets no such leniency and needs its typed and untyped cases in separate filters.
140+
Both extensions are combined into each filter below (safe: every <data> tag within
141+
a given filter shares the same mimeType-or-not shape, so there's no cross-extension
142+
contamination), and each extension gets both a lowercase and an UPPERCASE pathPattern
143+
- android:pathPattern has no case-insensitive mode, and some senders (e.g. archives
144+
re-exported from Windows tools) produce uppercase extensions.
145+
android:host="*" is required alongside pathPattern - the manifest matcher only
146+
evaluates pathPattern when host is also present.
147+
Known limitations:
148+
(1) a sender whose content:// Uri path never carries the filename/extension at all -
149+
the common case in practice, not an edge case: Storage Access Framework providers
150+
(Android's Downloads app, most file managers) hand out opaque document IDs like
151+
content://com.android.providers.downloads.documents/document/msf%3A19, and some email
152+
providers' attachment Uris look like
153+
content://.../message_attachment/12345/0/ATTACHMENT/false - can't match a
154+
pathPattern-based filter regardless of type (confirmed via ADFA-4934's QA pass: the
155+
OS fell through to an unrelated app that happened to declare a broader, unconstrained
156+
VIEW+content+application/octet-stream filter, since ours never matched at all).
157+
Mitigated (not fully fixed) below by a fourth, pathPattern-less filter matching only
158+
the small set of mimeTypes such senders actually use for a binary/zip attachment
159+
(application/octet-stream, application/zip, application/x-zip-compressed) - narrower
160+
than the mimeType="*/*" this same reasoning previously rejected (that would register
161+
this app as a candidate handler for every file view intent on the device), but still
162+
broader than just .cgp/.cgt: any octet-stream/zip content from any app now offers this
163+
app as an "Open with" candidate. Accepted since the real extension is re-validated
164+
from DISPLAY_NAME once opened (ExternalFileInstallViewModel.onReceived) - a mismatched
165+
file is rejected with msg_unsupported_file_type, not a crash.
166+
(2) only the fully-lowercase and fully-UPPERCASE forms are covered per extension -
167+
a mixed-case extension (e.g. "Plugin.Cgp") matches neither, since pathPattern's
168+
matcher supports only literal characters, '.', and '*' (no character classes), and
169+
exhaustively enumerating every case permutation (2^3 per 3-letter extension, times
170+
two extensions, times three filters below) would bloat this manifest far out of
171+
proportion to how rare a genuinely mixed-case sender is in practice (real senders
172+
observed so far are consistently either all-lowercase or Windows-style
173+
all-UPPERCASE). -->
174+
<!-- uiMode/locale/fontScale/density/keyboard/keyboardHidden/navigation are handled here
175+
(not just orientation etc.) because a recreation mid-dialog (e.g. a system dark/light
176+
auto-switch, or attaching/detaching a hardware keyboard) would otherwise reset the
177+
Compose dialog state to nothing while the ViewModel's one-shot receivedUriGate guard
178+
silently blocks the effect from ever being resent - ADFA-4934. Since this screen is
179+
Compose (unlike PluginManagerActivity), content reading LocalConfiguration/
180+
stringResource/isSystemInDarkTheme() still recomposes reactively on these axes even
181+
without an Activity recreation, so the same "stale until back-and-return" tradeoff is
182+
narrower here than on the View-based screen. -->
183+
<activity
184+
android:name=".activities.ExternalFileInstallActivity"
185+
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|uiMode|locale|fontScale|density|keyboard|keyboardHidden|navigation"
186+
android:excludeFromRecents="true"
187+
android:exported="true"
188+
android:launchMode="singleTask"
189+
android:theme="@style/Theme.AndroidIDE">
190+
<intent-filter>
191+
<action android:name="android.intent.action.VIEW" />
192+
<category android:name="android.intent.category.DEFAULT" />
193+
<category android:name="android.intent.category.BROWSABLE" />
194+
<data android:scheme="content" android:host="*" android:mimeType="*/*" android:pathPattern=".*\\.cgp" />
195+
<data android:scheme="content" android:host="*" android:mimeType="*/*" android:pathPattern=".*\\.CGP" />
196+
<data android:scheme="content" android:host="*" android:mimeType="*/*" android:pathPattern=".*\\.cgt" />
197+
<data android:scheme="content" android:host="*" android:mimeType="*/*" android:pathPattern=".*\\.CGT" />
198+
</intent-filter>
199+
<intent-filter>
200+
<action android:name="android.intent.action.VIEW" />
201+
<category android:name="android.intent.category.DEFAULT" />
202+
<category android:name="android.intent.category.BROWSABLE" />
203+
<data android:scheme="file" android:host="*" android:mimeType="*/*" android:pathPattern=".*\\.cgp" />
204+
<data android:scheme="file" android:host="*" android:mimeType="*/*" android:pathPattern=".*\\.CGP" />
205+
<data android:scheme="file" android:host="*" android:mimeType="*/*" android:pathPattern=".*\\.cgt" />
206+
<data android:scheme="file" android:host="*" android:mimeType="*/*" android:pathPattern=".*\\.CGT" />
207+
</intent-filter>
208+
<intent-filter>
209+
<action android:name="android.intent.action.VIEW" />
210+
<category android:name="android.intent.category.DEFAULT" />
211+
<category android:name="android.intent.category.BROWSABLE" />
212+
<data android:scheme="file" android:host="*" android:pathPattern=".*\\.cgp" />
213+
<data android:scheme="file" android:host="*" android:pathPattern=".*\\.CGP" />
214+
<data android:scheme="file" android:host="*" android:pathPattern=".*\\.cgt" />
215+
<data android:scheme="file" android:host="*" android:pathPattern=".*\\.CGT" />
216+
</intent-filter>
217+
<!-- pathPattern-less fallback for content:// senders whose document Uri never carries
218+
the filename at all (see "Known limitations" (1) above) - matched by mimeType only,
219+
so it still finds real .cgp/.cgt attachments from Downloads/email/file-manager
220+
providers that the pathPattern-based filter above can never reach. -->
221+
<intent-filter>
222+
<action android:name="android.intent.action.VIEW" />
223+
<category android:name="android.intent.category.DEFAULT" />
224+
<category android:name="android.intent.category.BROWSABLE" />
225+
<data android:scheme="content" android:host="*" android:mimeType="application/octet-stream" />
226+
<data android:scheme="content" android:host="*" android:mimeType="application/zip" />
227+
<data android:scheme="content" android:host="*" android:mimeType="application/x-zip-compressed" />
228+
</intent-filter>
229+
</activity>
119230
<activity android:name=".activities.AboutActivity" />
120231
<activity
121232
android:name=".activities.editor.FAQActivity"

‎app/src/main/java/com/itsaky/androidide/actions/file/InstallFileAction.kt‎

Lines changed: 47 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -28,43 +28,57 @@ import com.itsaky.androidide.utils.DialogUtils
2828
import com.itsaky.androidide.utils.flashError
2929
import com.itsaky.androidide.utils.flashSuccess
3030
import kotlinx.coroutines.launch
31+
import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION
3132
import org.koin.core.context.GlobalContext
3233

33-
class InstallFileAction(context: Context, override val order: Int) : FileTabAction() {
34+
class InstallFileAction(
35+
context: Context,
36+
override val order: Int,
37+
) : FileTabAction() {
38+
override val id: String = "ide.editor.fileTab.install"
3439

35-
override val id: String = "ide.editor.fileTab.install"
40+
init {
41+
label = context.getString(R.string.action_install)
42+
}
3643

37-
init {
38-
label = context.getString(R.string.action_install)
39-
}
44+
override fun prepare(data: ActionData) {
45+
super.prepare(data)
46+
if (!visible) return
47+
val activity =
48+
data.getActivity() ?: run {
49+
markInvisible()
50+
return
51+
}
52+
val currentFile = activity.editorViewModel.getCurrentFile()
53+
visible = currentFile?.extension?.lowercase() in setOf("apk", PLUGIN_ARCHIVE_EXTENSION)
54+
enabled = visible
55+
}
4056

41-
override fun prepare(data: ActionData) {
42-
super.prepare(data)
43-
if (!visible) return
44-
val activity = data.getActivity() ?: run { markInvisible(); return }
45-
val currentFile = activity.editorViewModel.getCurrentFile()
46-
visible = currentFile?.extension?.lowercase() in setOf("apk", "cgp")
47-
enabled = visible
48-
}
57+
override fun EditorHandlerActivity.doAction(data: ActionData): Boolean {
58+
val file = editorViewModel.getCurrentFile() ?: return false
59+
when (file.extension.lowercase()) {
60+
"apk" -> {
61+
apkInstallationViewModel.installApk(
62+
context = this,
63+
apk = file,
64+
launchInDebugMode = false,
65+
)
66+
}
4967

50-
override fun EditorHandlerActivity.doAction(data: ActionData): Boolean {
51-
val file = editorViewModel.getCurrentFile() ?: return false
52-
when (file.extension.lowercase()) {
53-
"apk" -> apkInstallationViewModel.installApk(
54-
context = this, apk = file, launchInDebugMode = false
55-
)
56-
"cgp" -> lifecycleScope.launch {
57-
val repo = GlobalContext.get().get<PluginRepository>()
58-
repo.installPluginFromFile(file)
59-
.onSuccess {
60-
flashSuccess(getString(R.string.msg_plugin_installed_restart))
61-
DialogUtils.showRestartPrompt(this@doAction)
62-
}
63-
.onFailure { e ->
64-
flashError(getString(R.string.msg_plugin_install_failed, e.message))
65-
}
66-
}
67-
}
68-
return true
69-
}
68+
PLUGIN_ARCHIVE_EXTENSION -> {
69+
lifecycleScope.launch {
70+
val repo = GlobalContext.get().get<PluginRepository>()
71+
repo
72+
.installPluginFromFile(file)
73+
.onSuccess {
74+
flashSuccess(getString(R.string.msg_plugin_installed_restart))
75+
DialogUtils.showRestartPrompt(this@doAction)
76+
}.onFailure { e ->
77+
flashError(getString(R.string.msg_plugin_install_failed, e.message))
78+
}
79+
}
80+
}
81+
}
82+
return true
83+
}
7084
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package com.itsaky.androidide.activities
2+
3+
import android.content.Intent
4+
import android.os.Bundle
5+
import android.view.View
6+
import androidx.compose.ui.platform.ComposeView
7+
import com.itsaky.androidide.app.IDEActivity
8+
import com.itsaky.androidide.viewmodels.ExternalFileInstallViewModel
9+
import org.koin.androidx.viewmodel.ext.android.viewModel
10+
11+
/**
12+
* Trampoline activity that receives a `.cgp`/`.cgt` file opened from outside the app (e.g. an
13+
* email attachment), prompts to install it, and finishes - it has no content of its own beyond
14+
* the dialogs [ExternalFileInstallScreen] shows.
15+
*
16+
* `singleTask` (manifest) + [onNewIntent] collapse a rapid double-tap on the same external file
17+
* into this one instance/ViewModel, where [ExternalFileInstallViewModel]'s `receivedUriGate`
18+
* already dedupes by Uri - without it, `standard` launch mode would spin up a second
19+
* Activity+ViewModel pair minting an independent temp file, which `PluginManagerViewModel`'s
20+
* path-based dedup guard can't recognize as the same source.
21+
*/
22+
class ExternalFileInstallActivity : IDEActivity() {
23+
private val viewModel: ExternalFileInstallViewModel by viewModel()
24+
25+
override fun bindLayout(): View =
26+
ComposeView(this).apply {
27+
setContent { ExternalFileInstallScreen(viewModel) }
28+
}
29+
30+
override fun onCreate(savedInstanceState: Bundle?) {
31+
super.onCreate(savedInstanceState)
32+
handleIntent()
33+
}
34+
35+
override fun onNewIntent(intent: Intent) {
36+
super.onNewIntent(intent)
37+
setIntent(intent)
38+
handleIntent()
39+
}
40+
41+
private fun handleIntent() {
42+
val uri = intent?.data
43+
if (uri == null) {
44+
finish()
45+
return
46+
}
47+
48+
// No savedInstanceState guard here: onReceived() is idempotent per ViewModel instance
49+
// (a rotation, or a re-delivered intent via onNewIntent, keeps the same instance, so this
50+
// is a no-op there), and calling it unconditionally means a process-death-recreated
51+
// instance - which starts fresh and would otherwise never see the restored intent's data -
52+
// still gets processed.
53+
viewModel.onReceived(uri)
54+
}
55+
}

0 commit comments

Comments
 (0)