From 0acf8c9ab081b857a049c9f792c1086790fade17 Mon Sep 17 00:00:00 2001 From: Joe Tretter Date: Sat, 9 May 2026 14:22:07 -0500 Subject: [PATCH] Fix runtime diagnostics, rotation stability, and manpage search positioning - Expand the missing-native-Git message with the selected ABI, 32/64-bit ABI lists, Android version, device model/platform details, expected binary path, and a request to report the information so device support can be added. - Use application-scoped DataStore delegates for game progress and pane layout preferences to avoid duplicate DataStore instances during activity recreation, such as screen orientation changes. - Center manpage search matches in the visible scroll viewport when possible and apply IME padding so keyboard-covered space is accounted for. - Support mobile-friendly handling for interactive add/stage commands (`git add -i`, `git stage -i`, and `git add --interactive`) by routing them through GitHug's staging semantics instead of rejecting them or launching Git's terminal UI. - Apply IME padding to editor dialogs so controls remain reachable while the keyboard is displayed. --- CompileGitForAllTargetPlatforms.sh | 173 ------------------ app/build.gradle.kts | 4 +- .../githugandroid/GameProgressStore.kt | 9 +- .../githugandroid/GitMessageEditorDialog.kt | 4 +- .../tretter/githugandroid/GitRuntime.kt | 34 ++-- .../tretter/githugandroid/GitSandboxEngine.kt | 24 ++- .../tretter/githugandroid/ManPageDialog.kt | 26 ++- .../tretter/githugandroid/PaneLayoutStore.kt | 11 +- .../tretter/githugandroid/TextEditorDialog.kt | 4 +- .../githugandroid/GitSandboxEngineTest.kt | 4 +- 10 files changed, 80 insertions(+), 213 deletions(-) delete mode 100755 CompileGitForAllTargetPlatforms.sh diff --git a/CompileGitForAllTargetPlatforms.sh b/CompileGitForAllTargetPlatforms.sh deleted file mode 100755 index d13a1c7..0000000 --- a/CompileGitForAllTargetPlatforms.sh +++ /dev/null @@ -1,173 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_DIR="$SCRIPT_DIR" -SDK_DIR="$PROJECT_DIR/android-sdk" -NDK_DIR="$SDK_DIR/ndk/27.2.12479018" -TOOLBIN="$NDK_DIR/toolchains/llvm/prebuilt/linux-x86_64/bin" -TMP_DIR="$PROJECT_DIR/.tmp-android-build" -GIT_SRC_DIR="$TMP_DIR/git-src" -HOST_GIT_DIR="$PROJECT_DIR/build/host-git" -HOST_GIT_BINARY="$HOST_GIT_DIR/libgit.so" -ANDROID_JNI_DIR="$PROJECT_DIR/app/src/main/jniLibs" -ANDROID_API=24 - -log() { - printf '\n[%s] %s\n' "compile-git" "$1" -} - -require_path() { - if [ ! -e "$1" ]; then - echo "Required path not found: $1" >&2 - exit 1 - fi -} - -require_tool() { - if ! command -v "$1" >/dev/null 2>&1; then - echo "Missing required tool: $1" >&2 - exit 1 - fi -} - -ensure_git_source() { - mkdir -p "$TMP_DIR" - if [ ! -d "$GIT_SRC_DIR/.git" ]; then - log "Cloning Git source" - git clone --depth 1 https://github.com/git/git.git "$GIT_SRC_DIR" - else - log "Using existing Git source checkout" - fi -} - -common_git_make_args() { - printf '%s\n' \ - NO_OPENSSL=YesPlease \ - NO_CURL=YesPlease \ - NO_EXPAT=YesPlease \ - NO_GETTEXT=YesPlease \ - NO_TCLTK=YesPlease \ - NO_PERL=YesPlease \ - NO_PYTHON=YesPlease \ - NO_INSTALL_HARDLINKS=YesPlease \ - NO_ICONV=YesPlease \ - NO_REGEX=NeedsStartEnd \ - HAVE_ALLOCA_H=YesPlease \ - HAVE_PATHS_H=YesPlease \ - HAVE_CLOCK_GETTIME=YesPlease \ - HAVE_CLOCK_MONOTONIC=YesPlease \ - HAVE_GETDELIM=YesPlease \ - FREAD_READS_DIRECTORIES=UnfortunatelyYes \ - CSPRNG_METHOD= -} - -build_host_git() { - log "Building Git for development host" - cd "$GIT_SRC_DIR" - make clean >/dev/null 2>&1 || true - mapfile -t make_args < <(common_git_make_args) - make -j"$(nproc 2>/dev/null || printf 4)" "${make_args[@]}" git - - mkdir -p "$HOST_GIT_DIR" - cp git "$HOST_GIT_BINARY" - chmod 755 "$HOST_GIT_BINARY" - - log "Host test Git binary: $HOST_GIT_BINARY" - "$HOST_GIT_BINARY" --version -} - -build_android_git_for_abi() { - local abi="$1" - local cc="$2" - local output_dir="$ANDROID_JNI_DIR/$abi" - - require_path "$TOOLBIN/${cc}" - - log "Building Git for Android $abi" - cd "$GIT_SRC_DIR" - make clean >/dev/null 2>&1 || true - mapfile -t make_args < <(common_git_make_args) - make -j"$(nproc 2>/dev/null || printf 4)" \ - uname_S=Android \ - uname_O=Android \ - NO_PTHREADS=YesPlease \ - NO_LIBGEN_H=YesPlease \ - HAVE_DEV_TTY=YesPlease \ - CC="$cc" \ - AR=llvm-ar \ - RANLIB=llvm-ranlib \ - STRIP=llvm-strip \ - "${make_args[@]}" \ - git - - log "Validating Android $abi binary" - file git - readelf -h git | sed -n '1,12p' - - mkdir -p "$output_dir" - llvm-strip -s git -o "$output_dir/libgit.so" - chmod 755 "$output_dir/libgit.so" - ls -lh "$output_dir/libgit.so" -} - -build_android_git() { - require_path "$NDK_DIR" - export PATH="$TOOLBIN:$PATH" - - build_android_git_for_abi "arm64-v8a" "aarch64-linux-android${ANDROID_API}-clang" - build_android_git_for_abi "armeabi-v7a" "armv7a-linux-androideabi${ANDROID_API}-clang" - build_android_git_for_abi "x86" "i686-linux-android${ANDROID_API}-clang" - build_android_git_for_abi "x86_64" "x86_64-linux-android${ANDROID_API}-clang" -} - -print_usage() { - cat <&2 - print_usage >&2 - exit 1 - ;; - esac - - require_tool git - require_tool make - require_tool perl - require_tool clang - require_tool pkg-config - require_tool readelf - - ensure_git_source - - case "${1:---all}" in - --host) - build_host_git - ;; - --android) - build_android_git - ;; - --all) - build_host_git - build_android_git - ;; - esac -} - -main "$@" diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b1a6020..e2a1d00 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,8 +19,8 @@ android { applicationId = "solutions.tretter.githugandroid" minSdk = 26 targetSdk = 35 - versionCode = 130 - versionName = "0.1.129" + versionCode = 131 + versionName = "0.1.130" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true diff --git a/app/src/main/java/solutions/tretter/githugandroid/GameProgressStore.kt b/app/src/main/java/solutions/tretter/githugandroid/GameProgressStore.kt index 965bd13..a0bcfad 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GameProgressStore.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GameProgressStore.kt @@ -1,25 +1,24 @@ package solutions.tretter.githugandroid import android.content.Context -import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStoreFile +import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map import java.io.IOException +private val Context.gameProgressDataStore by preferencesDataStore(name = "game_progress_preferences") + class GameProgressStore(private val context: Context) { init { AppLog.d("ProgressStore", "GameProgressStore initialized") } - private val dataStore = PreferenceDataStoreFactory.create( - produceFile = { context.preferencesDataStoreFile("game_progress_preferences") } - ) + private val dataStore = context.applicationContext.gameProgressDataStore private val completedLevelsKey = stringPreferencesKey("completed_levels") private val activeLevelIdKey = stringPreferencesKey("active_level_id") diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitMessageEditorDialog.kt b/app/src/main/java/solutions/tretter/githugandroid/GitMessageEditorDialog.kt index d602e64..705b397 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitMessageEditorDialog.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitMessageEditorDialog.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.rememberScrollState @@ -50,7 +51,8 @@ fun GitMessageEditorDialog( Surface( modifier = Modifier .fillMaxWidth() - .fillMaxHeight(0.72f), + .fillMaxHeight(0.72f) + .imePadding(), color = PanelPrimary, shape = RoundedCornerShape(8.dp), ) { diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt index 58479f7..5c950b4 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt @@ -58,12 +58,23 @@ class GitRepositoryRuntime private constructor( fun isNativeGitAvailable(): Boolean = nativeGitBinary() != null fun unavailableMessage(): String { + val selectedAbi = Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown" + val nativeLibraryDir = context?.applicationInfo?.nativeLibraryDir ?: "unknown" return buildString { append("GitHug Android cannot start because this build does not include a native Git binary for this device ABI.") - append("\n\nSupported device ABIs: ") + append("\n\nCurrent device ABI: ") + append(selectedAbi) + append("\nSupported device ABIs: ") append(Build.SUPPORTED_ABIS.joinToString(", ").ifBlank { "unknown" }) - append("\nExpected binary: nativeLibraryDir/libgit.so") - append("\n\nInstall a build that bundles the cross-compiled Git binary for this device.") + append("\nSupported 64-bit ABIs: ") + append(Build.SUPPORTED_64_BIT_ABIS.joinToString(", ").ifBlank { "none" }) + append("\nSupported 32-bit ABIs: ") + append(Build.SUPPORTED_32_BIT_ABIS.joinToString(", ").ifBlank { "none" }) + append("\nPlatform: Android ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT})") + append("\nDevice: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE}; ${Build.HARDWARE})") + append("\nExpected binary: $nativeLibraryDir/libgit.so") + append("\n\nPlease report this information to the developer so support can be added for this device/platform.") + append("\nInstall a build that bundles the cross-compiled Git binary for this device.") } } @@ -124,8 +135,6 @@ class GitRepositoryRuntime private constructor( expandShellPathspecs(currentRepo, shellTokens) }.normalizeGitStageAlias() - rejectUnsupportedInteractiveGitCommand(currentRepo, expandedTokens)?.let { return it } - executeSyntheticGitCommand(currentRepo, command, expandedTokens)?.let { return it } val result = when (expandedTokens.first()) { @@ -538,7 +547,7 @@ class GitRepositoryRuntime private constructor( ) to listOf("Cloned ${tokens[2]} into $target") } val shouldUseSandboxSemantics = when (gitCommand) { - "add" -> tokens.any { it == "-p" || it == "--patch" } + "add" -> tokens.any { it == "-p" || it == "--patch" || it == "-i" || it == "--interactive" } "rebase" -> "-i" in tokens || "--onto" in tokens "merge" -> "--squash" in tokens || tokens.lastOrNull() == "mybranch" || tokens.lastOrNull() == "feature" "revert", "stash" -> true @@ -561,19 +570,6 @@ class GitRepositoryRuntime private constructor( } } - private fun rejectUnsupportedInteractiveGitCommand( - currentRepo: RepoState, - tokens: List, - ): Pair>? { - if (tokens.firstOrNull() != "git") return null - val subcommand = tokens.getOrNull(1) ?: return null - val interactive = tokens.drop(2).any { it == "-i" || it == "--interactive" } - if (subcommand == "add" && interactive) { - return currentRepo to listOf("Interactive staging is not supported in the mobile terminal. Use git add or git add -p .") - } - return null - } - private fun executeEcho(workingDir: File, currentRepo: RepoState, tokens: List): Pair> { val redirectIndex = tokens.indexOfFirst { it == ">" || it == ">>" } if (redirectIndex == -1 || redirectIndex == tokens.lastIndex) { diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitSandboxEngine.kt b/app/src/main/java/solutions/tretter/githugandroid/GitSandboxEngine.kt index 540c2d4..54d6a9a 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitSandboxEngine.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitSandboxEngine.kt @@ -97,7 +97,7 @@ object GitSandboxEngine { } parts.size >= 2 && (parts[1] == "stage" || parts[1] == "add") -> { if (parts.drop(2).any { it == "-i" || it == "--interactive" }) { - return repo to listOf("Interactive staging is not supported in the mobile terminal. Use git add or git add -p .") + return interactiveAdd(repo, parts.drop(2)) } val target = parts.drop(2).lastOrNull { !it.startsWith("-") } ?: return repo to listOf("usage: git add ") @@ -162,6 +162,28 @@ object GitSandboxEngine { } } + private fun interactiveAdd(repo: RepoState, arguments: List): Pair> { + val targets = arguments.filterNot { it == "-i" || it == "--interactive" || it.startsWith("--") } + val target = targets.lastOrNull() + val updated = repo.files.map { file -> + if (file.deleted) { + file + } else if (target == null || target == "." || file.name == target) { + file.copy(staged = true) + } else { + file + } + } + val stagedCount = updated.count { updatedFile -> + val before = repo.files.firstOrNull { it.name == updatedFile.name } + updatedFile.staged && before?.staged != true + } + return repo.copy(files = updated) to listOf( + "Interactive add is handled by GitHug Android.", + "Staged ${if (target == null || target == ".") "$stagedCount file(s)" else target}.", + ) + } + fun tokenizeCommand(command: String): List { return tokenizeShellCommand(command).map { it.value } } diff --git a/app/src/main/java/solutions/tretter/githugandroid/ManPageDialog.kt b/app/src/main/java/solutions/tretter/githugandroid/ManPageDialog.kt index 8a38615..c4d7c80 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/ManPageDialog.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/ManPageDialog.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState @@ -28,10 +29,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog @@ -55,6 +59,8 @@ fun ManPageDialog( ) { val horizontalScrollState = rememberScrollState() val verticalScrollState = rememberScrollState() + val density = LocalDensity.current + var contentViewportSize by remember { mutableStateOf(IntSize.Zero) } var searchQuery by remember(state.content) { mutableStateOf("") } var selectedMatch by remember(state.content) { mutableStateOf(0) } val contentLines = remember(state.content) { state.content.lines() } @@ -106,15 +112,28 @@ fun ManPageDialog( LaunchedEffect(selectedMatch, matches) { val match = matches.getOrNull(selectedMatch) ?: return@LaunchedEffect - verticalScrollState.animateScrollTo((match.lineIndex * 18).coerceAtMost(verticalScrollState.maxValue)) - horizontalScrollState.animateScrollTo((match.columnIndex * 8).coerceAtMost(horizontalScrollState.maxValue)) + val lineHeightPx = with(density) { 18.sp.toPx() } + val charWidthPx = with(density) { 8.sp.toPx() } + val viewportHeight = contentViewportSize.height.takeIf { it > 0 } ?: 0 + val viewportWidth = contentViewportSize.width.takeIf { it > 0 } ?: 0 + val verticalTarget = ( + match.lineIndex * lineHeightPx - viewportHeight / 2f + lineHeightPx / 2f + ).toInt() + .coerceIn(0, verticalScrollState.maxValue) + val horizontalTarget = ( + match.columnIndex * charWidthPx - viewportWidth / 2f + (match.end - match.start) * charWidthPx / 2f + ).toInt() + .coerceIn(0, horizontalScrollState.maxValue) + verticalScrollState.animateScrollTo(verticalTarget) + horizontalScrollState.animateScrollTo(horizontalTarget) } Dialog(onDismissRequest = onClose) { Surface( modifier = Modifier .fillMaxWidth() - .fillMaxHeight(0.86f), + .fillMaxHeight(0.86f) + .imePadding(), color = PanelPrimary, shape = RoundedCornerShape(8.dp), ) { @@ -204,6 +223,7 @@ fun ManPageDialog( modifier = Modifier .fillMaxWidth() .weight(1f) + .onSizeChanged { contentViewportSize = it } .background(TerminalBackground, RoundedCornerShape(6.dp)) .padding(10.dp) .horizontalScroll(horizontalScrollState) diff --git a/app/src/main/java/solutions/tretter/githugandroid/PaneLayoutStore.kt b/app/src/main/java/solutions/tretter/githugandroid/PaneLayoutStore.kt index d738d48..47e4bee 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/PaneLayoutStore.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/PaneLayoutStore.kt @@ -1,19 +1,18 @@ package solutions.tretter.githugandroid import android.content.Context -import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStoreFile +import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map import java.io.IOException +private val Context.paneLayoutDataStore by preferencesDataStore(name = "pane_layout_preferences") + class PaneLayoutStore(private val context: Context) { - private val dataStore = PreferenceDataStoreFactory.create( - produceFile = { context.preferencesDataStoreFile("pane_layout_preferences") } - ) + private val dataStore = context.applicationContext.paneLayoutDataStore private val orderKey = stringPreferencesKey("pane_order") private val collapsedKey = stringPreferencesKey("pane_collapsed") @@ -68,4 +67,4 @@ class PaneLayoutStore(private val context: Context) { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/solutions/tretter/githugandroid/TextEditorDialog.kt b/app/src/main/java/solutions/tretter/githugandroid/TextEditorDialog.kt index 6ad6c29..876e417 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/TextEditorDialog.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/TextEditorDialog.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.widthIn @@ -57,7 +58,8 @@ fun TextEditorDialog( Surface( modifier = Modifier .fillMaxWidth() - .fillMaxHeight(0.86f), + .fillMaxHeight(0.86f) + .imePadding(), color = PanelPrimary, shape = RoundedCornerShape(8.dp), ) { diff --git a/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt b/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt index 5d92c26..75f3c87 100644 --- a/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt +++ b/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt @@ -282,8 +282,8 @@ class GitSandboxEngineTest { val (updatedRepo, output) = GitSandboxEngine.execute(repo, "git stage -i") - assertEquals(repo, updatedRepo) - assertTrue(output.any { it.contains("Interactive staging is not supported") }) + assertTrue(updatedRepo.files.single { it.name == "README" }.staged) + assertTrue(output.any { it.contains("Interactive add is handled by GitHug Android") }) } private fun testGitBinary(): File {