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"
minSdk = 26
targetSdk = 35
versionCode = 130
versionName = "0.1.129"
versionCode = 131
versionName = "0.1.130"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true

View File

@@ -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")

View File

@@ -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),
) {

View File

@@ -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<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>> {
val redirectIndex = tokens.indexOfFirst { it == ">" || it == ">>" }
if (redirectIndex == -1 || redirectIndex == tokens.lastIndex) {

View File

@@ -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 <path> or git add -p <path>.")
return interactiveAdd(repo, parts.drop(2))
}
val target = parts.drop(2).lastOrNull { !it.startsWith("-") }
?: 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> {
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.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)

View File

@@ -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) {
}
}
}
}
}

View File

@@ -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),
) {

View File

@@ -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 {