Merge pull request #41 from The412Banner/feat/release-signing

Signing with DroidDeck's own key, four package names, file-picker access, CI hardening
This commit is contained in:
The412Banner authored and GitHub committed 2026-09-24 13:42:47 -04:00
commit 94f9935ad4
11 files changed
+737 -10

No files matched your search

+45 -10
View File
@@ -1,5 +1,5 @@
name: Build APK
run-name: ${{ github.event_name == 'pull_request_target' && format('building pr {0} on {1}', github.event.pull_request.number, github.event.pull_request.head.sha) || format('building {0} on {1}', github.ref_name, github.sha) }}
run-name: ${{ github.event_name == 'pull_request' && format('building pr {0} on {1}', github.event.pull_request.number, github.event.pull_request.head.sha) || format('building {0} on {1}', github.ref_name, github.sha) }}
# One job: the two glibc preload libraries the session needs, then the app around them.
# Nothing is built on a developer machine - this workflow is the only thing that produces an apk.
@@ -22,10 +22,15 @@ on:
# A pull request against main builds by itself: the contributor's branch, as it would be once
# merged onto main, comes back as an apk on the run without anyone here pressing a button.
# Every push to the pull request rebuilds it and cancels the build it replaces; a draft waits
# until it is marked ready. pull_request_target rather than pull_request: GitHub does not run
# the latter at all while a pull request conflicts with main, and saying what conflicts is the
# point. The workflow file is always main's; the pull request's own code is checked out below.
pull_request_target:
# until it is marked ready.
#
# pull_request, NOT pull_request_target. The _target form runs a pull request's code with main's
# context: its caches are main's caches, so a hostile pull request could leave a poisoned
# Gradle/CMake cache that main's next build - which is signed with DroidDeck's key - picks up.
# Under pull_request a fork's run has a read-only token, no secrets, and caches of its own that
# main never reads. The price: GitHub does not run pull_request while a pull request conflicts
# with main, so a conflicting one gets no build (the pull request page still shows the conflict).
pull_request:
branches: [ main ]
types: [ opened, synchronize, reopened, ready_for_review ]
paths-ignore:
@@ -50,7 +55,7 @@ env:
jobs:
build:
if: ${{ github.event_name != 'pull_request_target' || !github.event.pull_request.draft }}
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
@@ -59,15 +64,27 @@ jobs:
# the next step is to say so and show where.
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
# Full history: the merge below needs it (a shallow clone refuses to merge at all).
fetch-depth: 0
# The pull request as it would be once merged onto main is what gets built. When it cannot
# be merged, the conflicting files and the conflicting hunks go into the run summary and the
# pull-request comment, and the job stops here: there is nothing coherent to build.
# DroidDeck's signing key lives only in GitHub environment secrets (see sign.yml). A key file
# committed by mistake would be in the history for good: stop at the first one. The AOSP
# testkey is public and meant to be here.
- name: No private key in the tree
run: |
set -euo pipefail
keys=$(git ls-files | grep -iE '\.(jks|keystore|p12|pfx|pk8|pem|key)$' | grep -vx 'keystore/testkey.p12' || true)
if [ -n "$keys" ]; then
echo "::error::a private key is committed - remove it from the history and make a new one: $keys"
exit 1
fi
- name: Merge onto main, or say what conflicts
if: github.event_name == 'pull_request_target'
if: github.event_name == 'pull_request'
run: |
set -uo pipefail
git config user.name "ci"
@@ -415,7 +432,7 @@ jobs:
# build failed, the compiler's own errors with paths made short. Fork pull requests carry a
# read-only token and cannot comment; the same text is in the run summary either way.
- name: Report on the pull request
if: always() && github.event_name == 'pull_request_target'
if: always() && github.event_name == 'pull_request'
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
@@ -435,7 +452,7 @@ jobs:
success)
echo "### Build passed"
echo
echo "The apk is the \`droiddeck-apk\` artifact on [this run]($RUN_URL) (signed with the same test key as every build, so it installs over any other)."
echo "The apk is the \`droiddeck-apk\` artifact on [this run]($RUN_URL) A pull request's build is signed only with the public test key: it installs on a phone without an official DroidDeck. To install it over one, sign it with DroidDeck's key or ask for a test build of the branch."
;;
failure)
echo "### ERROR: Build failed"
@@ -480,3 +497,21 @@ jobs:
retention-days: 30
# An apk is a zip already; compressing it again buys nothing and costs seconds.
compression-level: 0
# The apk signed with DroidDeck's own key (tools/release), so a test build installs over a release
# and keeps its data. Main's builds sign straight away (signing-main); any other branch's wait for
# the owner's approval (signing-branch). A pull request's never: its code is not ours to sign, and
# the job that would hold the key never runs for it. The testkey apk above is unaffected.
sign:
needs: build
if: github.event_name != 'pull_request'
# What sign.yml asks for; a called workflow never gets more than its caller gives it.
permissions:
contents: read
actions: read
uses: ./.github/workflows/sign.yml
with:
environment: ${{ github.ref == 'refs/heads/main' && 'signing-main' || 'signing-branch' }}
variants: '["standard"]'
# The key is an environment secret; a called workflow sees secrets only when they are passed on.
secrets: inherit
+68
View File
@@ -0,0 +1,68 @@
name: Release APKs
run-name: release ${{ inputs.version }} from main ${{ github.sha }}
# Every package of a release, signed with DroidDeck's own key: the standard app and the named ones
# in tools/release/variants.txt, one matrix job each. Nothing is rebuilt - the apk is the one the
# Build APK run of this exact main commit made (with its caches) and that was tested, so the
# release is those bytes. Waits for the owner's approval (the release environment); publishing the
# GitHub release stays a separate, manual step.
on:
workflow_dispatch:
inputs:
version:
description: Version for the file names, e.g. 0.1.7
required: true
permissions:
contents: read
actions: read
concurrency:
group: release
cancel-in-progress: false
jobs:
find:
name: find the main build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
outputs:
run_id: ${{ steps.find.outputs.run_id }}
variants: ${{ steps.find.outputs.variants }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- id: find
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
[[ $VERSION =~ ^[0-9A-Za-z._-]+$ ]] || { echo "::error::version '$VERSION': letters, digits, . _ - only"; exit 1; }
# This commit's Build APK run - a push or a dispatch on main, never a pull request's.
runs=$(gh run list -R "$GITHUB_REPOSITORY" --workflow build.yml --branch main --commit "$GITHUB_SHA" \
--status success --json databaseId,event,headSha -L 20)
id=$(jq -r --arg sha "$GITHUB_SHA" \
'[.[] | select(.headSha == $sha and (.event == "push" or .event == "workflow_dispatch"))][0].databaseId // empty' <<<"$runs")
if [ -z "$id" ]; then
echo "::error::main ${GITHUB_SHA::7} has no successful Build APK run yet: wait for it, then release"
exit 1
fi
variants=$(awk '!/^#/ && NF { print $1 }' tools/release/variants.txt | jq -R . | jq -sc .)
echo "run_id=$id" >> "$GITHUB_OUTPUT"
echo "variants=$variants" >> "$GITHUB_OUTPUT"
echo "Signing Build APK run $id (main ${GITHUB_SHA::7}) as $VERSION: $variants" >> "$GITHUB_STEP_SUMMARY"
sign:
needs: find
uses: ./.github/workflows/sign.yml
with:
environment: release
variants: ${{ needs.find.outputs.variants }}
run_id: ${{ needs.find.outputs.run_id }}
label: ${{ inputs.version }}
artifact: droiddeck-release
# The key is an environment secret; a called workflow sees secrets only when they are passed on.
secrets: inherit
+105
View File
@@ -0,0 +1,105 @@
name: Sign APKs
# Called, never run by itself: turns a "Build APK" apk into packages signed with DroidDeck's own key,
# one matrix job per package in tools/release/variants.txt (tools/release/sign-apk.sh does the work
# and checks it). The key lives only in the GitHub environment named by the caller:
# signing-main builds of main
# signing-branch builds of any other branch - waits for the owner's approval
# release release.yml - main only, waits for the owner's approval
# Pull requests never get here (build.yml skips this for pull_request), so code from a pull
# request can never be signed with the key or come near it.
on:
workflow_call:
inputs:
environment:
type: string
required: true
variants:
description: JSON array of names from tools/release/variants.txt
type: string
required: true
run_id:
description: The Build APK run whose apk to sign; empty for the calling run's own
type: string
default: ''
label:
description: Goes in the file names (a version); empty for <branch>-<short sha>
type: string
default: ''
artifact:
type: string
default: droiddeck-signed
permissions:
contents: read
actions: read
jobs:
sign:
name: sign ${{ matrix.variant }}
runs-on: ubuntu-24.04
timeout-minutes: 15
environment: ${{ inputs.environment }}
strategy:
fail-fast: false
matrix:
variant: ${{ fromJSON(inputs.variants) }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: The apk (this run's build)
if: inputs.run_id == ''
uses: actions/download-artifact@v4
with:
name: droiddeck-apk
path: in
- name: The apk (a main build)
if: inputs.run_id != ''
env:
GH_TOKEN: ${{ github.token }}
RUN: ${{ inputs.run_id }}
run: gh run download "$RUN" -R "$GITHUB_REPOSITORY" -n droiddeck-apk -D in
- name: Sign
env:
RELEASE_KEYSTORE_B64: ${{ secrets.RELEASE_KEYSTORE_B64 }}
RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }}
# The alias is not a secret (and as one, GitHub would mask 'droiddeck' all over the logs).
RELEASE_KEY_ALIAS: droiddeck
ENVIRONMENT: ${{ inputs.environment }}
VARIANT: ${{ matrix.variant }}
LABEL: ${{ inputs.label }}
run: |
set -euo pipefail
if [ -z "$RELEASE_KEYSTORE_B64" ]; then
echo "::error::environment '$ENVIRONMENT' has no signing key: run tools/release/make-release-key.sh once"
exit 1
fi
export RELEASE_KEYSTORE="$RUNNER_TEMP/release.p12"
( umask 077; base64 -d <<<"$RELEASE_KEYSTORE_B64" > "$RELEASE_KEYSTORE" )
BUILD_TOOLS=$(ls -d "$ANDROID_HOME"/build-tools/* | sort -V | tail -n1)
export BUILD_TOOLS
[ -n "$LABEL" ] || LABEL="${GITHUB_REF_NAME//\//-}-${GITHUB_SHA::7}"
suffix=$(awk -v v="$VARIANT" '!/^#/ && $1 == v { print $3 }' tools/release/variants.txt)
[ "$suffix" = - ] && suffix=
apks=(in/*.apk)
[ ${#apks[@]} = 1 ] && [ -f "${apks[0]}" ] || { echo "::error::expected one apk in the build"; exit 1; }
mkdir -p out
out="out/DroidDeck-$LABEL$suffix.apk"
tools/release/sign-apk.sh "${apks[0]}" "$VARIANT" "$out"
( cd out && sha256sum -- *.apk > "SHA256SUMS-$VARIANT.txt" && cat "SHA256SUMS-$VARIANT.txt" )
echo "- \`$(basename "$out")\` - $(cut -c1-16 "out/SHA256SUMS-$VARIANT.txt")…" >> "$GITHUB_STEP_SUMMARY"
- name: Forget the key
if: always()
run: rm -f "$RUNNER_TEMP/release.p12"
- uses: actions/upload-artifact@v4
with:
name: ${{ inputs.artifact }}-${{ matrix.variant }}
path: out/*
if-no-files-found: error
+12
View File
@@ -14,3 +14,15 @@ app/src/main/assets/linuxfs/
# Kotlin compiler session files (build-tool state, never source).
.kotlin/
# Signing keys never enter the repo (DroidDeck's lives in GitHub environment secrets; see
# tools/release/make-release-key.sh). keystore/testkey.p12 is the public AOSP testkey.
*.jks
*.keystore
*.p12
*.pfx
*.pk8
*.pem
!keystore/testkey.p12
droiddeck-release-key/
password.txt
+12
View File
@@ -123,5 +123,17 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/log_share_paths" />
</provider>
<!-- The app's data folder in Android's file picker ("Open from" > DroidDeck). Only the
system's documents UI may bind it; apps get a file only when the user picks it. -->
<provider
android:name=".files.AppFilesProvider"
android:authorities="${applicationId}.documents"
android:exported="true"
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
</application>
</manifest>
@@ -0,0 +1,243 @@
package com.droiddeck.launcher.files
import android.content.Context
import android.content.pm.ProviderInfo
import android.content.res.AssetFileDescriptor
import android.database.Cursor
import android.database.MatrixCursor
import android.graphics.Point
import android.os.CancellationSignal
import android.os.ParcelFileDescriptor
import android.provider.DocumentsContract.Document
import android.provider.DocumentsContract.Root
import android.provider.DocumentsProvider
import android.webkit.MimeTypeMap
import com.droiddeck.launcher.R
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.nio.file.FileVisitResult
import java.nio.file.Files
import java.nio.file.LinkOption
import java.nio.file.Path
import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes
/**
* The app's own data folder (the Linux runtime, settings, logs) in Android's file picker, under the
* app's name beside the phone's storage - what MT Manager's "documents provider" injection does, as
* part of the app. Only the system's documents UI can bind it (MANAGE_DOCUMENTS); another app sees
* a file only when the user picks it there.
*
* A document id is the file's absolute path, and nothing outside the data folder is ever handed out
* or accepted: every id is resolved and checked to lie inside it.
*/
class AppFilesProvider : DocumentsProvider() {
private lateinit var base: File
private lateinit var baseCanonical: String
override fun attachInfo(context: Context, info: ProviderInfo) {
super.attachInfo(context, info)
base = context.dataDir
baseCanonical = base.canonicalPath
}
override fun onCreate() = true
override fun queryRoots(projection: Array<String>?): Cursor {
val ctx = context!!
val result = MatrixCursor(projection ?: ROOT_COLUMNS)
result.newRow().apply {
add(Root.COLUMN_ROOT_ID, ROOT_ID)
add(Root.COLUMN_DOCUMENT_ID, base.absolutePath)
add(Root.COLUMN_TITLE, titleFor(ctx))
add(Root.COLUMN_SUMMARY, null)
add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_CREATE or Root.FLAG_SUPPORTS_SEARCH or Root.FLAG_SUPPORTS_IS_CHILD)
add(Root.COLUMN_MIME_TYPES, "*/*")
add(Root.COLUMN_AVAILABLE_BYTES, base.freeSpace)
add(Root.COLUMN_ICON, R.mipmap.ic_launcher)
}
return result
}
override fun queryDocument(documentId: String, projection: Array<String>?): Cursor =
MatrixCursor(projection ?: DOCUMENT_COLUMNS).also { addRow(it, fileFor(documentId)) }
override fun queryChildDocuments(parentDocumentId: String, projection: Array<String>?, sortOrder: String?): Cursor {
val result = MatrixCursor(projection ?: DOCUMENT_COLUMNS)
// A link in the runtime that leads out of the data folder (/etc/localtime and the like) is
// left out rather than listed as something that cannot be opened.
fileFor(parentDocumentId).listFiles()?.filter { inside(it) }?.forEach { addRow(result, it) }
return result
}
override fun openDocument(documentId: String, mode: String, signal: CancellationSignal?): ParcelFileDescriptor =
ParcelFileDescriptor.open(fileFor(documentId), ParcelFileDescriptor.parseMode(mode))
override fun openDocumentThumbnail(documentId: String, sizeHint: Point?, signal: CancellationSignal?): AssetFileDescriptor {
val file = fileFor(documentId)
return AssetFileDescriptor(ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY), 0, file.length())
}
override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String {
val parent = fileFor(parentDocumentId)
val name = displayName.substringAfterLast('/')
var file = File(parent, name)
var n = 2
while (file.exists()) file = File(parent, "$name (${n++})")
val made = try {
if (mimeType == Document.MIME_TYPE_DIR) file.mkdir() else file.createNewFile()
} catch (e: IOException) {
false
}
if (!made) throw FileNotFoundException("could not create ${file.path}")
return file.absolutePath
}
override fun deleteDocument(documentId: String) {
val file = fileFor(documentId)
if (file.absolutePath == base.absolutePath) throw FileNotFoundException("the app folder itself is not deleted")
try {
deleteTree(file.toPath())
} catch (e: IOException) {
throw FileNotFoundException("could not delete $documentId: ${e.message}")
}
}
/**
* Deletes a file or a folder and what is in it without ever following a link: the runtime holds
* links to places outside it, and deleting a folder must remove the link, not what it points at.
*/
private fun deleteTree(root: Path) {
if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) {
Files.delete(root)
return
}
Files.walkFileTree(root, object : SimpleFileVisitor<Path>() {
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
Files.delete(file)
return FileVisitResult.CONTINUE
}
override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult {
if (exc != null) throw exc
Files.delete(dir)
return FileVisitResult.CONTINUE
}
})
}
override fun removeDocument(documentId: String, parentDocumentId: String) = deleteDocument(documentId)
override fun renameDocument(documentId: String, displayName: String): String {
val file = fileFor(documentId)
val target = File(file.parentFile, displayName.substringAfterLast('/'))
if (target.exists() || !file.renameTo(target)) throw FileNotFoundException("could not rename $documentId")
return target.absolutePath
}
override fun moveDocument(sourceDocumentId: String, sourceParentDocumentId: String, targetParentDocumentId: String): String {
val source = fileFor(sourceDocumentId)
val targetParent = fileFor(targetParentDocumentId)
if (!targetParent.isDirectory) throw FileNotFoundException("$targetParentDocumentId is not a folder")
val target = File(targetParent, source.name)
if (target.exists() || !source.renameTo(target)) throw FileNotFoundException("could not move $sourceDocumentId")
return target.absolutePath
}
override fun getDocumentType(documentId: String): String = mimeOf(fileFor(documentId))
override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean {
val parent = runCatching { fileFor(parentDocumentId).absolutePath }.getOrNull() ?: return false
val child = runCatching { fileFor(documentId).absolutePath }.getOrNull() ?: return false
return child == parent || child.startsWith("$parent/")
}
override fun querySearchDocuments(rootId: String, query: String, projection: Array<String>?): Cursor {
val result = MatrixCursor(projection ?: DOCUMENT_COLUMNS)
val needle = query.lowercase()
val pending = ArrayDeque(listOf(base))
var visited = 0
// The runtime is tens of thousands of files: a bounded walk, not the whole tree.
while (pending.isNotEmpty() && result.count < 50 && visited++ < 20000) {
val f = pending.removeFirst()
if (!inside(f)) continue
if (f.isDirectory) f.listFiles()?.let { pending.addAll(it) }
else if (f.name.lowercase().contains(needle)) addRow(result, f)
}
return result
}
/** The file an id names, when it exists and lies inside the data folder (links followed). */
private fun fileFor(documentId: String): File {
val f = File(documentId)
if (!f.isAbsolute || !inside(f)) throw FileNotFoundException("$documentId is outside the app's files")
if (!f.exists()) throw FileNotFoundException("$documentId not found")
return f
}
private fun inside(f: File): Boolean {
// The folder's own entries are listed as they are; a link is judged by where it leads.
val abs = f.absoluteFile.normalize().path
if (abs != base.absolutePath && !abs.startsWith(base.absolutePath + "/")) return false
val real = runCatching { f.canonicalPath }.getOrNull() ?: return false
return real == baseCanonical || real.startsWith("$baseCanonical/")
}
private fun addRow(result: MatrixCursor, file: File) {
var flags = 0
if (file.isDirectory) {
if (file.canWrite()) flags = flags or Document.FLAG_DIR_SUPPORTS_CREATE
} else if (file.canWrite()) {
flags = flags or Document.FLAG_SUPPORTS_WRITE
}
if (file.absolutePath != base.absolutePath && file.parentFile?.canWrite() == true) {
flags = flags or Document.FLAG_SUPPORTS_DELETE or Document.FLAG_SUPPORTS_RENAME or
Document.FLAG_SUPPORTS_MOVE or Document.FLAG_SUPPORTS_REMOVE
}
val mime = mimeOf(file)
if (mime.startsWith("image/")) flags = flags or Document.FLAG_SUPPORTS_THUMBNAIL
result.newRow().apply {
add(Document.COLUMN_DOCUMENT_ID, file.absolutePath)
add(Document.COLUMN_DISPLAY_NAME, if (file.absolutePath == base.absolutePath) titleFor(context!!) else file.name)
add(Document.COLUMN_SIZE, if (file.isDirectory) null else file.length())
add(Document.COLUMN_MIME_TYPE, mime)
add(Document.COLUMN_LAST_MODIFIED, file.lastModified())
add(Document.COLUMN_FLAGS, flags)
}
}
private fun mimeOf(file: File): String {
if (file.isDirectory) return Document.MIME_TYPE_DIR
val ext = file.extension.lowercase()
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: "application/octet-stream"
}
companion object {
private const val ROOT_ID = "app"
private val ROOT_COLUMNS = arrayOf(
Root.COLUMN_ROOT_ID, Root.COLUMN_MIME_TYPES, Root.COLUMN_FLAGS, Root.COLUMN_ICON,
Root.COLUMN_TITLE, Root.COLUMN_SUMMARY, Root.COLUMN_DOCUMENT_ID, Root.COLUMN_AVAILABLE_BYTES,
)
private val DOCUMENT_COLUMNS = arrayOf(
Document.COLUMN_DOCUMENT_ID, Document.COLUMN_MIME_TYPE, Document.COLUMN_DISPLAY_NAME,
Document.COLUMN_LAST_MODIFIED, Document.COLUMN_FLAGS, Document.COLUMN_SIZE,
)
/**
* The name in the picker. The same app ships under other package names (for the performance
* modes some phones give only named apps); each says which it is, so four installs are not
* four identical "DroidDeck" entries.
*/
fun titleFor(context: Context): String {
val name = context.getString(R.string.app_name)
return when (context.packageName) {
"com.tencent.ig" -> "$name PUBG"
"com.antutu.benchmark.full" -> "$name AnTuTu"
"com.ludashi.benchmark" -> "$name Ludashi"
else -> name
}
}
}
}
+1
View File
@@ -0,0 +1 @@
b241ea7d77822d39b89d3227e06f3ecf62adec6cbfd38ad06196787991b5d3f0
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
# axml_rename.py in.apk out.apk old=new ... : rewrite whole strings in AndroidManifest.xml's string
# pool (binary XML), leaving every other entry of the apk byte-identical. Unsigned output.
import struct, sys, zipfile
def dec_len8(b, o):
n = b[o]; o += 1
if n & 0x80: n = ((n & 0x7f) << 8) | b[o]; o += 1
return n, o
def dec_len16(b, o):
n = struct.unpack_from('<H', b, o)[0]; o += 2
if n & 0x8000: n = ((n & 0x7fff) << 16) | struct.unpack_from('<H', b, o)[0]; o += 2
return n, o
def enc_len8(n):
return bytes([n]) if n < 0x80 else bytes([0x80 | (n >> 8), n & 0xff])
def enc_len16(n):
return struct.pack('<H', n) if n < 0x8000 else struct.pack('<HH', 0x8000 | (n >> 16), n & 0xffff)
def rewrite(axml, repl):
ftype, fhs, fsize = struct.unpack_from('<HHI', axml, 0)
assert ftype == 0x0003, 'not binary XML'
sp = fhs
ctype, chs, csize, count, scount, flags, sstart, stystart = struct.unpack_from('<HHIIIIII', axml, sp)
assert ctype == 0x0001
utf8 = bool(flags & 0x100)
offs = struct.unpack_from('<%dI' % count, axml, sp + chs)
sty_offs = axml[sp + chs + 4 * count: sp + chs + 4 * count + 4 * scount]
base = sp + sstart
strings = []
for off in offs:
o = base + off
if utf8:
_, o = dec_len8(axml, o); n, o = dec_len8(axml, o)
strings.append(axml[o:o + n].decode('utf-8'))
else:
n, o = dec_len16(axml, o)
strings.append(axml[o:o + 2 * n].decode('utf-16-le'))
hits = 0
for i, s in enumerate(strings):
if s in repl: strings[i] = repl[s]; hits += 1
data = bytearray(); new_offs = []
for s in strings:
new_offs.append(len(data))
if utf8:
e = s.encode('utf-8'); data += enc_len8(len(s.encode('utf-16-le')) // 2) + enc_len8(len(e)) + e + b'\0'
else:
e = s.encode('utf-16-le'); data += enc_len16(len(e) // 2) + e + b'\0\0'
while len(data) % 4: data += b'\0'
styles = axml[sp + stystart: sp + csize] if scount else b''
new_sstart = chs + 4 * count + 4 * scount
new_stystart = new_sstart + len(data) if scount else 0
body = struct.pack('<%dI' % count, *new_offs) + sty_offs + bytes(data) + styles
new_csize = chs + len(body)
header = struct.pack('<HHIIIIII', ctype, chs, new_csize, count, scount, flags, new_sstart, new_stystart)
header += axml[sp + 28: sp + chs]
rest = axml[sp + csize:]
out = axml[:sp] + header + body + rest
out = out[:4] + struct.pack('<I', len(out)) + out[8:]
return out, hits
src, dst = sys.argv[1], sys.argv[2]
repl = dict(a.split('=', 1) for a in sys.argv[3:])
with zipfile.ZipFile(src) as zi, zipfile.ZipFile(dst, 'w') as zo:
for info in zi.infolist():
if info.filename.startswith('META-INF/') and info.filename.split('/')[-1].split('.')[-1] in ('SF', 'RSA', 'DSA', 'EC', 'MF'):
continue
b = zi.read(info)
if info.filename == 'AndroidManifest.xml':
b, hits = rewrite(b, repl)
print(f'manifest: {hits} strings replaced')
zo.writestr(info, b, compress_type=info.compress_type)
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# make-release-key.sh - run ONCE, by the owner. Makes DroidDeck's signing key and gives it to GitHub.
#
# - The key is made on this device, in ~/droiddeck-release-key (outside the repo), with a long
# random password written beside it. Nothing secret is printed.
# - The key and password go to GitHub as secrets of the three signing environments
# (signing-main, signing-branch, release). GitHub never shows a secret again.
# - The key's certificate digest - public, not a secret - goes to keystore/release-signer.sha256,
# which CI checks every signed apk against. That file is the only thing to commit.
#
# BACK UP ~/droiddeck-release-key (both files) somewhere safe - a password manager, a USB stick.
# Without it no update can ever be signed for the people who installed a signed build.
set -euo pipefail
REPO=The412Banner/DroidDeck
ENVIRONMENTS=(signing-main signing-branch release)
ALIAS=droiddeck
OUT=${DROIDDECK_KEY_DIR:-$HOME/droiddeck-release-key}
here=$(cd "$(dirname "$0")" && pwd)
repo=$(cd "$here/../.." && pwd)
die() { echo "error: $*" >&2; exit 1; }
command -v keytool >/dev/null || die "keytool not found (Termux: pkg install openjdk-17)"
command -v gh >/dev/null || die "gh not found"
gh auth status >/dev/null 2>&1 || die "gh is not logged in (gh auth login)"
[ -e "$OUT/droiddeck-release.p12" ] && die "a key already exists in $OUT - there must only ever be one"
case "$OUT" in "$repo"|"$repo"/*) die "the key must not be made inside the repo" ;; esac
for env in "${ENVIRONMENTS[@]}"; do
gh api "repos/$REPO/environments/$env" >/dev/null 2>&1 || die "GitHub environment '$env' does not exist yet"
done
umask 077
mkdir -p "$OUT"
KS="$OUT/droiddeck-release.p12"
# 48 random characters; PKCS12 uses one password for the store and the key.
KS_PW=$(head -c 60 /dev/urandom | base64 -w0 | tr -d '/+=')
KS_PW=${KS_PW:0:48}
[ ${#KS_PW} -ge 40 ] || die "could not make a password"
export KS_PW
printf '%s\n' "$KS_PW" > "$OUT/password.txt"
keytool -genkeypair -keystore "$KS" -storetype PKCS12 -alias "$ALIAS" \
-keyalg RSA -keysize 4096 -validity 10950 -dname "CN=DroidDeck, O=The412Banner" \
-storepass:env KS_PW -keypass:env KS_PW >/dev/null 2>&1 || die "keytool could not make the key"
listing=$(keytool -list -v -keystore "$KS" -storepass:env KS_PW -alias "$ALIAS")
SHA=$(awk '/SHA256:/ { print $2 }' <<<"$listing" | head -n1 | tr -d ':' | tr 'A-F' 'a-f')
[[ $SHA =~ ^[0-9a-f]{64}$ ]] || die "could not read the key's certificate digest"
for env in "${ENVIRONMENTS[@]}"; do
base64 -w0 < "$KS" | gh secret set RELEASE_KEYSTORE_B64 --env "$env" -R "$REPO" >/dev/null
printf '%s' "$KS_PW" | gh secret set RELEASE_STORE_PASSWORD --env "$env" -R "$REPO" >/dev/null
echo "secrets set for environment: $env"
done
unset KS_PW
printf '%s\n' "$SHA" > "$repo/keystore/release-signer.sha256"
cat <<EOF
DroidDeck's signing key is made and in GitHub.
Key: $KS
Password: $OUT/password.txt
Digest: $SHA (public - written to keystore/release-signer.sha256)
Now:
1. Back up the folder $OUT (both files) to at least two safe places.
2. Commit keystore/release-signer.sha256 (only that file).
EOF
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# sign-apk.sh <in.apk> <variant> <out.apk>
#
# Turns a CI build (signed with the public AOSP testkey) into one package of tools/release/variants.txt
# signed with DroidDeck's own key, carrying the hand-over from the testkey:
# - v1 + v2 are signed by the testkey, v3 by our key, with a signed lineage testkey -> our key;
# - an install from before (testkey) updates to it and keeps its data;
# - the testkey loses its rollback right, so an apk signed with the testkey alone - which anyone
# can make - is refused as an update to it (device-proven on the Pocket FIT, 2026-09-24);
# - --rotation-min-sdk-version 28: the lineage applies from Android 9, not only from 13.
#
# The key comes from the environment and is never written anywhere but the path given:
# RELEASE_KEYSTORE path to the PKCS12 keystore
# RELEASE_STORE_PASSWORD its password (the key has the same one: PKCS12)
# RELEASE_KEY_ALIAS the key's alias
# BUILD_TOOLS Android build-tools dir (zipalign, apksigner, aapt)
# RELEASE_SIGNER_SHA256 optional: the expected certificate digest, else keystore/release-signer.sha256
set -euo pipefail
die() { echo "::error::$*" >&2; exit 1; }
[ $# -eq 3 ] || die "usage: sign-apk.sh <in.apk> <variant> <out.apk>"
in=$1 variant=$2 out=$3
here=$(cd "$(dirname "$0")" && pwd)
repo=$(cd "$here/../.." && pwd)
pkg=$(awk -v v="$variant" '!/^#/ && $1 == v { print $2 }' "$here/variants.txt")
[ -n "$pkg" ] || die "no variant '$variant' in tools/release/variants.txt"
expected=${RELEASE_SIGNER_SHA256:-$(tr -d ' \n' < "$repo/keystore/release-signer.sha256" 2>/dev/null || true)}
[[ $expected =~ ^[0-9a-f]{64}$ ]] \
|| die "keystore/release-signer.sha256 is missing: make the key first (tools/release/make-release-key.sh)"
: "${RELEASE_KEYSTORE:?}" "${RELEASE_STORE_PASSWORD:?}" "${RELEASE_KEY_ALIAS:?}" "${BUILD_TOOLS:?}"
[ -s "$RELEASE_KEYSTORE" ] || die "the release keystore is empty - is the secret set for this environment?"
TESTKEY="$repo/keystore/testkey.p12"
TESTKEY_SHA256=a40da80a59d170caa950cf15c18c454d47a39b26989d8b640ecd745ba71bf5dc
export TESTKEY_PASSWORD=android
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
# The package name and everything the manifest derives from it; component class names stay.
P=com.droiddeck.launcher
if [ "$pkg" = "$P" ]; then
python3 "$here/axml_rename.py" "$in" "$work/renamed.apk"
else
python3 "$here/axml_rename.py" "$in" "$work/renamed.apk" \
"$P=$pkg" "$P.androidx-startup=$pkg.androidx-startup" "$P.logs=$pkg.logs" \
"$P.documents=$pkg.documents" "$P.home=$pkg.home" \
"$P.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION=$pkg.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION"
fi
got=$("$BUILD_TOOLS/aapt" dump badging "$work/renamed.apk" | sed -n "s/^package: name='\([^']*\)'.*/\1/p")
[ "$got" = "$pkg" ] || die "$variant: the apk says package '$got', not '$pkg'"
if [ "$pkg" != "$P" ]; then
# A provider left on the old authority would clash with the standard app installed beside it.
tree=$("$BUILD_TOOLS/aapt" dump xmltree "$work/renamed.apk" AndroidManifest.xml)
grep -E 'android:(authorities|taskAffinity)' <<<"$tree" | grep -F "\"$P." \
&& die "$variant: the manifest still names $P in an authority or task"
fi
"$BUILD_TOOLS/zipalign" -p -f 4 "$work/renamed.apk" "$work/aligned.apk"
# The lineage: the testkey vouches for our key. Made fresh each time (it is two certificates and
# a signature, nothing secret beyond the key that signs it).
"$BUILD_TOOLS/apksigner" rotate --out "$work/lineage" \
--old-signer --ks "$TESTKEY" --ks-type PKCS12 --ks-pass env:TESTKEY_PASSWORD \
--ks-key-alias testkey --key-pass env:TESTKEY_PASSWORD --set-rollback false \
--new-signer --ks "$RELEASE_KEYSTORE" --ks-type PKCS12 --ks-pass env:RELEASE_STORE_PASSWORD \
--ks-key-alias "$RELEASE_KEY_ALIAS" --key-pass env:RELEASE_STORE_PASSWORD
"$BUILD_TOOLS/apksigner" sign \
--ks "$TESTKEY" --ks-type PKCS12 --ks-pass env:TESTKEY_PASSWORD \
--ks-key-alias testkey --key-pass env:TESTKEY_PASSWORD \
--next-signer --ks "$RELEASE_KEYSTORE" --ks-type PKCS12 --ks-pass env:RELEASE_STORE_PASSWORD \
--ks-key-alias "$RELEASE_KEY_ALIAS" --key-pass env:RELEASE_STORE_PASSWORD \
--lineage "$work/lineage" --rotation-min-sdk-version 28 \
--v1-signing-enabled true --v2-signing-enabled true --v3-signing-enabled true --v4-signing-enabled false \
--out "$out" "$work/aligned.apk"
rm -f "$out.idsig"
# What the apk must carry, checked rather than assumed.
"$BUILD_TOOLS/apksigner" verify --min-sdk-version 26 "$out" >/dev/null || die "$variant: does not verify"
# (Listed first, then searched: grep -q stops reading early, which fails a pipe under pipefail.)
listing=$(unzip -l "$out")
grep -qE 'META-INF/.*\.(SF|RSA)$' <<<"$listing" || die "$variant: no JAR signature (old installers)"
# Android 9+ must see our key and only our key. apksigner labels signers "Signer #1", "Signer
# (minSdkVersion=28, ...)" or "V3.0 Signer:" depending on its version; the digests are what count.
certs=$("$BUILD_TOOLS/apksigner" verify --min-sdk-version 28 --print-certs "$out")
v3=$(sed -n 's/^.*Signer.* certificate SHA-256 digest: //p' <<<"$certs" | sort -u)
if [ "$v3" != "$expected" ]; then
echo "$certs" >&2
die "$variant: Android 9+ sees signer(s) [$(tr '\n' ' ' <<<"$v3")], not only DroidDeck's key $expected"
fi
lineage=$("$BUILD_TOOLS/apksigner" lineage --in "$out" --print-certs)
grep -q "Signer #1 in lineage certificate SHA-256 digest: $TESTKEY_SHA256" <<<"$lineage" \
|| die "$variant: the lineage does not start at the testkey - old installs could not update"
grep -q "Signer #2 in lineage certificate SHA-256 digest: $expected" <<<"$lineage" \
|| die "$variant: the lineage does not hand over to DroidDeck's key"
rollback=$(awk '/Signer #1 in lineage/{s=1} /Signer #2 in lineage/{s=0} s && /rollback capability/' <<<"$lineage")
grep -q false <<<"$rollback" \
|| die "$variant: the testkey kept its rollback right - a testkey-signed apk could replace this one"
echo "$variant ($pkg): signed, hand-over from the testkey, v3 signer $expected"
+9
View File
@@ -0,0 +1,9 @@
# The packages a signed build ships as. The same apk, with only the manifest's package name (and
# the names derived from it) changed: some phones give their performance modes only to apps with
# certain names. Each is its own app to Android - installs beside the others, with its own data.
#
# name package file suffix
standard com.droiddeck.launcher -
pubg com.tencent.ig -pubg
antutu com.antutu.benchmark.full -antutu
ludashi com.ludashi.benchmark -ludashi