diff --git a/HTTPShortcuts/app/build.gradle.kts b/HTTPShortcuts/app/build.gradle.kts
index 0917cf755..8b82a1d21 100644
--- a/HTTPShortcuts/app/build.gradle.kts
+++ b/HTTPShortcuts/app/build.gradle.kts
@@ -224,6 +224,27 @@ android {
}
}
+val shellApkTemplateAssetDir = layout.buildDirectory.dir("generated/assets/shell-apk-template").get().asFile
+
+val copyShellApkTemplate by tasks.registering(Copy::class) {
+ dependsOn(":shell_apk_template:assembleRelease")
+ from(project(":shell_apk_template").layout.buildDirectory.file("outputs/apk/release/shell_apk_template-release-unsigned.apk")) {
+ rename { "shell-apk-template.apk" }
+ }
+ into(shellApkTemplateAssetDir)
+}
+
+android.sourceSets.getByName("main").assets.srcDir(shellApkTemplateAssetDir)
+
+tasks.configureEach {
+ if (name.startsWith("merge") && name.endsWith("Assets")) {
+ dependsOn(copyShellApkTemplate)
+ }
+ if (name.contains("Lint", ignoreCase = true)) {
+ dependsOn(copyShellApkTemplate)
+ }
+}
+
composeCompiler {
includeSourceInformation = true
stabilityConfigurationFiles.add(rootProject.layout.projectDirectory.file("stability_config.conf"))
@@ -357,6 +378,9 @@ dependencies {
/* Reading & writing zip files for Import & Export */
implementation(libs.zip4j)
+ /* Signing generated shell APKs */
+ implementation(libs.apksig)
+
/* Google Assistant integration */
"releaseFullImplementation"(libs.androidx.googleShortcuts)
diff --git a/HTTPShortcuts/app/src/main/AndroidManifest.xml b/HTTPShortcuts/app/src/main/AndroidManifest.xml
index e89a0e315..109c13760 100644
--- a/HTTPShortcuts/app/src/main/AndroidManifest.xml
+++ b/HTTPShortcuts/app/src/main/AndroidManifest.xml
@@ -14,6 +14,7 @@
+
@@ -63,6 +64,12 @@
+
+
+
+
+
+
@@ -296,6 +303,13 @@
+
+
Unit,
onPlaceShortcutOnHomeScreen: (ShortcutPlaceholder) -> Unit,
+ onInstallShortcutAsApp: (ShortcutPlaceholder) -> Unit,
onRemoveShortcutFromHomeScreen: (ShortcutPlaceholder) -> Unit,
onSelectShortcut: (ShortcutId) -> Unit,
onLongPress: () -> Unit,
@@ -103,6 +104,7 @@ fun MainContent(
isActive = index == pagerState.currentPage,
highlightedShortcutId = highlightedShortcutId,
onPlaceShortcutOnHomeScreen = onPlaceShortcutOnHomeScreen,
+ onInstallShortcutAsApp = onInstallShortcutAsApp,
onRemoveShortcutFromHomeScreen = onRemoveShortcutFromHomeScreen,
onSelectShortcut = onSelectShortcut,
onLongPress = onLongPress,
diff --git a/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/activities/main/MainDialogState.kt b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/activities/main/MainDialogState.kt
index 75c277319..9b172aa8c 100644
--- a/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/activities/main/MainDialogState.kt
+++ b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/activities/main/MainDialogState.kt
@@ -21,6 +21,9 @@ sealed class MainDialogState {
@Stable
data object ShortcutPlacement : MainDialogState()
+ @Stable
+ data object ShellApkUnknownSourcesPermissionRequired : MainDialogState()
+
@Stable
data class Unlock(
val tryAgain: Boolean = false,
diff --git a/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/activities/main/MainDialogs.kt b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/activities/main/MainDialogs.kt
index b0998276c..715ef265a 100644
--- a/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/activities/main/MainDialogs.kt
+++ b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/activities/main/MainDialogs.kt
@@ -29,6 +29,7 @@ fun MainDialogs(
onRecoveryConfirmed: () -> Unit,
onRecoveryDiscarded: () -> Unit,
onShortcutPlacementConfirmed: (useLegacy: Boolean) -> Unit,
+ onShellApkPermissionConfirmed: () -> Unit,
onNetworkRestrictionsWarningHidden: (Boolean) -> Unit,
onUnlockDialogSubmitted: (String) -> Unit,
onEditCategoryClicked: () -> Unit,
@@ -83,6 +84,14 @@ fun MainDialogs(
onDismissed = onDismissed,
)
}
+ is MainDialogState.ShellApkUnknownSourcesPermissionRequired -> {
+ ConfirmDialog(
+ title = stringResource(R.string.action_install_as_app),
+ message = stringResource(R.string.message_shell_apk_unknown_sources_permission_required),
+ onConfirmRequest = onShellApkPermissionConfirmed,
+ onDismissRequest = onDismissed,
+ )
+ }
is MainDialogState.Unlock -> {
UnlockAppDialog(
tryAgain = dialogState.tryAgain,
diff --git a/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/activities/main/MainScreen.kt b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/activities/main/MainScreen.kt
index 1262bd6c9..fafa0d9ef 100644
--- a/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/activities/main/MainScreen.kt
+++ b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/activities/main/MainScreen.kt
@@ -169,6 +169,7 @@ fun MainScreen(
highlightedShortcutId = viewState.highlightedShortcutId,
onActiveCategoryIdChanged = viewModel::onActiveCategoryChanged,
onPlaceShortcutOnHomeScreen = viewModel::onPlaceShortcutOnHomeScreen,
+ onInstallShortcutAsApp = viewModel::onInstallShortcutAsApp,
onRemoveShortcutFromHomeScreen = viewModel::onRemoveShortcutFromHomeScreen,
onSelectShortcut = viewModel::onSelectShortcut,
onLongPress = viewModel::onLongPress,
@@ -183,6 +184,7 @@ fun MainScreen(
onRecoveryConfirmed = viewModel::onRecoveryConfirmed,
onRecoveryDiscarded = viewModel::onRecoveryDiscarded,
onShortcutPlacementConfirmed = viewModel::onShortcutPlacementConfirmed,
+ onShellApkPermissionConfirmed = viewModel::onShellApkPermissionConfirmed,
onNetworkRestrictionsWarningHidden = viewModel::onNetworkRestrictionsWarningHidden,
onUnlockDialogSubmitted = viewModel::onUnlockDialogSubmitted,
onEditCategoryClicked = viewModel::onEditCategoryClicked,
diff --git a/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/activities/main/MainViewModel.kt b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/activities/main/MainViewModel.kt
index de421c826..f2245ee4c 100644
--- a/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/activities/main/MainViewModel.kt
+++ b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/activities/main/MainViewModel.kt
@@ -2,6 +2,7 @@ package ch.rmy.android.http_shortcuts.activities.main
import android.app.Activity
import android.app.Application
+import android.content.Intent
import android.net.Uri
import androidx.lifecycle.viewModelScope
import ch.rmy.android.framework.extensions.context
@@ -40,6 +41,8 @@ import ch.rmy.android.http_shortcuts.icons.ShortcutIcon
import ch.rmy.android.http_shortcuts.navigation.NavigationArgStore
import ch.rmy.android.http_shortcuts.navigation.NavigationDestination
import ch.rmy.android.http_shortcuts.scheduling.ExecutionScheduler
+import ch.rmy.android.http_shortcuts.shell_apk.InvalidShellApkException
+import ch.rmy.android.http_shortcuts.shell_apk.ShellApkInstaller
import ch.rmy.android.http_shortcuts.sync.ObserveSyncReplaceUseCase
import ch.rmy.android.http_shortcuts.utils.ActivityCloser
import ch.rmy.android.http_shortcuts.utils.AppOverlayUtil
@@ -98,6 +101,7 @@ constructor(
private val navigationArgStore: NavigationArgStore,
private val observeSyncReplace: ObserveSyncReplaceUseCase,
private val shortcutUpdateWorkerStarter: ShortcutUpdateWorker.Starter,
+ private val shellApkInstaller: ShellApkInstaller,
) : BaseViewModel(application) {
private lateinit var categories: List
@@ -106,6 +110,7 @@ constructor(
get() = initData.selectionMode
private var activeShortcutId: ShortcutId? = null
+ private var pendingShellApkPermissionIntent: Intent? = null
private var settingsRequestHandled: Boolean = false
private var switchedAwayFromInitialCategory = false
@@ -636,6 +641,38 @@ constructor(
placeShortcutOnHomeScreen(shortcut)
}
+ fun onInstallShortcutAsApp(shortcut: ShortcutPlaceholder) = runAction {
+ withProgressTracking {
+ try {
+ val result = shellApkInstaller.prepareInstall(
+ shortcutId = shortcut.id,
+ shortcutName = shortcut.name,
+ shortcutIcon = shortcut.icon,
+ )
+ when (result) {
+ is ShellApkInstaller.Result.PermissionRequired -> {
+ pendingShellApkPermissionIntent = result.intent
+ updateDialogState(MainDialogState.ShellApkUnknownSourcesPermissionRequired)
+ }
+ is ShellApkInstaller.Result.ReadyToInstall -> {
+ sendIntent(result.intent)
+ }
+ }
+ } catch (e: InvalidShellApkException) {
+ showSnackbar(R.string.error_shell_apk_invalid, long = true)
+ } catch (e: Exception) {
+ handleUnexpectedError(e)
+ }
+ }
+ }
+
+ fun onShellApkPermissionConfirmed() = runAction {
+ val intent = pendingShellApkPermissionIntent ?: skipAction()
+ pendingShellApkPermissionIntent = null
+ updateDialogState(null)
+ sendIntent(intent)
+ }
+
fun onRemoveShortcutFromHomeScreen(shortcut: ShortcutPlaceholder) = runAction {
removeShortcutFromHomeScreen(shortcut)
shortcutUpdateWorkerStarter.invoke()
@@ -652,6 +689,9 @@ constructor(
MainDialogState.NetworkRestrictionsWarning,
)
} else {
+ if (viewState.dialogState is MainDialogState.ShellApkUnknownSourcesPermissionRequired) {
+ pendingShellApkPermissionIntent = null
+ }
updateDialogState(null)
}
}
diff --git a/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/activities/main/ShortcutListContent.kt b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/activities/main/ShortcutListContent.kt
index f9a7ca968..c8ef246cb 100644
--- a/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/activities/main/ShortcutListContent.kt
+++ b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/activities/main/ShortcutListContent.kt
@@ -41,6 +41,7 @@ fun ShortcutListContent(
isActive: Boolean,
highlightedShortcutId: ShortcutId?,
onPlaceShortcutOnHomeScreen: (ShortcutPlaceholder) -> Unit,
+ onInstallShortcutAsApp: (ShortcutPlaceholder) -> Unit,
onRemoveShortcutFromHomeScreen: (ShortcutPlaceholder) -> Unit,
onSelectShortcut: (ShortcutId) -> Unit,
onLongPress: () -> Unit,
@@ -77,6 +78,9 @@ fun ShortcutListContent(
is ShortcutListEvent.PlaceShortcutOnHomeScreen -> consume {
onPlaceShortcutOnHomeScreen(event.shortcut)
}
+ is ShortcutListEvent.InstallShortcutAsApp -> consume {
+ onInstallShortcutAsApp(event.shortcut)
+ }
is ShortcutListEvent.RemoveShortcutFromHomeScreen -> consume {
onRemoveShortcutFromHomeScreen(event.shortcut)
}
@@ -126,6 +130,7 @@ fun ShortcutListContent(
dialogState = state.dialogState,
isInSyncReplaceMode = isInSyncReplaceMode,
onPlaceOnHomeScreenOptionSelected = viewModel::onPlaceOnHomeScreenOptionSelected,
+ onInstallAsAppOptionSelected = viewModel::onInstallAsAppOptionSelected,
onExecuteOptionSelected = viewModel::onExecuteOptionSelected,
onCancelPendingExecutionOptionSelected = viewModel::onCancelPendingExecutionOptionSelected,
onEditOptionSelected = viewModel::onEditOptionSelected,
diff --git a/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/activities/main/ShortcutListDialogs.kt b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/activities/main/ShortcutListDialogs.kt
index ee91b3012..97110a5ea 100644
--- a/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/activities/main/ShortcutListDialogs.kt
+++ b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/activities/main/ShortcutListDialogs.kt
@@ -41,6 +41,7 @@ fun ShortcutListDialogs(
dialogState: ShortcutListDialogState?,
isInSyncReplaceMode: Boolean,
onPlaceOnHomeScreenOptionSelected: () -> Unit,
+ onInstallAsAppOptionSelected: () -> Unit,
onExecuteOptionSelected: () -> Unit,
onCancelPendingExecutionOptionSelected: () -> Unit,
onEditOptionSelected: () -> Unit,
@@ -106,6 +107,7 @@ fun ShortcutListDialogs(
isPending = dialogState.isPending,
isHidden = dialogState.isHidden,
onPlaceOnHomeScreenOptionSelected = onPlaceOnHomeScreenOptionSelected,
+ onInstallAsAppOptionSelected = onInstallAsAppOptionSelected,
onExecuteOptionSelected = onExecuteOptionSelected,
onCancelPendingExecutionOptionSelected = onCancelPendingExecutionOptionSelected,
onEditOptionSelected = onEditOptionSelected,
@@ -312,6 +314,7 @@ private fun ContextMenuDialog(
isPending: Boolean,
isHidden: Boolean,
onPlaceOnHomeScreenOptionSelected: () -> Unit,
+ onInstallAsAppOptionSelected: () -> Unit,
onExecuteOptionSelected: () -> Unit,
onCancelPendingExecutionOptionSelected: () -> Unit,
onEditOptionSelected: () -> Unit,
@@ -334,6 +337,12 @@ private fun ContextMenuDialog(
icon = painterResource(R.drawable.outline_add_to_home_screen),
onClick = onPlaceOnHomeScreenOptionSelected,
)
+ SelectDialogEntry(
+ horizontalPadding = horizontalPadding,
+ label = stringResource(R.string.action_install_as_app),
+ icon = painterResource(R.drawable.outline_touch_app_24),
+ onClick = onInstallAsAppOptionSelected,
+ )
SelectDialogEntry(
horizontalPadding = horizontalPadding,
label = stringResource(R.string.action_run),
diff --git a/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/activities/main/ShortcutListEvent.kt b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/activities/main/ShortcutListEvent.kt
index 9122bf92b..c17a3a5bd 100644
--- a/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/activities/main/ShortcutListEvent.kt
+++ b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/activities/main/ShortcutListEvent.kt
@@ -9,6 +9,8 @@ abstract class ShortcutListEvent : ViewModelEvent() {
data class PlaceShortcutOnHomeScreen(val shortcut: ShortcutPlaceholder) : ShortcutListEvent()
+ data class InstallShortcutAsApp(val shortcut: ShortcutPlaceholder) : ShortcutListEvent()
+
data class RemoveShortcutFromHomeScreen(val shortcut: ShortcutPlaceholder) : ShortcutListEvent()
data class SelectShortcut(val shortcutId: ShortcutId) : ShortcutListEvent()
diff --git a/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/activities/main/ShortcutListViewModel.kt b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/activities/main/ShortcutListViewModel.kt
index c65745405..fa5848bf0 100644
--- a/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/activities/main/ShortcutListViewModel.kt
+++ b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/activities/main/ShortcutListViewModel.kt
@@ -316,6 +316,13 @@ constructor(
emitEvent(ShortcutListEvent.PlaceShortcutOnHomeScreen(shortcut.toShortcutPlaceholder()))
}
+ fun onInstallAsAppOptionSelected() = runAction {
+ updateDialogState(null)
+ val shortcutId = activeShortcutId ?: skipAction()
+ val shortcut = getShortcutById(shortcutId) ?: skipAction()
+ emitEvent(ShortcutListEvent.InstallShortcutAsApp(shortcut.toShortcutPlaceholder()))
+ }
+
fun onExecuteOptionSelected() = runAction {
updateDialogState(null)
val shortcutId = activeShortcutId ?: skipAction()
diff --git a/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/shell_apk/BinaryXmlStringPoolEditor.kt b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/shell_apk/BinaryXmlStringPoolEditor.kt
new file mode 100644
index 000000000..7d1d49625
--- /dev/null
+++ b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/shell_apk/BinaryXmlStringPoolEditor.kt
@@ -0,0 +1,205 @@
+package ch.rmy.android.http_shortcuts.shell_apk
+
+import java.io.ByteArrayOutputStream
+import java.nio.charset.Charset
+import javax.inject.Inject
+
+/**
+ * Performs narrow string replacement in an Android binary XML document.
+ *
+ * The shell APK template is already compiled when it is bundled into the main app, so its manifest is not plain XML
+ * anymore. For the generated shell APKs we only need to replace placeholder strings in the manifest string pool
+ * (package name, app label, and target URI), leaving the XML tree structure untouched.
+ */
+class BinaryXmlStringPoolEditor
+@Inject
+constructor() {
+
+ fun replaceStrings(binaryXml: ByteArray, replacements: Map): ByteArray {
+ if (binaryXml.readUShort(0) != RES_XML_TYPE) {
+ error("Not an Android binary XML document")
+ }
+
+ val stringPoolOffset = XML_HEADER_SIZE
+ if (binaryXml.readUShort(stringPoolOffset) != RES_STRING_POOL_TYPE) {
+ error("Binary XML string pool not found")
+ }
+
+ val oldStringPoolSize = binaryXml.readIntLE(stringPoolOffset + OFFSET_CHUNK_SIZE)
+ val newStringPool = rebuildStringPool(
+ chunk = binaryXml.copyOfRange(stringPoolOffset, stringPoolOffset + oldStringPoolSize),
+ replacements = replacements,
+ )
+ val newXmlSize = binaryXml.readIntLE(OFFSET_CHUNK_SIZE) + newStringPool.size - oldStringPoolSize
+
+ return ByteArrayOutputStream(newXmlSize).use { output ->
+ output.write(binaryXml, 0, stringPoolOffset)
+ output.write(newStringPool)
+ output.write(binaryXml, stringPoolOffset + oldStringPoolSize, binaryXml.size - stringPoolOffset - oldStringPoolSize)
+ output.toByteArray().also {
+ it.writeIntLE(OFFSET_CHUNK_SIZE, newXmlSize)
+ }
+ }
+ }
+
+ private fun rebuildStringPool(chunk: ByteArray, replacements: Map): ByteArray {
+ val headerSize = chunk.readUShort(OFFSET_HEADER_SIZE)
+ val stringCount = chunk.readIntLE(OFFSET_STRING_COUNT)
+ val styleCount = chunk.readIntLE(OFFSET_STYLE_COUNT)
+ val flags = chunk.readIntLE(OFFSET_FLAGS)
+ val stringsStart = chunk.readIntLE(OFFSET_STRINGS_START)
+ val stylesStart = chunk.readIntLE(OFFSET_STYLES_START)
+ val utf8 = flags and UTF8_FLAG != 0
+ val styleData = if (stylesStart == 0) {
+ ByteArray(0)
+ } else {
+ chunk.copyOfRange(stylesStart, chunk.size)
+ }
+
+ val strings = (0 until stringCount).map { index ->
+ val stringOffset = chunk.readIntLE(headerSize + index * 4)
+ val value = if (utf8) {
+ chunk.readUtf8String(stringsStart + stringOffset)
+ } else {
+ chunk.readUtf16String(stringsStart + stringOffset)
+ }
+ replacements[value] ?: value
+ }
+
+ val stringDataOutput = ByteArrayOutputStream()
+ val stringOffsets = IntArray(stringCount)
+ strings.forEachIndexed { index, value ->
+ stringOffsets[index] = stringDataOutput.size()
+ stringDataOutput.write(if (utf8) value.encodeUtf8String() else value.encodeUtf16String())
+ }
+ while (stringDataOutput.size() % 4 != 0) {
+ stringDataOutput.write(0)
+ }
+
+ val newStringData = stringDataOutput.toByteArray()
+ val newStylesStart = if (styleCount == 0) 0 else stringsStart + newStringData.size
+ val newChunkSize = stringsStart + newStringData.size + styleData.size
+ val output = chunk.copyOfRange(0, stringsStart).copyOf(newChunkSize)
+
+ // Replacing labels or package names can change the byte length of the pool, so all string offsets and chunk
+ // sizes are rebuilt rather than patched in-place.
+ output.writeIntLE(OFFSET_CHUNK_SIZE, newChunkSize)
+ output.writeIntLE(OFFSET_STYLES_START, newStylesStart)
+ stringOffsets.forEachIndexed { index, offset ->
+ output.writeIntLE(headerSize + index * 4, offset)
+ }
+ newStringData.copyInto(output, stringsStart)
+ if (styleData.isNotEmpty()) {
+ styleData.copyInto(output, newStylesStart)
+ }
+ return output
+ }
+
+ private fun ByteArray.readUtf8String(offset: Int): String {
+ var cursor = offset
+ cursor += readLength8(cursor).byteCount
+ val byteLength = readLength8(cursor)
+ cursor += byteLength.byteCount
+ return String(this, cursor, byteLength.value, Charsets.UTF_8)
+ }
+
+ private fun ByteArray.readUtf16String(offset: Int): String {
+ val length = readLength16(offset)
+ val cursor = offset + length.byteCount
+ return String(this, cursor, length.value * 2, UTF_16LE)
+ }
+
+ private fun String.encodeUtf8String(): ByteArray {
+ val bytes = toByteArray(Charsets.UTF_8)
+ return ByteArrayOutputStream().use { output ->
+ output.writeLength8(length)
+ output.writeLength8(bytes.size)
+ output.write(bytes)
+ output.write(0)
+ output.toByteArray()
+ }
+ }
+
+ private fun String.encodeUtf16String(): ByteArray {
+ val bytes = toByteArray(UTF_16LE)
+ return ByteArrayOutputStream().use { output ->
+ output.writeLength16(length)
+ output.write(bytes)
+ output.write(0)
+ output.write(0)
+ output.toByteArray()
+ }
+ }
+
+ private fun ByteArray.readLength8(offset: Int): EncodedLength {
+ val first = this[offset].toInt() and 0xFF
+ return if (first and 0x80 == 0) {
+ EncodedLength(first, 1)
+ } else {
+ EncodedLength(((first and 0x7F) shl 8) or (this[offset + 1].toInt() and 0xFF), 2)
+ }
+ }
+
+ private fun ByteArray.readLength16(offset: Int): EncodedLength {
+ val first = readUShort(offset)
+ return if (first and 0x8000 == 0) {
+ EncodedLength(first, 2)
+ } else {
+ EncodedLength(((first and 0x7FFF) shl 16) or readUShort(offset + 2), 4)
+ }
+ }
+
+ private fun ByteArrayOutputStream.writeLength8(length: Int) {
+ if (length > 0x7F) {
+ write((length shr 8) or 0x80)
+ }
+ write(length and 0xFF)
+ }
+
+ private fun ByteArrayOutputStream.writeLength16(length: Int) {
+ if (length > 0x7FFF) {
+ write(((length shr 16) and 0x7F) or 0x80)
+ write((length shr 24) and 0xFF)
+ }
+ write(length and 0xFF)
+ write((length shr 8) and 0xFF)
+ }
+
+ private fun ByteArray.readUShort(offset: Int): Int =
+ (this[offset].toInt() and 0xFF) or ((this[offset + 1].toInt() and 0xFF) shl 8)
+
+ private fun ByteArray.readIntLE(offset: Int): Int =
+ (this[offset].toInt() and 0xFF) or
+ ((this[offset + 1].toInt() and 0xFF) shl 8) or
+ ((this[offset + 2].toInt() and 0xFF) shl 16) or
+ ((this[offset + 3].toInt() and 0xFF) shl 24)
+
+ private fun ByteArray.writeIntLE(offset: Int, value: Int) {
+ this[offset] = (value and 0xFF).toByte()
+ this[offset + 1] = ((value shr 8) and 0xFF).toByte()
+ this[offset + 2] = ((value shr 16) and 0xFF).toByte()
+ this[offset + 3] = ((value shr 24) and 0xFF).toByte()
+ }
+
+ private data class EncodedLength(
+ val value: Int,
+ val byteCount: Int,
+ )
+
+ companion object {
+ private const val RES_STRING_POOL_TYPE = 0x0001
+ private const val RES_XML_TYPE = 0x0003
+ private const val XML_HEADER_SIZE = 8
+ private const val UTF8_FLAG = 0x00000100
+
+ private const val OFFSET_HEADER_SIZE = 2
+ private const val OFFSET_CHUNK_SIZE = 4
+ private const val OFFSET_STRING_COUNT = 8
+ private const val OFFSET_STYLE_COUNT = 12
+ private const val OFFSET_FLAGS = 16
+ private const val OFFSET_STRINGS_START = 20
+ private const val OFFSET_STYLES_START = 24
+
+ private val UTF_16LE = Charset.forName("UTF-16LE")
+ }
+}
diff --git a/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/shell_apk/InvalidShellApkException.kt b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/shell_apk/InvalidShellApkException.kt
new file mode 100644
index 000000000..168043770
--- /dev/null
+++ b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/shell_apk/InvalidShellApkException.kt
@@ -0,0 +1,3 @@
+package ch.rmy.android.http_shortcuts.shell_apk
+
+class InvalidShellApkException : Exception()
diff --git a/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/shell_apk/ShellApkBuilder.kt b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/shell_apk/ShellApkBuilder.kt
new file mode 100644
index 000000000..2474638b0
--- /dev/null
+++ b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/shell_apk/ShellApkBuilder.kt
@@ -0,0 +1,175 @@
+package ch.rmy.android.http_shortcuts.shell_apk
+
+import android.content.Context
+import android.content.pm.PackageManager
+import ch.rmy.android.http_shortcuts.data.domains.shortcuts.ShortcutId
+import ch.rmy.android.http_shortcuts.icons.ShortcutIcon
+import java.io.File
+import java.util.zip.CRC32
+import java.util.zip.ZipEntry
+import java.util.zip.ZipInputStream
+import java.util.zip.ZipOutputStream
+import javax.inject.Inject
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+class ShellApkBuilder
+@Inject
+constructor(
+ private val context: Context,
+ private val binaryXmlStringPoolEditor: BinaryXmlStringPoolEditor,
+ private val shellIconWriter: ShellIconWriter,
+ private val shellApkSigner: ShellApkSigner,
+) {
+
+ suspend fun build(
+ shortcutId: ShortcutId,
+ packageName: String,
+ appName: String,
+ icon: ShortcutIcon,
+ ): File =
+ withContext(Dispatchers.IO) {
+ val workDir = File(context.cacheDir, "shell-apk/$packageName")
+ workDir.deleteRecursively()
+ workDir.mkdirs()
+
+ val unsignedApk = File(workDir, "unsigned.apk")
+ val signedApk = File(workDir, "signed.apk")
+ val targetUri = "http-shortcuts://$shortcutId"
+ val iconBytes = shellIconWriter.createIconPng(icon)
+
+ context.assets.open(TEMPLATE_ASSET).use { input ->
+ // The template APK is a tiny, unsigned Android app bundled as an asset. Customizing it here keeps the
+ // shell apps independent from Gradle and avoids shipping source-generation tooling on the device.
+ rewriteTemplateApk(
+ templateBytes = input.readBytes(),
+ outputFile = unsignedApk,
+ packageName = packageName,
+ appName = appName.ifEmpty { "-" },
+ targetUri = targetUri,
+ iconBytes = iconBytes,
+ )
+ }
+ shellApkSigner.sign(unsignedApk, signedApk)
+ validateApk(signedApk)
+ signedApk
+ }
+
+ private fun rewriteTemplateApk(
+ templateBytes: ByteArray,
+ outputFile: File,
+ packageName: String,
+ appName: String,
+ targetUri: String,
+ iconBytes: ByteArray,
+ ) {
+ val entries = buildList {
+ var iconWritten = false
+ ZipInputStream(templateBytes.inputStream()).use { zipInput ->
+ while (true) {
+ val entry = zipInput.nextEntry ?: break
+ if (entry.name.startsWith("META-INF/")) {
+ continue
+ }
+
+ val newBytes = when {
+ entry.name == MANIFEST_ENTRY -> {
+ // AndroidManifest.xml is binary XML inside the APK, so placeholder values are replaced in
+ // its string pool instead of trying to parse it as text.
+ binaryXmlStringPoolEditor.replaceStrings(
+ zipInput.readBytes(),
+ mapOf(
+ TEMPLATE_PACKAGE_NAME to packageName,
+ TEMPLATE_APP_NAME to appName,
+ TEMPLATE_TARGET_URI to targetUri,
+ ),
+ )
+ }
+ entry.name.isTemplateIconEntry() -> {
+ // Resource shrinking/optimization gives launcher PNGs short generated names such as
+ // res/En.png, so the runtime replacement targets all template PNG resources.
+ iconWritten = true
+ iconBytes
+ }
+ else -> zipInput.readBytes()
+ }
+ add(
+ TemplateEntry(
+ name = entry.name,
+ bytes = newBytes,
+ method = entry.method.takeIf { it == ZipEntry.STORED } ?: ZipEntry.DEFLATED,
+ time = entry.time,
+ ),
+ )
+ }
+ }
+ if (!iconWritten) {
+ add(
+ TemplateEntry(
+ name = FALLBACK_ICON_ENTRY,
+ bytes = iconBytes,
+ method = ZipEntry.DEFLATED,
+ time = 0,
+ ),
+ )
+ }
+ }
+
+ outputFile.outputStream().use { fileOutput ->
+ ZipOutputStream(fileOutput).use { zipOutput ->
+ entries
+ .sortedBy { if (it.name == RESOURCES_ENTRY) 0 else 1 }
+ .forEach { entry ->
+ zipOutput.writeEntry(entry)
+ }
+ }
+ }
+ }
+
+ private fun String.isTemplateIconEntry(): Boolean =
+ startsWith("res/") && endsWith(".png")
+
+ private fun ZipOutputStream.writeEntry(entry: TemplateEntry) {
+ val zipEntry = ZipEntry(entry.name).also { zipEntry ->
+ zipEntry.time = entry.time
+ if (entry.method == ZipEntry.STORED) {
+ // STORED entries must have their size and CRC set before writing, otherwise Android may reject the APK
+ // even though the ZIP can still be read by lenient desktop tooling.
+ val crc = CRC32().apply {
+ update(entry.bytes)
+ }
+ zipEntry.method = ZipEntry.STORED
+ zipEntry.size = entry.bytes.size.toLong()
+ zipEntry.compressedSize = entry.bytes.size.toLong()
+ zipEntry.crc = crc.value
+ }
+ }
+ putNextEntry(zipEntry)
+ write(entry.bytes)
+ closeEntry()
+ }
+
+ @Suppress("DEPRECATION")
+ private fun validateApk(apkFile: File) {
+ context.packageManager.getPackageArchiveInfo(apkFile.absolutePath, PackageManager.GET_ACTIVITIES)
+ ?: throw InvalidShellApkException()
+ }
+
+ private data class TemplateEntry(
+ val name: String,
+ val bytes: ByteArray,
+ val method: Int,
+ val time: Long,
+ )
+
+ companion object {
+ private const val TEMPLATE_ASSET = "shell-apk-template.apk"
+ private const val MANIFEST_ENTRY = "AndroidManifest.xml"
+ private const val RESOURCES_ENTRY = "resources.arsc"
+ private const val FALLBACK_ICON_ENTRY = "res/drawable/ic_launcher_shell.png"
+
+ private const val TEMPLATE_PACKAGE_NAME = "ch.rmy.android.http_shortcuts.shelltemplate"
+ private const val TEMPLATE_APP_NAME = "HTTP Shortcuts Shell"
+ private const val TEMPLATE_TARGET_URI = "http-shortcuts://shell-template-placeholder"
+ }
+}
diff --git a/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/shell_apk/ShellApkInstallIntentFactory.kt b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/shell_apk/ShellApkInstallIntentFactory.kt
new file mode 100644
index 000000000..b935e810f
--- /dev/null
+++ b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/shell_apk/ShellApkInstallIntentFactory.kt
@@ -0,0 +1,33 @@
+package ch.rmy.android.http_shortcuts.shell_apk
+
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.provider.Settings
+import androidx.core.net.toUri
+import java.io.File
+import javax.inject.Inject
+
+class ShellApkInstallIntentFactory
+@Inject
+constructor(
+ private val context: Context,
+) {
+
+ fun canRequestPackageInstalls(): Boolean =
+ Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
+ context.packageManager.canRequestPackageInstalls()
+
+ fun createInstallIntent(apkFile: File): Intent =
+ ShellApkInstallerActivity.createIntent(context, apkFile)
+
+ fun createManageUnknownSourcesIntent(): Intent =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ Intent(
+ Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
+ "package:${context.packageName}".toUri(),
+ )
+ } else {
+ Intent(Settings.ACTION_SECURITY_SETTINGS)
+ }
+}
diff --git a/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/shell_apk/ShellApkInstaller.kt b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/shell_apk/ShellApkInstaller.kt
new file mode 100644
index 000000000..d79a1eaae
--- /dev/null
+++ b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/shell_apk/ShellApkInstaller.kt
@@ -0,0 +1,38 @@
+package ch.rmy.android.http_shortcuts.shell_apk
+
+import android.content.Intent
+import ch.rmy.android.http_shortcuts.data.domains.shortcuts.ShortcutId
+import ch.rmy.android.http_shortcuts.icons.ShortcutIcon
+import javax.inject.Inject
+
+class ShellApkInstaller
+@Inject
+constructor(
+ private val packageNameFactory: ShellApkPackageNameFactory,
+ private val shellApkBuilder: ShellApkBuilder,
+ private val installIntentFactory: ShellApkInstallIntentFactory,
+) {
+
+ suspend fun prepareInstall(
+ shortcutId: ShortcutId,
+ shortcutName: String,
+ shortcutIcon: ShortcutIcon,
+ ): Result {
+ if (!installIntentFactory.canRequestPackageInstalls()) {
+ return Result.PermissionRequired(installIntentFactory.createManageUnknownSourcesIntent())
+ }
+ val packageName = packageNameFactory.createPackageName(shortcutId)
+ val apkFile = shellApkBuilder.build(
+ shortcutId = shortcutId,
+ packageName = packageName,
+ appName = shortcutName,
+ icon = shortcutIcon,
+ )
+ return Result.ReadyToInstall(installIntentFactory.createInstallIntent(apkFile))
+ }
+
+ sealed interface Result {
+ data class ReadyToInstall(val intent: Intent) : Result
+ data class PermissionRequired(val intent: Intent) : Result
+ }
+}
diff --git a/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/shell_apk/ShellApkInstallerActivity.kt b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/shell_apk/ShellApkInstallerActivity.kt
new file mode 100644
index 000000000..386e1e6e5
--- /dev/null
+++ b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/shell_apk/ShellApkInstallerActivity.kt
@@ -0,0 +1,214 @@
+package ch.rmy.android.http_shortcuts.shell_apk
+
+import android.app.Activity
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageInstaller
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Bundle
+import androidx.core.net.toUri
+import ch.rmy.android.framework.extensions.showToast
+import ch.rmy.android.framework.extensions.logException
+import ch.rmy.android.http_shortcuts.R
+import java.io.File
+
+class ShellApkInstallerActivity : Activity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ try {
+ if (intent.isShellApkInstallStatusIntent()) {
+ handleInstallStatus(intent)
+ return
+ }
+
+ val apkFile = File(intent.getStringExtra(EXTRA_APK_PATH).orEmpty())
+ if (!apkFile.isFile) {
+ showToast(R.string.error_shell_apk_invalid, long = true)
+ finish()
+ return
+ }
+
+ val packageName = getArchivePackageName(apkFile)
+ if (packageName == null) {
+ showToast(R.string.error_shell_apk_invalid, long = true)
+ finish()
+ return
+ }
+
+ install(apkFile, packageName)
+ deleteTemporaryApkFiles(apkFile)
+ showToast(R.string.message_shell_apk_install_started)
+ } catch (e: Exception) {
+ logException(e)
+ showToast(getString(R.string.error_shell_apk_install_failed, e.message.orEmpty()), long = true)
+ } finally {
+ finish()
+ }
+ }
+
+ private fun handleInstallStatus(intent: Intent) {
+ when (val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) {
+ PackageInstaller.STATUS_PENDING_USER_ACTION -> {
+ intent.getConfirmationIntent()
+ ?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ ?.let(::startActivity)
+ ?: showInstallFailure("status=$status")
+ }
+ PackageInstaller.STATUS_SUCCESS -> {
+ // Some vendor installers deliver a late failure callback after success.
+ // Keep the package name so that callback can still verify the installed app.
+ showToast(R.string.message_shell_apk_installed)
+ }
+ else -> {
+ // Some vendor installers report STATUS_FAILURE_ABORTED after the package has already been installed.
+ // Trust the package manager over the callback before showing a failure to the user.
+ val expectedPackageNames =
+ intent.getShellApkExpectedPackageNames() + getPendingPackageName().orEmpty()
+ if (expectedPackageNames.any(::isPackageInstalled)) {
+ clearPendingPackageName()
+ showToast(R.string.message_shell_apk_installed)
+ } else {
+ clearPendingPackageName()
+ val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
+ .orEmpty()
+ .ifEmpty { "status=$status" }
+ showInstallFailure("status=$status, $message")
+ }
+ }
+ }
+ }
+
+ private fun deleteTemporaryApkFiles(apkFile: File) {
+ runCatching {
+ // PackageInstaller has copied the APK into its session after commit(), so the cache copy can be removed.
+ apkFile.parentFile?.deleteRecursively()
+ }.onFailure(::logException)
+ }
+
+ private fun install(apkFile: File, packageName: String) {
+ val packageInstaller = packageManager.packageInstaller
+ val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
+ .apply {
+ setAppPackageName(packageName)
+ setSize(apkFile.length())
+ }
+ val sessionId = packageInstaller.createSession(params)
+ var session: PackageInstaller.Session? = null
+ try {
+ rememberPendingPackageName(packageName)
+ session = packageInstaller.openSession(sessionId)
+ apkFile.inputStream().use { input ->
+ session.openWrite(APK_SESSION_NAME, 0, apkFile.length()).use { output ->
+ input.copyTo(output)
+ session.fsync(output)
+ }
+ }
+ session.commit(createStatusPendingIntent(sessionId, packageName).intentSender)
+ } catch (e: Exception) {
+ clearPendingPackageName()
+ packageInstaller.abandonSession(sessionId)
+ throw e
+ } finally {
+ session?.close()
+ }
+ }
+
+ @Suppress("DEPRECATION")
+ private fun getArchivePackageName(apkFile: File): String? =
+ packageManager.getPackageArchiveInfo(apkFile.absolutePath, 0)
+ ?.packageName
+
+ private fun isPackageInstalled(packageName: String): Boolean =
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0))
+ } else {
+ @Suppress("DEPRECATION")
+ packageManager.getPackageInfo(packageName, 0)
+ }
+ true
+ } catch (_: PackageManager.NameNotFoundException) {
+ false
+ }
+
+ private fun createStatusPendingIntent(sessionId: Int, packageName: String): PendingIntent =
+ PendingIntent.getActivity(
+ this,
+ sessionId,
+ Intent(this, ShellApkInstallerActivity::class.java)
+ // The action survives vendor PackageInstaller fill-in intents more reliably than extras or data.
+ .setAction("$SHELL_APK_INSTALL_STATUS_ACTION_PREFIX$packageName")
+ // Some vendor installers drop custom extras from the final status callback.
+ .setData("http-shortcuts-install:$packageName".toUri())
+ .putExtra(PackageInstaller.EXTRA_PACKAGE_NAME, packageName)
+ .putExtra(SHELL_APK_PACKAGE_NAME_EXTRA, packageName),
+ PendingIntent.FLAG_UPDATE_CURRENT or
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0,
+ )
+
+ private fun rememberPendingPackageName(packageName: String) {
+ getPreferences(MODE_PRIVATE)
+ .edit()
+ .putString(PENDING_PACKAGE_NAME, packageName)
+ .apply()
+ }
+
+ private fun getPendingPackageName(): String? =
+ getPreferences(MODE_PRIVATE)
+ .getString(PENDING_PACKAGE_NAME, null)
+ ?.takeIf { it.isNotEmpty() }
+
+ private fun clearPendingPackageName() {
+ getPreferences(MODE_PRIVATE)
+ .edit()
+ .remove(PENDING_PACKAGE_NAME)
+ .apply()
+ }
+
+ @Suppress("DEPRECATION")
+ private fun Intent.getConfirmationIntent(): Intent? =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
+ } else {
+ getParcelableExtra(Intent.EXTRA_INTENT)
+ }
+
+ private fun showInstallFailure(message: String) {
+ showToast(
+ getString(R.string.error_shell_apk_install_failed, message.ifEmpty { "unknown" }),
+ long = true,
+ )
+ }
+
+ companion object {
+ private const val APK_SESSION_NAME = "base.apk"
+ private const val EXTRA_APK_PATH = "apk_path"
+ private const val PENDING_PACKAGE_NAME = "pending_package_name"
+
+ fun createIntent(context: Context, apkFile: File): Intent =
+ Intent(context, ShellApkInstallerActivity::class.java)
+ .putExtra(EXTRA_APK_PATH, apkFile.absolutePath)
+ }
+}
+
+internal const val SHELL_APK_PACKAGE_NAME_EXTRA = "package_name"
+internal const val SHELL_APK_INSTALL_STATUS_ACTION_PREFIX =
+ "ch.rmy.android.http_shortcuts.shell_apk.INSTALL_STATUS."
+
+internal fun Intent.isShellApkInstallStatusIntent(): Boolean =
+ action?.startsWith(SHELL_APK_INSTALL_STATUS_ACTION_PREFIX) == true ||
+ hasExtra(PackageInstaller.EXTRA_STATUS)
+
+internal fun Intent.getShellApkExpectedPackageNames(): List =
+ listOfNotNull(
+ getStringExtra(PackageInstaller.EXTRA_PACKAGE_NAME),
+ getStringExtra(SHELL_APK_PACKAGE_NAME_EXTRA),
+ data?.schemeSpecificPart,
+ action
+ ?.takeIf { it.startsWith(SHELL_APK_INSTALL_STATUS_ACTION_PREFIX) }
+ ?.removePrefix(SHELL_APK_INSTALL_STATUS_ACTION_PREFIX),
+ ).filter(String::isNotEmpty).distinct()
diff --git a/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/shell_apk/ShellApkPackageNameFactory.kt b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/shell_apk/ShellApkPackageNameFactory.kt
new file mode 100644
index 000000000..9228e9cbf
--- /dev/null
+++ b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/shell_apk/ShellApkPackageNameFactory.kt
@@ -0,0 +1,21 @@
+package ch.rmy.android.http_shortcuts.shell_apk
+
+import ch.rmy.android.http_shortcuts.data.domains.shortcuts.ShortcutId
+import java.util.Locale
+import javax.inject.Inject
+
+class ShellApkPackageNameFactory
+@Inject
+constructor() {
+
+ fun createPackageName(shortcutId: ShortcutId): String =
+ "$PACKAGE_PREFIX${shortcutId.normalizedForPackageName()}"
+
+ private fun ShortcutId.normalizedForPackageName(): String =
+ lowercase(Locale.US)
+ .filter { it in 'a'..'z' || it in '0'..'9' }
+
+ companion object {
+ private const val PACKAGE_PREFIX = "ch.rmy.android.http_shortcuts.app_"
+ }
+}
diff --git a/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/shell_apk/ShellApkSigner.kt b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/shell_apk/ShellApkSigner.kt
new file mode 100644
index 000000000..a3842cb76
--- /dev/null
+++ b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/shell_apk/ShellApkSigner.kt
@@ -0,0 +1,78 @@
+package ch.rmy.android.http_shortcuts.shell_apk
+
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import com.android.apksig.ApkSigner
+import java.io.File
+import java.math.BigInteger
+import java.security.KeyPairGenerator
+import java.security.KeyStore
+import java.security.PrivateKey
+import java.security.cert.X509Certificate
+import java.util.Date
+import javax.inject.Inject
+import javax.security.auth.x500.X500Principal
+
+/**
+ * Signs generated shell APKs with one app-local key.
+ *
+ * Android requires every APK to be signed, including APKs generated locally on the device. The key is created once in
+ * Android Keystore and then reused so reinstalling a shell APK with the same package name works as an update.
+ */
+class ShellApkSigner
+@Inject
+constructor() {
+
+ fun sign(inputApk: File, outputApk: File) {
+ val signerConfig = getSignerConfig()
+ ApkSigner.Builder(listOf(signerConfig))
+ .setInputApk(inputApk)
+ .setOutputApk(outputApk)
+ .setMinSdkVersion(MIN_SDK_VERSION)
+ .setV1SigningEnabled(true)
+ .setV2SigningEnabled(true)
+ .setV3SigningEnabled(true)
+ .build()
+ .sign()
+ }
+
+ private fun getSignerConfig(): ApkSigner.SignerConfig {
+ val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply {
+ load(null)
+ }
+ if (!keyStore.containsAlias(KEY_ALIAS)) {
+ // The private key is intentionally non-exportable and scoped to this app install.
+ generateKey()
+ keyStore.load(null)
+ }
+ val privateKey = keyStore.getKey(KEY_ALIAS, null) as PrivateKey
+ val certificate = keyStore.getCertificate(KEY_ALIAS) as X509Certificate
+ return ApkSigner.SignerConfig.Builder(KEY_ALIAS, privateKey, listOf(certificate))
+ .build()
+ }
+
+ private fun generateKey() {
+ val generator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, ANDROID_KEYSTORE)
+ generator.initialize(
+ KeyGenParameterSpec.Builder(
+ KEY_ALIAS,
+ KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY,
+ )
+ .setCertificateSubject(X500Principal("CN=HTTP Shortcuts Shell APK"))
+ .setCertificateSerialNumber(BigInteger.ONE)
+ .setCertificateNotBefore(Date(0))
+ .setCertificateNotAfter(Date(System.currentTimeMillis() + CERTIFICATE_VALIDITY_MS))
+ .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512)
+ .setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1)
+ .build(),
+ )
+ generator.generateKeyPair()
+ }
+
+ companion object {
+ private const val ANDROID_KEYSTORE = "AndroidKeyStore"
+ private const val KEY_ALIAS = "http_shortcuts_shell_apk"
+ private const val MIN_SDK_VERSION = 26
+ private const val CERTIFICATE_VALIDITY_MS = 30L * 365L * 24L * 60L * 60L * 1000L
+ }
+}
diff --git a/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/shell_apk/ShellIconWriter.kt b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/shell_apk/ShellIconWriter.kt
new file mode 100644
index 000000000..1db6472c3
--- /dev/null
+++ b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/shell_apk/ShellIconWriter.kt
@@ -0,0 +1,58 @@
+package ch.rmy.android.http_shortcuts.shell_apk
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.drawable.Drawable
+import androidx.core.graphics.createBitmap
+import ch.rmy.android.http_shortcuts.icons.ShortcutIcon
+import ch.rmy.android.http_shortcuts.utils.IconUtil
+import java.io.ByteArrayOutputStream
+import javax.inject.Inject
+
+class ShellIconWriter
+@Inject
+constructor(
+ private val context: Context,
+) {
+
+ fun createIconPng(icon: ShortcutIcon): ByteArray {
+ val bitmap = loadBitmap(icon) ?: throw InvalidShellApkException()
+ return try {
+ ByteArrayOutputStream().use { output ->
+ bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)
+ output.toByteArray()
+ }
+ } finally {
+ bitmap.recycle()
+ }
+ }
+
+ private fun loadBitmap(icon: ShortcutIcon): Bitmap? =
+ try {
+ // Reuse the existing shortcut icon pipeline so custom icons, built-in icons, and external resource icons
+ // are rasterized the same way as home-screen shortcuts.
+ IconUtil.getIcon(context, icon, adaptive = false)
+ ?.loadDrawable(context)
+ ?.toBitmap()
+ } catch (_: Exception) {
+ null
+ }
+
+ private fun Drawable.toBitmap(): Bitmap {
+ val bitmap = createBitmap(ICON_SIZE, ICON_SIZE)
+ val canvas = Canvas(bitmap)
+ val oldBounds = copyBounds()
+ try {
+ setBounds(0, 0, ICON_SIZE, ICON_SIZE)
+ draw(canvas)
+ } finally {
+ bounds = oldBounds
+ }
+ return bitmap
+ }
+
+ companion object {
+ private const val ICON_SIZE = 192
+ }
+}
diff --git a/HTTPShortcuts/app/src/main/res/values-zh-rCN/strings.xml b/HTTPShortcuts/app/src/main/res/values-zh-rCN/strings.xml
index 7a364d3f4..e00f19b50 100644
--- a/HTTPShortcuts/app/src/main/res/values-zh-rCN/strings.xml
+++ b/HTTPShortcuts/app/src/main/res/values-zh-rCN/strings.xml
@@ -62,6 +62,8 @@
响应头、状态码, …
放在主屏幕上
+
+ 安装为应用
添加至桌面
@@ -394,6 +396,18 @@
%1$s 已删除
%1$s 已添加至桌面
+
+ 请允许 HTTP Shortcuts 安装未知来源应用,然后再次尝试安装此快捷方式。
+
+ 生成的安装包无效,无法安装。
+
+ 正在打开安装器…
+
+ 应用已安装。
+
+ 未找到对应的快捷方式,请卸载此应用
+
+ 安装失败:%1$s
删除
diff --git a/HTTPShortcuts/app/src/main/res/values-zh-rTW/strings.xml b/HTTPShortcuts/app/src/main/res/values-zh-rTW/strings.xml
index 736fa1f2d..5eba0010d 100644
--- a/HTTPShortcuts/app/src/main/res/values-zh-rTW/strings.xml
+++ b/HTTPShortcuts/app/src/main/res/values-zh-rTW/strings.xml
@@ -62,6 +62,8 @@
回應標頭、狀態代碼 …
放置於主畫面
+
+ 安裝為應用
放置於主畫面
@@ -384,6 +386,18 @@
「%1$s」已刪除。
「%1$s」已放置於主畫面。
+
+ 請允許 HTTP Shortcuts 安裝未知來源應用,然後再次嘗試安裝此捷徑。
+
+ 產生的安裝包無效,無法安裝。
+
+ 正在開啟安裝器…
+
+ 應用已安裝。
+
+ 未找到對應的捷徑,請解除安裝此應用
+
+ 安裝失敗:%1$s
刪除
diff --git a/HTTPShortcuts/app/src/main/res/values/strings.xml b/HTTPShortcuts/app/src/main/res/values/strings.xml
index 53015fc56..92dd781b9 100644
--- a/HTTPShortcuts/app/src/main/res/values/strings.xml
+++ b/HTTPShortcuts/app/src/main/res/values/strings.xml
@@ -62,6 +62,8 @@
Response Headers, Status Code, …
Place on Home Screen
+
+ Install as App
Place on Home Screen
@@ -399,6 +401,18 @@
\“%1$s\” deleted.
\“%1$s\” placed on home screen.
+
+ Allow HTTP Shortcuts to install unknown apps, then try installing the shortcut again.
+
+ The generated APK is invalid and cannot be installed.
+
+ Opening installer…
+
+ App installed.
+
+ Shortcut not found. Please uninstall this app.
+
+ Install failed: %1$s
Delete
diff --git a/HTTPShortcuts/app/src/test/kotlin/ch/rmy/android/testutils/DefaultModels.kt b/HTTPShortcuts/app/src/test/kotlin/ch/rmy/android/testutils/DefaultModels.kt
index 45cf5b6be..7f4258e62 100644
--- a/HTTPShortcuts/app/src/test/kotlin/ch/rmy/android/testutils/DefaultModels.kt
+++ b/HTTPShortcuts/app/src/test/kotlin/ch/rmy/android/testutils/DefaultModels.kt
@@ -33,6 +33,7 @@ object DefaultModels {
bodyContent = "",
timeout = 0,
isWaitForNetwork = false,
+ networkPreference = null,
securityPolicy = null,
launcherShortcut = false,
secondaryLauncherShortcut = false,
diff --git a/HTTPShortcuts/gradle/libs.versions.toml b/HTTPShortcuts/gradle/libs.versions.toml
index 644a82c84..3366fbdee 100644
--- a/HTTPShortcuts/gradle/libs.versions.toml
+++ b/HTTPShortcuts/gradle/libs.versions.toml
@@ -79,6 +79,7 @@ androidx-preference = { module = "androidx.preference:preference-ktx", version.r
androidx-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
androidx-test = { module = "androidx.test:core-ktx", version.ref = "androidxTestCore" }
androidx-work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntime" }
+apksig = { module = "com.android.tools.build:apksig", version.ref = "androidGradle" }
brotli = { module = "org.brotli:dec", version.ref = "brotli" }
bugsnag-android = { module = "com.bugsnag:bugsnag-android", version.ref = "bugsnagAndroid" }
bugsnag-gradle = { module = "com.bugsnag:bugsnag-android-gradle-plugin", version.ref = "bugsnag" }
diff --git a/HTTPShortcuts/settings.gradle.kts b/HTTPShortcuts/settings.gradle.kts
index d738b29c5..224127d14 100644
--- a/HTTPShortcuts/settings.gradle.kts
+++ b/HTTPShortcuts/settings.gradle.kts
@@ -4,6 +4,7 @@ include(":curl_command")
include(":favicon_grabber")
include(":icon_fetcher")
include(":scripting")
+include(":shell_apk_template")
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
diff --git a/HTTPShortcuts/shell_apk_template/build.gradle.kts b/HTTPShortcuts/shell_apk_template/build.gradle.kts
new file mode 100644
index 000000000..8b2c747b0
--- /dev/null
+++ b/HTTPShortcuts/shell_apk_template/build.gradle.kts
@@ -0,0 +1,125 @@
+import java.awt.Color
+import java.awt.image.BufferedImage
+import javax.imageio.ImageIO
+import javax.xml.XMLConstants
+import javax.xml.parsers.DocumentBuilderFactory
+import javax.xml.transform.OutputKeys
+import javax.xml.transform.TransformerFactory
+import javax.xml.transform.dom.DOMSource
+import javax.xml.transform.stream.StreamResult
+import org.w3c.dom.Element
+
+plugins {
+ id("com.android.application")
+}
+
+android {
+ namespace = "ch.rmy.android.http_shortcuts.shelltemplate"
+
+ compileSdk = 37
+
+ defaultConfig {
+ applicationId = "ch.rmy.android.http_shortcuts.shelltemplate"
+ minSdk = 26
+ targetSdk = 36
+ versionCode = 1
+ versionName = "1.0"
+ }
+}
+
+val generatedIconResDir = layout.buildDirectory.dir("generated/res/shell-template-icon").get().asFile
+val generatedStringResDir = layout.buildDirectory.dir("generated/res/shell-template-strings").get().asFile
+val generatedIconFiles = mapOf(
+ "mipmap-mdpi" to 48,
+ "mipmap-hdpi" to 72,
+ "mipmap-xhdpi" to 96,
+ "mipmap-xxhdpi" to 144,
+ "mipmap-xxxhdpi" to 192,
+).map { (density, size) ->
+ generatedIconResDir.resolve("$density/ic_launcher_shell.png") to size
+}
+
+val generateShellTemplateIcon by tasks.registering {
+ generatedIconFiles.forEach { (file, _) ->
+ outputs.file(file)
+ }
+
+ doLast {
+ generatedIconFiles.forEach { (file, size) ->
+ file.parentFile.mkdirs()
+ val image = BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB)
+ val graphics = image.createGraphics()
+ try {
+ graphics.color = Color(0x2D, 0x6C, 0xDF)
+ graphics.fillRect(0, 0, size, size)
+ graphics.color = Color.WHITE
+ graphics.fillRect((size * 0.31f).toInt(), (size * 0.33f).toInt(), (size * 0.38f).toInt(), (size * 0.08f).toInt())
+ graphics.fillRect((size * 0.31f).toInt(), (size * 0.46f).toInt(), (size * 0.38f).toInt(), (size * 0.08f).toInt())
+ graphics.fillRect((size * 0.31f).toInt(), (size * 0.58f).toInt(), (size * 0.25f).toInt(), (size * 0.08f).toInt())
+ } finally {
+ graphics.dispose()
+ }
+ ImageIO.write(image, "png", file)
+ }
+ }
+}
+
+val shellTemplateStringNames = setOf(
+ "message_shell_apk_shortcut_not_found",
+)
+
+// Keep shell APK text in the main app's translation files, but copy only the strings the tiny template needs.
+// This lets translators use the existing localization workflow without bloating every generated shell APK.
+val syncShellTemplateStrings by tasks.registering {
+ val appResDir = project(":app").layout.projectDirectory.dir("src/main/res").asFile
+ val appStringFiles = fileTree(appResDir) {
+ include("values*/strings.xml")
+ }
+
+ inputs.files(appStringFiles)
+ outputs.dir(generatedStringResDir)
+
+ doLast {
+ generatedStringResDir.deleteRecursively()
+
+ val documentBuilderFactory = DocumentBuilderFactory.newInstance().apply {
+ setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true)
+ }
+ val documentBuilder = documentBuilderFactory.newDocumentBuilder()
+ val transformer = TransformerFactory.newInstance()
+ .newTransformer()
+ .apply {
+ setOutputProperty(OutputKeys.ENCODING, "utf-8")
+ setOutputProperty(OutputKeys.INDENT, "yes")
+ }
+
+ appStringFiles.files.forEach { stringsFile ->
+ val inputDocument = documentBuilder.parse(stringsFile)
+ val outputDocument = documentBuilder.newDocument()
+ val outputResources = outputDocument.createElement("resources")
+ outputDocument.appendChild(outputResources)
+
+ val stringElements = inputDocument.documentElement.getElementsByTagName("string")
+ for (index in 0 until stringElements.length) {
+ val element = stringElements.item(index) as Element
+ if (element.getAttribute("name") in shellTemplateStringNames) {
+ outputResources.appendChild(outputDocument.importNode(element, true))
+ }
+ }
+
+ if (outputResources.childNodes.length > 0) {
+ val outputFile = generatedStringResDir.resolve("${stringsFile.parentFile.name}/strings.xml")
+ outputFile.parentFile.mkdirs()
+ transformer.transform(DOMSource(outputDocument), StreamResult(outputFile))
+ }
+ }
+ }
+}
+
+android.sourceSets.getByName("main").res.srcDir(generatedIconResDir)
+android.sourceSets.getByName("main").res.srcDir(generatedStringResDir)
+
+tasks.named("preBuild") {
+ dependsOn(generateShellTemplateIcon)
+ dependsOn(syncShellTemplateStrings)
+}
diff --git a/HTTPShortcuts/shell_apk_template/src/main/AndroidManifest.xml b/HTTPShortcuts/shell_apk_template/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..292f691c9
--- /dev/null
+++ b/HTTPShortcuts/shell_apk_template/src/main/AndroidManifest.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/HTTPShortcuts/shell_apk_template/src/main/java/ch/rmy/android/http_shortcuts/shelltemplate/ShellActivity.java b/HTTPShortcuts/shell_apk_template/src/main/java/ch/rmy/android/http_shortcuts/shelltemplate/ShellActivity.java
new file mode 100644
index 000000000..a0271bcc9
--- /dev/null
+++ b/HTTPShortcuts/shell_apk_template/src/main/java/ch/rmy/android/http_shortcuts/shelltemplate/ShellActivity.java
@@ -0,0 +1,54 @@
+package ch.rmy.android.http_shortcuts.shelltemplate;
+
+import android.app.Activity;
+import android.content.ActivityNotFoundException;
+import android.content.Intent;
+import android.content.pm.ActivityInfo;
+import android.content.pm.PackageManager;
+import android.net.Uri;
+import android.os.Bundle;
+import android.widget.Toast;
+
+public class ShellActivity extends Activity {
+
+ private static final String META_DATA_TARGET_URI = "target_uri";
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ String targetUri = getTargetUri();
+ if (targetUri == null || targetUri.length() == 0) {
+ showShortcutNotFoundMessage();
+ finish();
+ return;
+ }
+
+ try {
+ // The URI itself is injected into the generated APK manifest at install time.
+ Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(targetUri));
+ startActivity(intent);
+ } catch (ActivityNotFoundException | SecurityException e) {
+ showShortcutNotFoundMessage();
+ } finally {
+ finish();
+ }
+ }
+
+ private String getTargetUri() {
+ try {
+ ActivityInfo info = getPackageManager()
+ .getActivityInfo(getComponentName(), PackageManager.GET_META_DATA);
+ if (info.metaData == null) {
+ return null;
+ }
+ return info.metaData.getString(META_DATA_TARGET_URI);
+ } catch (PackageManager.NameNotFoundException e) {
+ return null;
+ }
+ }
+
+ private void showShortcutNotFoundMessage() {
+ Toast.makeText(this, R.string.message_shell_apk_shortcut_not_found, Toast.LENGTH_LONG).show();
+ }
+}
diff --git a/HTTPShortcuts/shell_apk_template/src/main/res/values/styles.xml b/HTTPShortcuts/shell_apk_template/src/main/res/values/styles.xml
new file mode 100644
index 000000000..04f7b06a2
--- /dev/null
+++ b/HTTPShortcuts/shell_apk_template/src/main/res/values/styles.xml
@@ -0,0 +1,4 @@
+
+
+
+