From 0516d023fa03362e5a34b5436fbda5f6008d4d4f Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:40:53 -0400 Subject: [PATCH] feat: art-first library cards with a display menu (#1744) * feat: art-first library cards with a display menu for size, title and storage * build: use native arm64 docker on Apple Silicon and add a fast deploy loop * fix: address review feedback on library cards and local build scripts --- .../launcher/session/SessionPrefs.kt | 18 ++ .../com/droiddeck/launcher/ui/LibraryPage.kt | 164 ++++++++++++++++-- .../com/droiddeck/launcher/ui/StoresPage.kt | 19 +- app/src/main/res/values/strings.xml | 5 + tools/build_local.sh | 7 +- tools/comet/build-local.sh | 11 +- tools/docker_platform.sh | 11 ++ tools/docker_preflight.py | 3 +- tools/local-cross.Dockerfile | 16 +- tools/quick_deploy.sh | 33 ++++ tools/test_local.sh | 5 +- tools/vr/build.sh | 3 +- 12 files changed, 261 insertions(+), 34 deletions(-) create mode 100644 tools/docker_platform.sh create mode 100755 tools/quick_deploy.sh diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 2df87e166..98964e61b 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -125,6 +125,24 @@ object SessionPrefs { prefs(context).edit().putString("storesView", if (view in STORES_VIEWS) view else STORES_VIEWS.first()).apply() } + fun libraryCardLevel(context: Context): Int = prefs(context).getInt("libraryCardLevel", 2) + + fun setLibraryCardLevel(context: Context, level: Int) { + prefs(context).edit().putInt("libraryCardLevel", level).apply() + } + + fun libraryShowTitle(context: Context): Boolean = prefs(context).getBoolean("libraryShowTitle", true) + + fun setLibraryShowTitle(context: Context, show: Boolean) { + prefs(context).edit().putBoolean("libraryShowTitle", show).apply() + } + + fun libraryShowSize(context: Context): Boolean = prefs(context).getBoolean("libraryShowSize", true) + + fun setLibraryShowSize(context: Context, show: Boolean) { + prefs(context).edit().putBoolean("libraryShowSize", show).apply() + } + fun libraryShowUninstalled(context: Context): Boolean = prefs(context).getBoolean("libraryShowUninstalled", false) fun setLibraryShowUninstalled(context: Context, show: Boolean) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/LibraryPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/LibraryPage.kt index ad7f8cc43..51bddfc67 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/LibraryPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/LibraryPage.kt @@ -17,6 +17,8 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -35,12 +37,15 @@ import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.FilterAlt import androidx.compose.material.icons.filled.GridView import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Remove import androidx.compose.material.icons.filled.Sort import androidx.compose.material.icons.filled.ViewList import androidx.compose.material.icons.filled.ViewModule +import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.outlined.Download import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -55,11 +60,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.key.onPreviewKeyEvent @@ -88,6 +95,8 @@ import com.droiddeck.launcher.stores.StoreSizes import com.droiddeck.launcher.stores.StoresState import com.droiddeck.launcher.stores.arrangeStoreTabs import com.droiddeck.launcher.stores.download.DownloadEntry +import com.droiddeck.launcher.stores.download.DownloadStage +import com.droiddeck.launcher.stores.download.DownloadState import com.droiddeck.launcher.stores.formatBytes import com.droiddeck.launcher.stores.itch.ItchCollection import com.droiddeck.launcher.stores.itch.ItchCollections @@ -147,6 +156,37 @@ internal fun librarySources(entries: List, order: List): L .filter { source -> entries.any { it.source == source } } /** Survives leaving the page, so Stores can open the Library on one store's owned games. */ +internal object LibraryDisplay { + val scales = listOf(0.75f, 0.875f, 1f, 1.2f, 1.45f) + var level by mutableIntStateOf(2) + private set + var title by mutableStateOf(true) + private set + var size by mutableStateOf(true) + private set + + fun load(context: android.content.Context) { + level = SessionPrefs.libraryCardLevel(context).coerceIn(0, scales.lastIndex) + title = SessionPrefs.libraryShowTitle(context) + size = SessionPrefs.libraryShowSize(context) + } + + fun step(context: android.content.Context, by: Int) { + level = (level + by).coerceIn(0, scales.lastIndex) + SessionPrefs.setLibraryCardLevel(context, level) + } + + fun toggleTitle(context: android.content.Context) { + title = !title + SessionPrefs.setLibraryShowTitle(context, title) + } + + fun toggleSize(context: android.content.Context) { + size = !size + SessionPrefs.setLibraryShowSize(context, size) + } +} + internal object LibraryView { var source by mutableStateOf(LIBRARY_ALL) var query by mutableStateOf("") @@ -171,6 +211,7 @@ internal fun LibraryPage(s: FrontEndState, a: FrontEndActions, selected: String, val focusManager = androidx.compose.ui.platform.LocalFocusManager.current val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current LaunchedEffect(Unit) { StoresState.refresh(ctx) } + remember { LibraryDisplay.load(ctx) } val stores = s.gameStoresEnabled && !s.shortcutPicker val signedIn = Store.entries.filter { StoresState.isSignedIn(it) } LaunchedEffect(stores, signedIn) { if (stores) signedIn.forEach { StoresState.loadLibrary(ctx, it) } } @@ -360,7 +401,7 @@ private fun LibraryGrid( ) { val listMode = s.storesView == "list" val capsules = s.storesView == "capsules" - val tileWidth = if (capsules) CapsuleWidth else CardWidth + val tileWidth = (if (capsules) CapsuleWidth else if (listMode) CardWidth else LibraryCardWidth) * LibraryDisplay.scales[LibraryDisplay.level] val focus = LocalFrontFocus.current val scope = rememberCoroutineScope() val density = androidx.compose.ui.platform.LocalDensity.current @@ -397,7 +438,7 @@ private fun LibraryGrid( } val picker = s.shortcutPicker && e.game != null val choose = if (picker) ({ a.onSteamGame(e.game!!) }) else null - if (listMode) LibraryRow(e, first, navigation, choose, onOpen) else LibraryTile(e, capsules, first, navigation, choose, onOpen) + if (listMode) LibraryRow(e, first, navigation, choose, onOpen) else LibraryTile(e, capsules, first, navigation, choose, onOpen, showSource = source == LIBRARY_ALL) } LazyVerticalGrid( columns = if (listMode) GridCells.Fixed(1) else GridCells.Adaptive(tileWidth), state = grid, @@ -464,7 +505,7 @@ private fun LibraryHeader( ModeHeader( stringResource(R.string.rail_games), LibraryView.query, { LibraryView.query = it }, placeholder = stringResource(R.string.stores_search_library), - clearSearchFocus = searchCleared, + clearSearchFocus = searchCleared, inlineWhenStacked = true, lead = { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(start = 2.dp, end = 4.dp)) { Text(stringResource(R.string.rail_games), fontSize = if (narrow) 20.sp else 24.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1) @@ -473,6 +514,7 @@ private fun LibraryHeader( } }, ) { + DisplayButton() if (showUninstalled != null || xbox) LibraryFilterButton(showUninstalled, onShowUninstalled, xbox) if (collections) CollectionsButton() SortButton(s, a) @@ -532,7 +574,7 @@ private fun entryArt(e: LibraryEntry, tall: Boolean): Any? { } @Composable -private fun LibraryTile(e: LibraryEntry, capsule: Boolean, first: Boolean, navigation: Modifier, choose: (() -> Unit)?, onOpen: (String) -> Unit) { +private fun LibraryTile(e: LibraryEntry, capsule: Boolean, first: Boolean, navigation: Modifier, choose: (() -> Unit)?, onOpen: (String) -> Unit, showSource: Boolean) { val pal = LocalPalette.current val src = remember { MutableInteractionSource() } val hot = rememberHot(src) @@ -556,9 +598,60 @@ private fun LibraryTile(e: LibraryEntry, capsule: Boolean, first: Boolean, navig .hoverable(src).clickable(role = Role.Button, interactionSource = src, indication = LocalIndication.current, onClick = open) .controllerConfirm(onClick = choose ?: open), ) { - CardFace(e.title, wide, tall, download, hot, capsule, dim = !e.installed && download == null, - badge = { SourceChip(e.source, small = true, modifier = Modifier.align(Alignment.TopStart).padding(6.dp)) }) { - LibraryMarker(e, download) + LibraryArt(e, wide, tall, download, hot, capsule, showSource) + } +} + +private val LibraryCardWidth = 150.dp + +@Composable +private fun LibraryArt(e: LibraryEntry, wide: Any?, tall: Any?, download: DownloadEntry?, hot: Boolean, capsule: Boolean, showSource: Boolean) { + val url = if (capsule) tall ?: wide else wide ?: tall + val art = cardArtRequest(url, tall) + val ratio = if (capsule) 5f / 7f else 16f / 9f + var offRatio by remember(url) { mutableStateOf(false) } + var trim by remember(url) { mutableStateOf(false) } + val blurs = android.os.Build.VERSION.SDK_INT >= 31 + Box( + Modifier.fillMaxWidth().aspectRatio(ratio).background(Color(0xFF101318)) + .then(if (url == null) Modifier.background(artBrush(hueOf(e.title))) else Modifier), + ) { + if (art != null && offRatio) { + AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize().blur(32.dp)) + Spacer(Modifier.matchParentSize().background(Color.Black.copy(alpha = if (blurs) 0.18f else 0.6f))) + } + if (art != null) AsyncImage( + model = art, contentDescription = null, contentScale = if (trim) ContentScale.Crop else ContentScale.Fit, modifier = Modifier.matchParentSize(), + onSuccess = { r -> + val d = r.result.drawable + if (d.intrinsicWidth > 0 && d.intrinsicHeight > 0) { + val shape = d.intrinsicWidth.toFloat() / d.intrinsicHeight + trim = !capsule && shape > ratio * 1.25f + offRatio = !trim && kotlin.math.abs(shape / ratio - 1f) > 0.03f + } + }, + ) + if (showSource) SourceChip(e.source, small = true, modifier = Modifier.align(Alignment.TopStart).padding(6.dp)) + val state = when { + download != null -> "${download.percent}%" + e.item != null && StoresState.isUnfinished(e.item) -> stringResource(R.string.stores_resume) + e.installed && libraryUpdateAvailable(e) -> stringResource(R.string.common_update) + else -> null + } + if (state != null) Box(Modifier.align(Alignment.TopEnd).padding(6.dp)) { StateChip(state, ChipTone.BUSY, small = true) } + val showName = LibraryDisplay.title || hot + val showSize = LibraryDisplay.size || hot + if (showName || showSize) Column( + verticalArrangement = Arrangement.spacedBy(1.dp), + modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth() + .background(Brush.verticalGradient(0f to Color.Transparent, 0.6f to Color.Black.copy(alpha = 0.78f), 1f to Color.Black.copy(alpha = 0.92f))) + .padding(start = 8.dp, end = 8.dp, top = 20.dp, bottom = if (download != null) 14.dp else 6.dp), + ) { + if (showName) Text(e.title, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = if (hot) 2 else 1, overflow = TextOverflow.Ellipsis, lineHeight = 13.sp) + if (showSize) LibrarySize(e) + } + if (download != null) Box(Modifier.align(Alignment.BottomCenter).padding(start = 6.dp, end = 6.dp, bottom = 5.dp)) { + ProgressBarThin(download.fraction, paused = download.state == DownloadState.PAUSED, verify = download.stage == DownloadStage.VERIFY) } } } @@ -578,19 +671,27 @@ private fun RequestSize(e: LibraryEntry) { LaunchedEffect(item.key) { StoreSizes.request(ctx, item) } } +private fun librarySize(e: LibraryEntry): Long? = if (e.installed) e.storeKey?.let { StoreSizes.onDisk[it] } + ?: e.game?.gameFiles?.let { FolderSizes.known[it.path] } + else e.item?.let { StoreSizes.size(it) }?.takeIf { it > 0 } + @Composable -internal fun LibraryMarker(e: LibraryEntry, download: DownloadEntry?) { +private fun LibrarySize(e: LibraryEntry) { + val size = librarySize(e)?.takeIf { it > 0 } ?: return + Text(formatBytes(size), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1) +} + +@Composable +internal fun LibraryMarker(e: LibraryEntry, download: DownloadEntry?, showDownload: Boolean = true) { val pal = LocalPalette.current - val size = if (e.installed) e.storeKey?.let { StoreSizes.onDisk[it] } - ?: e.game?.gameFiles?.let { FolderSizes.known[it.path] } - else e.item?.let { StoreSizes.size(it) }?.takeIf { it > 0 } + val size = librarySize(e) when { download != null -> Text("${download.percent}%", fontSize = 10.sp, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1) e.item != null && StoresState.isUnfinished(e.item) -> Text(stringResource(R.string.stores_resume), fontSize = 10.sp, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1) else -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) { if (e.installed && libraryUpdateAvailable(e)) Text(stringResource(R.string.common_update), fontSize = 10.sp, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1) if (size != null && size > 0) Text(formatBytes(size), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1) - if (!e.installed) Icon(Icons.Outlined.Download, stringResource(R.string.stores_section_not_installed), tint = Color.White, modifier = Modifier.size(12.dp)) + if (!e.installed && showDownload) Icon(Icons.Outlined.Download, stringResource(R.string.stores_section_not_installed), tint = Color.White, modifier = Modifier.size(12.dp)) } } } @@ -766,6 +867,45 @@ private fun CollectionsButton() { } } +@Composable +private fun DisplayButton() { + val pal = LocalPalette.current + val ctx = LocalContext.current + val title = stringResource(R.string.library_display) + var at by remember { mutableStateOf(null) } + Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) { + HeaderIconButton(title, Icons.Filled.Visibility, "display") { + val from = at ?: return@HeaderIconButton + Steps.ask(StepAsk( + anchor = from, side = StepSide.Left, accent = pal.signal, pillCorner = 8.dp, items = 4, + handle = { Icon(Icons.Filled.Visibility, null, tint = pal.signal, modifier = Modifier.size(18.dp)) }, + ) { + StepTitle(title) + StepList { + val last = LibraryDisplay.scales.lastIndex + StepChoice( + stringResource(R.string.library_display_larger), icon = Icons.Filled.Add, enabled = open && LibraryDisplay.level < last, + trailing = "${LibraryDisplay.level + 1}/${last + 1}", + modifier = Modifier.fillMaxWidth().stepItem(0).focusRequester(first), + ) { LibraryDisplay.step(ctx, 1) } + StepChoice( + stringResource(R.string.library_display_smaller), icon = Icons.Filled.Remove, enabled = open && LibraryDisplay.level > 0, + modifier = Modifier.fillMaxWidth().stepItem(1), + ) { LibraryDisplay.step(ctx, -1) } + StepChoice( + stringResource(R.string.library_display_title), selected = LibraryDisplay.title, enabled = open, + modifier = Modifier.fillMaxWidth().stepItem(2), + ) { LibraryDisplay.toggleTitle(ctx) } + StepChoice( + stringResource(R.string.library_display_size), selected = LibraryDisplay.size, enabled = open, + modifier = Modifier.fillMaxWidth().stepItem(3), + ) { LibraryDisplay.toggleSize(ctx) } + } + }) + } + } +} + @Composable private fun SortButton(s: FrontEndState, a: FrontEndActions) { val pal = LocalPalette.current diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt index 26802c070..3e0dc7772 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt @@ -1059,7 +1059,7 @@ internal fun SectionLabel(title: String) { internal fun ModeHeader( title: String, query: String, onQuery: (String) -> Unit, placeholder: String, clearSearchFocus: Boolean = false, - lead: (@Composable () -> Unit)? = null, trailing: @Composable () -> Unit, + lead: (@Composable () -> Unit)? = null, inlineWhenStacked: Boolean = false, trailing: @Composable () -> Unit, ) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current @@ -1101,8 +1101,23 @@ internal fun ModeHeader( } BoxWithConstraints(Modifier.fillMaxWidth()) { val stacked = maxWidth < NarrowPaneWidth + val inline = stacked && inlineWhenStacked && maxWidth >= 440.dp Column(Modifier.fillMaxWidth()) { - if (stacked) { + if (inline && !(searching || open > 0.01f)) Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(vertical = 4.dp), + ) { + heading() + Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.horizontalScroll(rememberScrollState()), + ) { + searchButton() + trailing() + } + } + } else if (stacked) { Box(Modifier.padding(vertical = 4.dp)) { heading() } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ad87e966d..148384012 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2308,6 +2308,11 @@ Resume Refresh Sort + Display + Larger cards + Smaller cards + Show title + Show size Recently played Recently installed Title A–Z diff --git a/tools/build_local.sh b/tools/build_local.sh index c36035e51..608b664d7 100755 --- a/tools/build_local.sh +++ b/tools/build_local.sh @@ -24,7 +24,8 @@ java_dir="${JAVA_HOME:-/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents [[ -x "$java_dir/bin/java" ]] || java_dir="${HOME}/.local/share/mise/installs/java/temurin-17" [[ "$java_dir" == */homebrew/* ]] && echo "using homebrew java path: $java_dir" [[ "$java_dir" == */mise/* ]] && echo "using mise java path: $java_dir" -image_name=${DROIDDECK_BUILD_IMAGE:-droiddeck-local-cross:24.04-v3} +. "${repo_root}/tools/docker_platform.sh" +image_name=${DROIDDECK_BUILD_IMAGE} build_variant=${DROIDDECK_BUILD_VARIANT:-release} case "$build_variant" in debug) gradle_task=assembleDebug ;; @@ -113,7 +114,7 @@ image_id=$(docker image inspect --format '{{.Id}}' "${image_name}") rm -rf -- "${linuxfs_dir}" mkdir -p "${linuxfs_dir}" -docker run --rm --platform linux/amd64 \ +docker run --rm --platform "${docker_platform}" \ --user "$(id -u):$(id -g)" \ -v "${repo_root}:/src" -w /src "${image_name}" bash -lc ' set -euo pipefail @@ -283,7 +284,7 @@ install -m644 "${mango_pkgs}/usr/bin/mangoapp" "${mango_dir}/mangoapp" for library in libfmt.so.10 libspdlog.so.1.13 libglfw.so.3 libtraceevent.so.1; do cp -L "${mango_pkgs}/usr/lib/${library}" "${mango_dir}/${library}" done -docker run --rm --platform linux/amd64 --user "$(id -u):$(id -g)" -v "${repo_root}:/src" -w /src "${image_name}" \ +docker run --rm --platform "${docker_platform}" --user "$(id -u):$(id -g)" -v "${repo_root}:/src" -w /src "${image_name}" \ aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -Wl,-soname,libtracefs.so.1 \ -o app/src/main/assets/linuxfs/usr/local/lib/mangoapp/libtracefs.so.1 tools/mangoapp/libtracefs-shim.c mkdir -p "${linuxfs_dir}/usr/local/bin" diff --git a/tools/comet/build-local.sh b/tools/comet/build-local.sh index d809cae50..9adc9549d 100644 --- a/tools/comet/build-local.sh +++ b/tools/comet/build-local.sh @@ -1,14 +1,19 @@ #!/usr/bin/env bash set -euo pipefail repo=$(cd "$(dirname "$0")/../.." && pwd) -image=${DROIDDECK_BUILD_IMAGE:-droiddeck-local-cross:24.04-v3} -docker run --rm --platform linux/amd64 -v "$repo:/src" \ - -v "${HOME}/.cache/droiddeck-comet-rust:/rust" -w /src \ +. "$repo/tools/docker_platform.sh" +image=${DROIDDECK_BUILD_IMAGE} +platform=${DROIDDECK_DOCKER_PLATFORM} +rust_cache="${HOME}/.cache/droiddeck-comet-rust${platform#linux/amd64}" +rust_cache=${rust_cache/\/arm64/-arm64} +docker run --rm --platform "$platform" -v "$repo:/src" \ + -v "${rust_cache}:/rust" -w /src \ -e CARGO_HOME=/rust/cargo -e RUSTUP_HOME=/rust/rustup "$image" bash -lc ' set -euo pipefail trap "chown -R '"$(id -u):$(id -g)"' app/build/comet app/src/main/assets/linuxfs/usr/local/lib/droiddeck-comet /rust 2>/dev/null || true" EXIT apt-get update -qq apt-get install -y -qq curl git unzip ca-certificates pkg-config >/dev/null + git config --global --add safe.directory "*" export PATH=/rust/cargo/bin:$PATH if ! command -v cargo >/dev/null; then curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.90.0 diff --git a/tools/docker_platform.sh b/tools/docker_platform.sh new file mode 100644 index 000000000..e7f34e561 --- /dev/null +++ b/tools/docker_platform.sh @@ -0,0 +1,11 @@ +# Sourced by the build scripts: picks the Docker platform and the matching cross-build image. +docker_platform=${DROIDDECK_DOCKER_PLATFORM:-} +if [[ -z "${docker_platform}" ]]; then + case "$(uname -m)" in arm64|aarch64) docker_platform=linux/arm64 ;; *) docker_platform=linux/amd64 ;; esac +fi +case "${docker_platform}" in linux/arm64|linux/arm64/*) docker_platform=linux/arm64 ;; esac +if [[ -z "${DROIDDECK_BUILD_IMAGE:-}" ]]; then + DROIDDECK_BUILD_IMAGE=droiddeck-local-cross:24.04-v3 + [[ "${docker_platform}" == linux/arm64 ]] && DROIDDECK_BUILD_IMAGE+=-arm64 +fi +export DROIDDECK_DOCKER_PLATFORM=${docker_platform} DROIDDECK_BUILD_IMAGE diff --git a/tools/docker_preflight.py b/tools/docker_preflight.py index b9cb3dbd9..eddd6206a 100644 --- a/tools/docker_preflight.py +++ b/tools/docker_preflight.py @@ -1,4 +1,5 @@ """Check the engine before modifying build assets; never rebuild for a daemon error.""" +import os from pathlib import Path import subprocess import sys @@ -20,5 +21,5 @@ if __name__ == '__main__': if 'No such image' not in image.stderr and 'image not known' not in image.stderr: # Docker, Podman sys.exit('Cannot inspect the build image: ' + image.stderr.strip()) repo = Path(__file__).resolve().parents[1] - subprocess.run(['docker', 'build', '--platform', 'linux/amd64', '-t', sys.argv[1], + subprocess.run(['docker', 'build', '--platform', os.environ.get('DROIDDECK_DOCKER_PLATFORM', 'linux/amd64'), '-t', sys.argv[1], '-f', str(repo / 'tools/local-cross.Dockerfile'), str(repo)], check=True) diff --git a/tools/local-cross.Dockerfile b/tools/local-cross.Dockerfile index 8d143e7ca..653ad2b2c 100644 --- a/tools/local-cross.Dockerfile +++ b/tools/local-cross.Dockerfile @@ -3,16 +3,12 @@ FROM ubuntu:24.04 ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update \ - && apt-get install -y \ - binutils \ - binutils-aarch64-linux-gnu \ - gcc \ - g++ \ - gcc-aarch64-linux-gnu \ - g++-aarch64-linux-gnu \ - python3 \ - zstd \ - unzip \ + && apt-get install -y binutils gcc g++ python3 zstd unzip \ + && if [ "$(uname -m)" = aarch64 ]; then \ + for tool in gcc g++ strip readelf objdump ar; do ln -s "$(command -v $tool)" /usr/local/bin/aarch64-linux-gnu-$tool; done; \ + else \ + apt-get install -y binutils-aarch64-linux-gnu gcc-aarch64-linux-gnu g++-aarch64-linux-gnu; \ + fi \ && mkdir -p /src WORKDIR /src diff --git a/tools/quick_deploy.sh b/tools/quick_deploy.sh new file mode 100755 index 000000000..57e585c4a --- /dev/null +++ b/tools/quick_deploy.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Kotlin/resource-only loop: reuses the assets build_local.sh already prepared, then signs and installs. +set -euo pipefail +repo_root=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +cd "${repo_root}" +sdk_dir=${ANDROID_HOME:-${ANDROID_SDK_ROOT:-"${HOME}/Library/Android/sdk"}} +export ANDROID_HOME=${sdk_dir} ANDROID_SDK_ROOT=${sdk_dir} +export JAVA_HOME=${JAVA_HOME:-/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home} +out=${1:-"${TMPDIR:-/tmp}/droiddeck-quick-signed.apk"} + +signing_env=${DROIDDECK_SIGNING_ENV:-} +if [[ -z "${signing_env}" ]]; then + for candidate in "${repo_root}/.signing.env" \ + "$(dirname "$(git -C "${repo_root}" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || echo .)")/.signing.env"; do + [[ -f "${candidate}" ]] && { signing_env=${candidate}; break; } + done +fi +[[ -f "${signing_env}" ]] || { echo "No signing env found; set DROIDDECK_SIGNING_ENV or add .signing.env." >&2; exit 1; } + +build_tools= +while IFS= read -r dir; do + [[ -x "${dir}/aapt" && -x "${dir}/zipalign" && -x "${dir}/apksigner" ]] && { build_tools=${dir}; break; } +done < <(ls -d "${sdk_dir}"/build-tools/* 2>/dev/null | sort -rV) +[[ -n "${build_tools}" ]] || { echo "No build-tools with aapt, zipalign and apksigner under ${sdk_dir}." >&2; exit 1; } + +./gradlew -q :app:assembleRelease +( + cd "$(dirname "${signing_env}")" + set -a; . "${signing_env}"; set +a + BUILD_TOOLS=${build_tools} "${repo_root}/tools/release/sign-apk.sh" \ + "${repo_root}/app/build/outputs/apk/release/app-release.apk" standard "${out}" | tail -1 +) +"${repo_root}/tools/deploy_local.sh" "${out}" | tail -2 diff --git a/tools/test_local.sh b/tools/test_local.sh index bd29aef7d..4eb43ff22 100755 --- a/tools/test_local.sh +++ b/tools/test_local.sh @@ -5,7 +5,8 @@ cd "$repo_root" if [[ $(uname -s) == Linux ]]; then exec python3 -m unittest discover -s tools/tests "$@" fi -image=${DROIDDECK_BUILD_IMAGE:-droiddeck-local-cross:24.04-v3} +. "${repo_root}/tools/docker_platform.sh" +image=${DROIDDECK_BUILD_IMAGE} python3 tools/docker_preflight.py "$image" -exec docker run --rm --platform linux/amd64 --user "$(id -u):$(id -g)" \ +exec docker run --rm --platform "${DROIDDECK_DOCKER_PLATFORM}" --user "$(id -u):$(id -g)" \ -v "$repo_root:/src:ro" -w /src "$image" python3 -m unittest discover -s tools/tests "$@" diff --git a/tools/vr/build.sh b/tools/vr/build.sh index fd8aebd1c..f28ef326b 100755 --- a/tools/vr/build.sh +++ b/tools/vr/build.sh @@ -2,6 +2,7 @@ set -euo pipefail here=$(cd "$(dirname "$0")" && pwd) repo=$(cd "$here/../.." && pwd) +. "$repo/tools/docker_platform.sh" native=0 if [ "${1:-}" = --native ]; then native=1; shift; fi @@ -87,7 +88,7 @@ compile() { if [ "$native" = 1 ]; then (cd "$repo" && "${cmd[@]}") else - docker run --rm --platform linux/amd64 --user "$(id -u):$(id -g)" -v "$repo:/src" -w /src \ + docker run --rm --platform "${DROIDDECK_DOCKER_PLATFORM:-linux/amd64}" --user "$(id -u):$(id -g)" -v "$repo:/src" -w /src \ "${DROIDDECK_BUILD_IMAGE:-droiddeck-local-cross:24.04-v3}" "${cmd[@]}" fi }