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 @@ + + +