Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,8 @@
<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).
<!-- Receives a .cgp/.cgt file opened from outside the app (e.g. an email attachment), and a .cgt
handed over by the editor's Install action (an explicit intent with a FileProvider Uri).
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,53 @@

package com.itsaky.androidide.actions.file

import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import androidx.lifecycle.lifecycleScope
import com.itsaky.androidide.actions.ActionData
import com.itsaky.androidide.actions.markInvisible
import com.itsaky.androidide.activities.ExternalFileInstallActivity
import com.itsaky.androidide.activities.editor.EditorHandlerActivity
import com.itsaky.androidide.repositories.PluginRepository
import com.itsaky.androidide.resources.R
import com.itsaky.androidide.utils.DialogUtils
import com.itsaky.androidide.utils.fileProviderUriFor
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.adfa.constants.TEMPLATE_ARCHIVE_EXTENSION
import org.koin.core.context.GlobalContext
import org.slf4j.LoggerFactory
import java.io.File

private val log = LoggerFactory.getLogger(InstallFileAction::class.java)

/** What the file-tab Install action does with a file; one kind per supported extension. */
internal enum class InstallKind { APK, PLUGIN, TEMPLATE }

/** The [InstallKind] for [file], by case-insensitive extension, or null when it is not installable. */
internal fun installKindFor(file: File?): InstallKind? =
when (file?.extension?.lowercase()) {
"apk" -> InstallKind.APK
PLUGIN_ARCHIVE_EXTENSION -> InstallKind.PLUGIN
TEMPLATE_ARCHIVE_EXTENSION -> InstallKind.TEMPLATE
else -> null
}

/**
* An explicit VIEW intent that hands [file] to [ExternalFileInstallActivity] as a FileProvider
* `content://` Uri, so the editor reuses the install dialogs built for files opened from other apps.
*/
internal fun templateInstallIntent(
context: Context,
file: File,
): Intent =
Intent(Intent.ACTION_VIEW)
.setClass(context, ExternalFileInstallActivity::class.java)
.setData(context.fileProviderUriFor(file))
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)

class InstallFileAction(
context: Context,
Expand All @@ -50,22 +84,22 @@ class InstallFileAction(
return
}
val currentFile = activity.editorViewModel.getCurrentFile()
visible = currentFile?.extension?.lowercase() in setOf("apk", PLUGIN_ARCHIVE_EXTENSION)
visible = installKindFor(currentFile) != null
enabled = visible
}

override fun EditorHandlerActivity.doAction(data: ActionData): Boolean {
val file = editorViewModel.getCurrentFile() ?: return false
when (file.extension.lowercase()) {
"apk" -> {
when (installKindFor(file)) {
InstallKind.APK -> {
apkInstallationViewModel.installApk(
context = this,
apk = file,
launchInDebugMode = false,
)
}

PLUGIN_ARCHIVE_EXTENSION -> {
InstallKind.PLUGIN -> {
lifecycleScope.launch {
val repo = GlobalContext.get().get<PluginRepository>()
repo
Expand All @@ -78,6 +112,27 @@ class InstallFileAction(
}
}
}

InstallKind.TEMPLATE -> {
// Defensive: the provider declares a root-path, so getUriForFile only throws when the path
// cannot be canonicalised (an IOException it wraps). Flash the "could not read" error the
// shared install screen uses instead of crashing the editor, and keep the cause in the log.
try {
startActivity(templateInstallIntent(this, file))
} catch (e: IllegalArgumentException) {
log.warn("Could not start the template install for {}", file.name, e)
flashError(getString(R.string.msg_invalid_incoming_file))
return false
} catch (e: ActivityNotFoundException) {
log.warn("Could not start the template install for {}", file.name, e)
flashError(getString(R.string.msg_invalid_incoming_file))
return false
}
}

null -> {
return false
}
}
return true
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ 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.
* email attachment) or handed over by the editor's Install action for a `.cgt` tab (via a
Comment thread
davidschachterADFA marked this conversation as resolved.
Resolved
* FileProvider Uri, see `templateInstallIntent`), 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
* `singleTask` (manifest) + [onNewIntent] collapse a rapid double-tap on the same 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,9 @@ private sealed interface DialogUiState {
}

/**
* Standalone host: the activity opened for a `.cgp`/`.cgt` from outside the app, which forwards
* plugins to [PluginManagerActivity] and finishes itself when the flow ends.
* Standalone host: the activity opened for a `.cgp`/`.cgt` from outside the app, or for a `.cgt`
* from the editor's Install action, which forwards plugins to [PluginManagerActivity] and finishes
* itself when the flow ends.
*/
@Suppress("ktlint:compose:vm-forwarding-check")
@Composable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ import org.slf4j.LoggerFactory
import java.io.File

/**
* Handles a `.cgp`/`.cgt` file opened from outside the app (e.g. an email attachment), backing
* Handles a `.cgp`/`.cgt` file opened from outside the app (e.g. an email attachment) or sent by
* the editor's Install action for a `.cgt` tab, backing
* [com.itsaky.androidide.activities.ExternalFileInstallActivity].
*/
class ExternalFileInstallViewModel(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package com.itsaky.androidide.actions.file

import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.test.core.app.ApplicationProvider
import com.google.common.truth.Truth.assertThat
import com.itsaky.androidide.actions.ActionData
import com.itsaky.androidide.activities.ExternalFileInstallActivity
import com.itsaky.androidide.activities.editor.EditorHandlerActivity
import com.itsaky.androidide.utils.fileProviderAuthority
import com.itsaky.androidide.utils.fileProviderUriFor
import com.itsaky.androidide.utils.flashError
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.slot
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.File

@RunWith(RobolectricTestRunner::class)
class InstallFileActionTest {
Comment thread
davidschachterADFA marked this conversation as resolved.
Resolved
private val activity = mockk<EditorHandlerActivity>(relaxed = true)
private val data = ActionData.create(activity)
private val context = ApplicationProvider.getApplicationContext<Context>()
private val uri = Uri.parse("content://test.providers.fileprovider/a.cgt")

@After
fun tearDown() {
unmockkStatic(FILE_PROVIDER_UTILS, FLASHBAR_UTILS)
}

private fun currentFile(file: File?) {
every { activity.editorViewModel.getOpenedFiles() } returns listOfNotNull(file)
every { activity.editorViewModel.getCurrentFile() } returns file
}

@Test
fun `cgt maps to TEMPLATE in any case`() {
assertThat(installKindFor(File("a.cgt"))).isEqualTo(InstallKind.TEMPLATE)
assertThat(installKindFor(File("a.CGT"))).isEqualTo(InstallKind.TEMPLATE)
}

@Test
fun `cgp and apk keep their kinds`() {
assertThat(installKindFor(File("a.cgp"))).isEqualTo(InstallKind.PLUGIN)
assertThat(installKindFor(File("a.Cgp"))).isEqualTo(InstallKind.PLUGIN)
assertThat(installKindFor(File("a.apk"))).isEqualTo(InstallKind.APK)
assertThat(installKindFor(File("a.APK"))).isEqualTo(InstallKind.APK)
}

@Test
fun `other extensions, no extension and no file are not installable`() {
assertThat(installKindFor(File("a.zip"))).isNull()
assertThat(installKindFor(File("a.txt"))).isNull()
assertThat(installKindFor(File("cgt"))).isNull()
assertThat(installKindFor(File("a.cgt.bak"))).isNull()
assertThat(installKindFor(null)).isNull()
}

@Test
fun `template install intent targets the install activity with a FileProvider uri and read grant`() {
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
val file = File(context.filesDir, "my.cgt").apply { writeText("x") }

val intent = templateInstallIntent(context, file)

assertThat(intent.action).isEqualTo(Intent.ACTION_VIEW)
assertThat(intent.component?.className).isEqualTo(ExternalFileInstallActivity::class.java.name)
assertThat(intent.data?.scheme).isEqualTo("content")
assertThat(intent.data?.authority).isEqualTo(context.fileProviderAuthority())
assertThat(intent.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION)
.isEqualTo(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}

@Test
fun `Install is offered for a cgt tab in any case and hidden for other files`() {
for ((name, visible) in listOf("a.cgt" to true, "a.CGT" to true, "a.cgp" to true, "a.apk" to true, "a.txt" to false)) {
currentFile(File(name))

val action = InstallFileAction(context, 0).apply { prepare(data) }

assertThat(action.visible).isEqualTo(visible)
assertThat(action.enabled).isEqualTo(visible)
}
}

@Test
fun `tapping Install on a cgt starts the install activity with the file's FileProvider uri`() {
mockkStatic(FILE_PROVIDER_UTILS)
every { any<Context>().fileProviderUriFor(any()) } returns uri
currentFile(File("a.cgt"))

val result = runBlocking { InstallFileAction(context, 0).execAction(data) }

val started = slot<Intent>()
verify(exactly = 1) { activity.startActivity(capture(started)) }
assertThat(result).isEqualTo(true)
assertThat(started.captured.component?.className).isEqualTo(ExternalFileInstallActivity::class.java.name)
assertThat(started.captured.data).isEqualTo(uri)
}

@Test
fun `a failed launch flashes the error and reports failure instead of crashing`() {
mockkStatic(FILE_PROVIDER_UTILS, FLASHBAR_UTILS)
every { any<Context>().fileProviderUriFor(any()) } returns uri
every { any<Activity>().flashError(any<String>()) } just runs
every { activity.startActivity(any<Intent>()) } throws ActivityNotFoundException()
currentFile(File("a.cgt"))

val result = runBlocking { InstallFileAction(context, 0).execAction(data) }

assertThat(result).isEqualTo(false)
verify(exactly = 1) { activity.flashError(any<String>()) }
}

private companion object {
const val FILE_PROVIDER_UTILS = "com.itsaky.androidide.utils.FileProviderUtilsKt"
const val FLASHBAR_UTILS = "com.itsaky.androidide.utils.FlashbarActivityUtilsKt"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.itsaky.androidide.repositories.PluginRepository
import com.itsaky.androidide.repositories.TemplateCollectionRepository
import com.itsaky.androidide.ui.models.ExternalFileInstallUiEffect
import com.itsaky.androidide.ui.models.ExternalFileInstallUiEvent
import com.itsaky.androidide.utils.fileProviderUriFor
import com.itsaky.androidide.viewmodel.MainDispatcherRule
import io.mockk.coEvery
import io.mockk.coVerify
Expand Down Expand Up @@ -139,6 +140,41 @@ class ExternalFileInstallViewModelTest {
assertThat((first as ExternalFileInstallUiEffect.ShowTemplateNameConflict).existingName).isEqualTo("my-templates")
}

@Test
fun `installing a cgt from the app's FileProvider consumes a copy and leaves the source file`() =
runTest {
stubTemplatesFeatureAvailable(true)
val info = TemplateCollectionRepository.CollectionInfo(templateNames = listOf("Empty Activity"))
var inspectedText: String? = null
coEvery { templateCollectionRepository.inspectCollection(any()) } coAnswers {
inspectedText = firstArg<File>().readText()
Result.success(info)
}
coEvery { templateCollectionRepository.findExistingCollision(any()) } returns null
// TemplateCollectionRepositoryImpl.installCollection deletes the file it is given on success.
coEvery { templateCollectionRepository.installCollection(any(), any(), any()) } coAnswers {
firstArg<File>().delete()
Result.success(Unit)
}
val file = File(context.filesDir, "editor.cgt").apply { writeText("dummy") }

viewModel.onReceived(context.fileProviderUriFor(file))

val confirmation = viewModel.uiEffect.first() as ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation
assertThat(confirmation.suggestedBaseName).isEqualTo("editor")
assertThat(inspectedText).isEqualTo("dummy")
assertThat(confirmation.tempFile.canonicalFile).isNotEqualTo(file.canonicalFile)

viewModel.onEvent(
ExternalFileInstallUiEvent.ConfirmTemplateInstall(confirmation.tempFile, confirmation.suggestedBaseName, overwrite = false),
)
viewModel.uiEffect.first() as ExternalFileInstallUiEffect.ShowSuccess

// The install consumed the temp copy; the editor's own file must still be there.
assertThat(confirmation.tempFile.exists()).isFalse()
assertThat(file.exists()).isTrue()
}

@Test
fun `a second onReceived for a different file cleans up the first file's still-pending temp copy`() =
runTest {
Expand Down
Loading