diff --git a/HTTPShortcuts/app/build.gradle.kts b/HTTPShortcuts/app/build.gradle.kts
index 47fcd3e96..0afde0a5b 100644
--- a/HTTPShortcuts/app/build.gradle.kts
+++ b/HTTPShortcuts/app/build.gradle.kts
@@ -225,6 +225,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"))
@@ -359,6 +380,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 37723825f..e844aa125 100644
--- a/HTTPShortcuts/app/src/main/AndroidManifest.xml
+++ b/HTTPShortcuts/app/src/main/AndroidManifest.xml
@@ -14,6 +14,7 @@
+
@@ -235,6 +236,13 @@
android:showWhenLocked="true"
android:theme="@style/Theme.Transparent" />
+
+
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/MainScreen.kt b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/activities/main/MainScreen.kt
index 1262bd6c9..45b766ddc 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,
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 893da8ee6..58d38d2d6 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
@@ -40,6 +40,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 +100,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
@@ -636,6 +639,32 @@ constructor(
placeShortcutOnHomeScreen(shortcut)
}
+ fun onInstallShortcutAsApp(shortcut: ShortcutPlaceholder) = runAction {
+ withProgressTracking {
+ try {
+ val result = shellApkInstaller.prepareInstall(
+ shortcutId = shortcut.id,
+ shortcutName = shortcut.name,
+ shortcutIcon = shortcut.icon,
+ allShortcutIds = shortcutRepository.getShortcuts().map { it.id },
+ )
+ when (result) {
+ is ShellApkInstaller.Result.PermissionRequired -> {
+ showSnackbar(R.string.message_shell_apk_unknown_sources_permission_required, long = true)
+ sendIntent(result.intent)
+ }
+ 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 onRemoveShortcutFromHomeScreen(shortcut: ShortcutPlaceholder) = runAction {
removeShortcutFromHomeScreen(shortcut)
shortcutUpdateWorkerStarter.invoke()
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 71f336d30..39c74467c 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 d256c7500..733165c7a 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
@@ -31,6 +31,7 @@ fun ShortcutListDialogs(
dialogState: ShortcutListDialogState?,
isInSyncReplaceMode: Boolean,
onPlaceOnHomeScreenOptionSelected: () -> Unit,
+ onInstallAsAppOptionSelected: () -> Unit,
onExecuteOptionSelected: () -> Unit,
onCancelPendingExecutionOptionSelected: () -> Unit,
onEditOptionSelected: () -> Unit,
@@ -95,6 +96,7 @@ fun ShortcutListDialogs(
isPending = dialogState.isPending,
isHidden = dialogState.isHidden,
onPlaceOnHomeScreenOptionSelected = onPlaceOnHomeScreenOptionSelected,
+ onInstallAsAppOptionSelected = onInstallAsAppOptionSelected,
onExecuteOptionSelected = onExecuteOptionSelected,
onCancelPendingExecutionOptionSelected = onCancelPendingExecutionOptionSelected,
onEditOptionSelected = onEditOptionSelected,
@@ -293,6 +295,7 @@ private fun ContextMenuDialog(
isPending: Boolean,
isHidden: Boolean,
onPlaceOnHomeScreenOptionSelected: () -> Unit,
+ onInstallAsAppOptionSelected: () -> Unit,
onExecuteOptionSelected: () -> Unit,
onCancelPendingExecutionOptionSelected: () -> Unit,
onEditOptionSelected: () -> Unit,
@@ -315,6 +318,12 @@ private fun ContextMenuDialog(
icon = painterResource(R.drawable.outline_menu_24),
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 81df6dc0d..ec8151156 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
@@ -288,6 +288,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..d8f2895d8
--- /dev/null
+++ b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/shell_apk/ShellApkBuilder.kt
@@ -0,0 +1,177 @@
+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) {
+ val packageInfo = context.packageManager.getPackageArchiveInfo(apkFile.absolutePath, PackageManager.GET_ACTIVITIES)
+ if (packageInfo == null) {
+ 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..0ecb19a69
--- /dev/null
+++ b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/shell_apk/ShellApkInstallIntentFactory.kt
@@ -0,0 +1,32 @@
+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 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,
+ android.net.Uri.parse("package:${context.packageName}"),
+ )
+ } 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..458df8d8d
--- /dev/null
+++ b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/shell_apk/ShellApkInstaller.kt
@@ -0,0 +1,39 @@
+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,
+ allShortcutIds: Collection,
+ ): Result {
+ if (!installIntentFactory.canRequestPackageInstalls()) {
+ return Result.PermissionRequired(installIntentFactory.createManageUnknownSourcesIntent())
+ }
+ val packageName = packageNameFactory.createPackageName(shortcutId, allShortcutIds)
+ 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..7e0f74a5f
--- /dev/null
+++ b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/shell_apk/ShellApkInstallerActivity.kt
@@ -0,0 +1,167 @@
+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 android.widget.Toast
+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.action == ACTION_INSTALL_STATUS) {
+ handleInstallStatus(intent)
+ return
+ }
+
+ val apkFile = File(intent.getStringExtra(EXTRA_APK_PATH).orEmpty())
+ if (!apkFile.isFile) {
+ Toast.makeText(this, R.string.error_shell_apk_invalid, Toast.LENGTH_LONG).show()
+ finish()
+ return
+ }
+
+ val packageName = getArchivePackageName(apkFile)
+ if (packageName == null) {
+ Toast.makeText(this, R.string.error_shell_apk_invalid, Toast.LENGTH_LONG).show()
+ finish()
+ return
+ }
+
+ install(apkFile, packageName)
+ deleteTemporaryApkFiles(apkFile)
+ Toast.makeText(this, R.string.message_shell_apk_install_started, Toast.LENGTH_SHORT).show()
+ } catch (e: Exception) {
+ logException(e)
+ Toast.makeText(this, getString(R.string.error_shell_apk_install_failed, e.message.orEmpty()), Toast.LENGTH_LONG).show()
+ } 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 -> {
+ Toast.makeText(this, R.string.message_shell_apk_installed, Toast.LENGTH_SHORT).show()
+ }
+ 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 expectedPackageName = intent.getStringExtra(EXTRA_PACKAGE_NAME)
+ if (expectedPackageName != null && isPackageInstalled(expectedPackageName)) {
+ Toast.makeText(this, R.string.message_shell_apk_installed, Toast.LENGTH_SHORT).show()
+ } else {
+ 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 {
+ 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) {
+ 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)
+ .setAction(ACTION_INSTALL_STATUS)
+ .putExtra(EXTRA_PACKAGE_NAME, packageName),
+ PendingIntent.FLAG_UPDATE_CURRENT or
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0,
+ )
+
+ @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) {
+ Toast.makeText(
+ this,
+ getString(R.string.error_shell_apk_install_failed, message.ifEmpty { "unknown" }),
+ Toast.LENGTH_LONG,
+ ).show()
+ }
+
+ companion object {
+ private const val ACTION_INSTALL_STATUS = "ch.rmy.android.http_shortcuts.shell_apk.INSTALL_STATUS"
+ private const val APK_SESSION_NAME = "base.apk"
+ private const val EXTRA_APK_PATH = "apk_path"
+ private const val EXTRA_PACKAGE_NAME = "package_name"
+
+ fun createIntent(context: Context, apkFile: File): Intent =
+ Intent(context, ShellApkInstallerActivity::class.java)
+ .putExtra(EXTRA_APK_PATH, apkFile.absolutePath)
+ }
+}
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..789f3d4ff
--- /dev/null
+++ b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/shell_apk/ShellApkPackageNameFactory.kt
@@ -0,0 +1,52 @@
+package ch.rmy.android.http_shortcuts.shell_apk
+
+import ch.rmy.android.http_shortcuts.data.domains.shortcuts.ShortcutId
+import java.util.Locale
+import java.util.zip.CRC32
+import javax.inject.Inject
+
+class ShellApkPackageNameFactory
+@Inject
+constructor() {
+
+ fun createPackageName(shortcutId: ShortcutId, allShortcutIds: Collection): String {
+ val normalizedId = shortcutId.normalizedForPackageName()
+ val candidate = normalizedId.take(8)
+ if (!candidate.hasCollision(shortcutId, allShortcutIds)) {
+ return "$PACKAGE_PREFIX$candidate"
+ }
+
+ val fallback = normalizedId.take(4) + normalizedId.takeLast(4)
+ if (!fallback.hasCollision(shortcutId, allShortcutIds)) {
+ return "$PACKAGE_PREFIX$fallback"
+ }
+
+ return "$PACKAGE_PREFIX${fallback}_${shortcutId.crc32Suffix()}"
+ }
+
+ private fun String.hasCollision(shortcutId: ShortcutId, allShortcutIds: Collection): Boolean =
+ allShortcutIds
+ .asSequence()
+ .filter { it != shortcutId }
+ .map { it.normalizedForPackageName() }
+ .any { otherId ->
+ otherId.take(length) == this ||
+ (length == 8 && otherId.take(4) + otherId.takeLast(4) == this)
+ }
+
+ private fun ShortcutId.normalizedForPackageName(): String =
+ lowercase(Locale.US)
+ .filter { it in 'a'..'z' || it in '0'..'9' || it == '_' }
+ .takeIf { it.isNotEmpty() }
+ ?: crc32Suffix()
+
+ private fun ShortcutId.crc32Suffix(): String {
+ val crc = CRC32()
+ crc.update(toByteArray())
+ return crc.value.toString(16).padStart(8, '0').takeLast(8)
+ }
+
+ companion object {
+ 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..ec1f1f064
--- /dev/null
+++ b/HTTPShortcuts/app/src/main/kotlin/ch/rmy/android/http_shortcuts/shell_apk/ShellIconWriter.kt
@@ -0,0 +1,73 @@
+package ch.rmy.android.http_shortcuts.shell_apk
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+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) ?: createFallbackBitmap()
+ 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
+ }
+
+ private fun createFallbackBitmap(): Bitmap {
+ val bitmap = createBitmap(ICON_SIZE, ICON_SIZE)
+ val canvas = Canvas(bitmap)
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG)
+ paint.color = Color.rgb(45, 108, 223)
+ canvas.drawRect(0f, 0f, ICON_SIZE.toFloat(), ICON_SIZE.toFloat(), paint)
+ paint.color = Color.WHITE
+ canvas.drawRect(60f, 64f, 132f, 80f, paint)
+ canvas.drawRect(60f, 88f, 132f, 104f, paint)
+ canvas.drawRect(60f, 112f, 108f, 128f, paint)
+ 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 15036b8fa..c5ff3804e 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/http_shortcuts/shell_apk/ShellApkPackageNameFactoryTest.kt b/HTTPShortcuts/app/src/test/kotlin/ch/rmy/android/http_shortcuts/shell_apk/ShellApkPackageNameFactoryTest.kt
new file mode 100644
index 000000000..3c46f0c47
--- /dev/null
+++ b/HTTPShortcuts/app/src/test/kotlin/ch/rmy/android/http_shortcuts/shell_apk/ShellApkPackageNameFactoryTest.kt
@@ -0,0 +1,62 @@
+package ch.rmy.android.http_shortcuts.shell_apk
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class ShellApkPackageNameFactoryTest {
+
+ private val factory = ShellApkPackageNameFactory()
+
+ @Test
+ fun `uses first eight normalized shortcut id characters`() {
+ assertEquals(
+ "ch.rmy.android.http_shortcuts.app_abcdef12",
+ factory.createPackageName(
+ shortcutId = "ABCDEF12-3456-7890",
+ allShortcutIds = listOf("ABCDEF12-3456-7890"),
+ ),
+ )
+ }
+
+ @Test
+ fun `uses first four and last four normalized shortcut id characters when first eight collide`() {
+ assertEquals(
+ "ch.rmy.android.http_shortcuts.app_abcd9999",
+ factory.createPackageName(
+ shortcutId = "abcd1111-2222-9999",
+ allShortcutIds = listOf(
+ "abcd1111-2222-9999",
+ "abcd1111-3333-8888",
+ ),
+ ),
+ )
+ }
+
+ @Test
+ fun `adds deterministic suffix when fallback also collides`() {
+ val packageName = factory.createPackageName(
+ shortcutId = "abcd1111-2222-9999",
+ allShortcutIds = listOf(
+ "abcd1111-2222-9999",
+ "abcd1111-3333-8888",
+ "abcd9999-4444-9999",
+ ),
+ )
+
+ assertEquals(
+ true,
+ packageName.startsWith("ch.rmy.android.http_shortcuts.app_abcd9999_"),
+ )
+ }
+
+ @Test
+ fun `normalizes invalid characters out of package suffix`() {
+ assertEquals(
+ "ch.rmy.android.http_shortcuts.app_shortcut",
+ factory.createPackageName(
+ shortcutId = "Shortcut ID!",
+ allShortcutIds = listOf("Shortcut ID!"),
+ ),
+ )
+ }
+}
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 f75fcb654..7725bc6df 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 @@
+
+
+
+