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.
This commit is contained in:
Joe Tretter
2026-05-09 14:22:07 -05:00
parent edec66bfe6
commit 0acf8c9ab0
10 changed files with 80 additions and 213 deletions

View File

@@ -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 <<EOF_USAGE
Usage: bash ./CompileGitForAllTargetPlatforms.sh [--host | --android | --all]
--host Compile Git for the development host and copy it to build/host-git/libgit.so
--android Cross-compile Git for Android ABIs served through Google Play
--all Compile both host and Android targets (default)
EOF_USAGE
}
main() {
case "${1:---all}" in
--host|--android|--all)
;;
-h|--help)
print_usage
return
;;
*)
echo "Unknown argument: $1" >&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 "$@"

View File

@@ -19,8 +19,8 @@ android {
applicationId = "solutions.tretter.githugandroid" applicationId = "solutions.tretter.githugandroid"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 130 versionCode = 131
versionName = "0.1.129" versionName = "0.1.130"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true

View File

@@ -1,25 +1,24 @@
package solutions.tretter.githugandroid package solutions.tretter.githugandroid
import android.content.Context import android.content.Context
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.stringPreferencesKey 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.Flow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import java.io.IOException import java.io.IOException
private val Context.gameProgressDataStore by preferencesDataStore(name = "game_progress_preferences")
class GameProgressStore(private val context: Context) { class GameProgressStore(private val context: Context) {
init { init {
AppLog.d("ProgressStore", "GameProgressStore initialized") AppLog.d("ProgressStore", "GameProgressStore initialized")
} }
private val dataStore = PreferenceDataStoreFactory.create( private val dataStore = context.applicationContext.gameProgressDataStore
produceFile = { context.preferencesDataStoreFile("game_progress_preferences") }
)
private val completedLevelsKey = stringPreferencesKey("completed_levels") private val completedLevelsKey = stringPreferencesKey("completed_levels")
private val activeLevelIdKey = stringPreferencesKey("active_level_id") private val activeLevelIdKey = stringPreferencesKey("active_level_id")

View File

@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -50,7 +51,8 @@ fun GitMessageEditorDialog(
Surface( Surface(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.fillMaxHeight(0.72f), .fillMaxHeight(0.72f)
.imePadding(),
color = PanelPrimary, color = PanelPrimary,
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
) { ) {

View File

@@ -58,12 +58,23 @@ class GitRepositoryRuntime private constructor(
fun isNativeGitAvailable(): Boolean = nativeGitBinary() != null fun isNativeGitAvailable(): Boolean = nativeGitBinary() != null
fun unavailableMessage(): String { fun unavailableMessage(): String {
val selectedAbi = Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"
val nativeLibraryDir = context?.applicationInfo?.nativeLibraryDir ?: "unknown"
return buildString { return buildString {
append("GitHug Android cannot start because this build does not include a native Git binary for this device ABI.") 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(Build.SUPPORTED_ABIS.joinToString(", ").ifBlank { "unknown" })
append("\nExpected binary: nativeLibraryDir/libgit.so") append("\nSupported 64-bit ABIs: ")
append("\n\nInstall a build that bundles the cross-compiled Git binary for this device.") 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) expandShellPathspecs(currentRepo, shellTokens)
}.normalizeGitStageAlias() }.normalizeGitStageAlias()
rejectUnsupportedInteractiveGitCommand(currentRepo, expandedTokens)?.let { return it }
executeSyntheticGitCommand(currentRepo, command, expandedTokens)?.let { return it } executeSyntheticGitCommand(currentRepo, command, expandedTokens)?.let { return it }
val result = when (expandedTokens.first()) { val result = when (expandedTokens.first()) {
@@ -538,7 +547,7 @@ class GitRepositoryRuntime private constructor(
) to listOf("Cloned ${tokens[2]} into $target") ) to listOf("Cloned ${tokens[2]} into $target")
} }
val shouldUseSandboxSemantics = when (gitCommand) { 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 "rebase" -> "-i" in tokens || "--onto" in tokens
"merge" -> "--squash" in tokens || tokens.lastOrNull() == "mybranch" || tokens.lastOrNull() == "feature" "merge" -> "--squash" in tokens || tokens.lastOrNull() == "mybranch" || tokens.lastOrNull() == "feature"
"revert", "stash" -> true "revert", "stash" -> true
@@ -561,19 +570,6 @@ class GitRepositoryRuntime private constructor(
} }
} }
private fun rejectUnsupportedInteractiveGitCommand(
currentRepo: RepoState,
tokens: List<String>,
): Pair<RepoState, List<String>>? {
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 <path> or git add -p <path>.")
}
return null
}
private fun executeEcho(workingDir: File, currentRepo: RepoState, tokens: List<String>): Pair<RepoState, List<String>> { private fun executeEcho(workingDir: File, currentRepo: RepoState, tokens: List<String>): Pair<RepoState, List<String>> {
val redirectIndex = tokens.indexOfFirst { it == ">" || it == ">>" } val redirectIndex = tokens.indexOfFirst { it == ">" || it == ">>" }
if (redirectIndex == -1 || redirectIndex == tokens.lastIndex) { if (redirectIndex == -1 || redirectIndex == tokens.lastIndex) {

View File

@@ -97,7 +97,7 @@ object GitSandboxEngine {
} }
parts.size >= 2 && (parts[1] == "stage" || parts[1] == "add") -> { parts.size >= 2 && (parts[1] == "stage" || parts[1] == "add") -> {
if (parts.drop(2).any { it == "-i" || it == "--interactive" }) { 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 <path> or git add -p <path>.") return interactiveAdd(repo, parts.drop(2))
} }
val target = parts.drop(2).lastOrNull { !it.startsWith("-") } val target = parts.drop(2).lastOrNull { !it.startsWith("-") }
?: return repo to listOf("usage: git add <path>") ?: return repo to listOf("usage: git add <path>")
@@ -162,6 +162,28 @@ object GitSandboxEngine {
} }
} }
private fun interactiveAdd(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
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<String> { fun tokenizeCommand(command: String): List<String> {
return tokenizeShellCommand(command).map { it.value } return tokenizeShellCommand(command).map { it.value }
} }

View File

@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -28,10 +29,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
@@ -55,6 +59,8 @@ fun ManPageDialog(
) { ) {
val horizontalScrollState = rememberScrollState() val horizontalScrollState = rememberScrollState()
val verticalScrollState = rememberScrollState() val verticalScrollState = rememberScrollState()
val density = LocalDensity.current
var contentViewportSize by remember { mutableStateOf(IntSize.Zero) }
var searchQuery by remember(state.content) { mutableStateOf("") } var searchQuery by remember(state.content) { mutableStateOf("") }
var selectedMatch by remember(state.content) { mutableStateOf(0) } var selectedMatch by remember(state.content) { mutableStateOf(0) }
val contentLines = remember(state.content) { state.content.lines() } val contentLines = remember(state.content) { state.content.lines() }
@@ -106,15 +112,28 @@ fun ManPageDialog(
LaunchedEffect(selectedMatch, matches) { LaunchedEffect(selectedMatch, matches) {
val match = matches.getOrNull(selectedMatch) ?: return@LaunchedEffect val match = matches.getOrNull(selectedMatch) ?: return@LaunchedEffect
verticalScrollState.animateScrollTo((match.lineIndex * 18).coerceAtMost(verticalScrollState.maxValue)) val lineHeightPx = with(density) { 18.sp.toPx() }
horizontalScrollState.animateScrollTo((match.columnIndex * 8).coerceAtMost(horizontalScrollState.maxValue)) 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) { Dialog(onDismissRequest = onClose) {
Surface( Surface(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.fillMaxHeight(0.86f), .fillMaxHeight(0.86f)
.imePadding(),
color = PanelPrimary, color = PanelPrimary,
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
) { ) {
@@ -204,6 +223,7 @@ fun ManPageDialog(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f) .weight(1f)
.onSizeChanged { contentViewportSize = it }
.background(TerminalBackground, RoundedCornerShape(6.dp)) .background(TerminalBackground, RoundedCornerShape(6.dp))
.padding(10.dp) .padding(10.dp)
.horizontalScroll(horizontalScrollState) .horizontalScroll(horizontalScrollState)

View File

@@ -1,19 +1,18 @@
package solutions.tretter.githugandroid package solutions.tretter.githugandroid
import android.content.Context import android.content.Context
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.stringPreferencesKey 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.catch
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import java.io.IOException import java.io.IOException
private val Context.paneLayoutDataStore by preferencesDataStore(name = "pane_layout_preferences")
class PaneLayoutStore(private val context: Context) { class PaneLayoutStore(private val context: Context) {
private val dataStore = PreferenceDataStoreFactory.create( private val dataStore = context.applicationContext.paneLayoutDataStore
produceFile = { context.preferencesDataStoreFile("pane_layout_preferences") }
)
private val orderKey = stringPreferencesKey("pane_order") private val orderKey = stringPreferencesKey("pane_order")
private val collapsedKey = stringPreferencesKey("pane_collapsed") private val collapsedKey = stringPreferencesKey("pane_collapsed")
@@ -68,4 +67,4 @@ class PaneLayoutStore(private val context: Context) {
} }
} }
} }
} }

View File

@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
@@ -57,7 +58,8 @@ fun TextEditorDialog(
Surface( Surface(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.fillMaxHeight(0.86f), .fillMaxHeight(0.86f)
.imePadding(),
color = PanelPrimary, color = PanelPrimary,
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
) { ) {

View File

@@ -282,8 +282,8 @@ class GitSandboxEngineTest {
val (updatedRepo, output) = GitSandboxEngine.execute(repo, "git stage -i") val (updatedRepo, output) = GitSandboxEngine.execute(repo, "git stage -i")
assertEquals(repo, updatedRepo) assertTrue(updatedRepo.files.single { it.name == "README" }.staged)
assertTrue(output.any { it.contains("Interactive staging is not supported") }) assertTrue(output.any { it.contains("Interactive add is handled by GitHug Android") })
} }
private fun testGitBinary(): File { private fun testGitBinary(): File {