mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
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:
11 files changed
+737
-10
No files matched your search
+45
-10
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
b241ea7d77822d39b89d3227e06f3ecf62adec6cbfd38ad06196787991b5d3f0
|
||||
@@ -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)
|
||||
Executable
+68
@@ -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
|
||||
Executable
+100
@@ -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"
|
||||
@@ -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
|
||||
Reference in new issue
Block a user