ui: First card auto-focus, visible top-bar focus, account button
On launch and on every destination switch the first card of the content gets focus automatically - invisible focus on chrome confused 'Do I need to press DOWN first?'. The search button shows a white ring when focused, and the top bar gained a real account button (person icon + logged-in username) replacing the cryptic monitor icon. start-emulator.sh: headless-safe boot (no -gpuhost crash without a display), whitelists adb public keys headlessly (drop them into /tmp/jtube-adbkeys/), and starts the socat bridge itself.
This commit is contained in:
parent
b84be3e65e
commit
e55f7f53c1
5 changed files with 149 additions and 39 deletions
|
|
@ -18,6 +18,8 @@ import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
|
@ -108,12 +110,17 @@ private fun ChannelsList(
|
||||||
onOpenChannel: (String) -> Unit,
|
onOpenChannel: (String) -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val firstCardFocus = remember { androidx.compose.ui.focus.FocusRequester() }
|
||||||
|
LaunchedEffect(items.isNotEmpty()) {
|
||||||
|
if (items.isNotEmpty()) runCatching { firstCardFocus.requestFocus() }
|
||||||
|
}
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize().padding(vertical = 8.dp)) {
|
LazyColumn(modifier = Modifier.fillMaxSize().padding(vertical = 8.dp)) {
|
||||||
itemsIndexed(items) { index, channel ->
|
itemsIndexed(items) { index, channel ->
|
||||||
ChannelCard(
|
ChannelCard(
|
||||||
channel = channel,
|
channel = channel,
|
||||||
onClick = { onOpenChannel("${channel.name}@${channel.host}") },
|
onClick = { onOpenChannel("${channel.name}@${channel.host}") },
|
||||||
onLeftAtStart = if (index == 0) onOpenDrawer else null,
|
onLeftAtStart = if (index == 0) onOpenDrawer else null,
|
||||||
|
modifier = if (index == 0) Modifier.focusRequester(firstCardFocus) else Modifier,
|
||||||
)
|
)
|
||||||
if (index >= items.size - PagedListViewModel.LOOKAHEAD) {
|
if (index >= items.size - PagedListViewModel.LOOKAHEAD) {
|
||||||
LaunchedEffect(index) { viewModel.loadMore() }
|
LaunchedEffect(index) { viewModel.loadMore() }
|
||||||
|
|
@ -130,10 +137,15 @@ private fun ChannelsList(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChannelCard(channel: VideoChannelDto, onClick: () -> Unit, onLeftAtStart: (() -> Unit)? = null) {
|
private fun ChannelCard(
|
||||||
|
channel: VideoChannelDto,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLeftAtStart: (() -> Unit)? = null,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
Card(
|
Card(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = Modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||||
colors = CardDefaults.colors(containerColor = MaterialTheme.colorScheme.surface),
|
colors = CardDefaults.colors(containerColor = MaterialTheme.colorScheme.surface),
|
||||||
|
|
|
||||||
|
|
@ -268,6 +268,14 @@ private fun FocusScrollRow(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// On app start and destination switch: focus the FIRST card automatically
|
||||||
|
// (user request — otherwise invisible focus confuses "where am I").
|
||||||
|
LaunchedEffect(videos.isNotEmpty()) {
|
||||||
|
if (videos.isNotEmpty() && focusedIndex == -1) {
|
||||||
|
kotlinx.coroutines.delay(150) // let images/measuring settle
|
||||||
|
runCatching { focusRequesters[0]?.requestFocus() }
|
||||||
|
}
|
||||||
|
}
|
||||||
LaunchedEffect(focusedIndex) {
|
LaunchedEffect(focusedIndex) {
|
||||||
if (focusedIndex > 0) {
|
if (focusedIndex > 0) {
|
||||||
val visible = rowState.layoutInfo.visibleItemsInfo
|
val visible = rowState.layoutInfo.visibleItemsInfo
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package net.jeena.jtubetv.ui.screens
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.focusable
|
import androidx.compose.foundation.focusable
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
|
@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.List
|
import androidx.compose.material.icons.automirrored.filled.List
|
||||||
|
import androidx.compose.material.icons.filled.AccountCircle
|
||||||
import androidx.compose.material.icons.filled.History
|
import androidx.compose.material.icons.filled.History
|
||||||
import androidx.compose.material.icons.filled.Logout
|
import androidx.compose.material.icons.filled.Logout
|
||||||
import androidx.compose.material.icons.filled.PersonalVideo
|
import androidx.compose.material.icons.filled.PersonalVideo
|
||||||
|
|
@ -37,6 +39,7 @@ import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.input.key.key
|
import androidx.compose.ui.input.key.key
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
|
@ -48,6 +51,7 @@ import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.Icon
|
import androidx.tv.material3.Icon
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
|
import net.jeena.jtubetv.AppGraph
|
||||||
import net.jeena.jtubetv.data.api.VideoDto
|
import net.jeena.jtubetv.data.api.VideoDto
|
||||||
import net.jeena.jtubetv.ui.AppViewModel
|
import net.jeena.jtubetv.ui.AppViewModel
|
||||||
import net.jeena.jtubetv.ui.list.ListSource
|
import net.jeena.jtubetv.ui.list.ListSource
|
||||||
|
|
@ -212,38 +216,40 @@ private fun TopBar(
|
||||||
.padding(horizontal = 24.dp, vertical = 10.dp),
|
.padding(horizontal = 24.dp, vertical = 10.dp),
|
||||||
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(16.dp),
|
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
val searchFocus = remember { FocusRequester() }
|
RoundTopButton(
|
||||||
LaunchedEffect(Unit) { runCatching { searchFocus.requestFocus() } }
|
bg = Color(0xFFCE2B2B),
|
||||||
Box(
|
icon = Icons.Filled.Search,
|
||||||
|
desc = "Search",
|
||||||
|
onClick = onSearchClick,
|
||||||
|
)
|
||||||
|
// "Who is logged in" — account button with the username (SmartTube shows the user).
|
||||||
|
val accountFocused = remember { mutableStateOf(false) }
|
||||||
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(44.dp)
|
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(Color(0xFFCE2B2B))
|
.onFocusChanged { accountFocused.value = it.isFocused }
|
||||||
.focusRequester(searchFocus)
|
.background(if (accountFocused.value) Color(0x33FFFFFF) else Color.Transparent)
|
||||||
|
.then(
|
||||||
|
if (accountFocused.value) Modifier.border(3.dp, Color.White, CircleShape)
|
||||||
|
else Modifier
|
||||||
|
)
|
||||||
.focusable()
|
.focusable()
|
||||||
.clickable(onClick = onSearchClick),
|
.clickable(onClick = onAccountClick)
|
||||||
contentAlignment = Alignment.Center,
|
.padding(6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Search,
|
imageVector = Icons.Filled.AccountCircle,
|
||||||
contentDescription = "Search",
|
|
||||||
tint = Color.White,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(44.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(Color(0xFF262626))
|
|
||||||
.focusable()
|
|
||||||
.clickable(onClick = onAccountClick),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.PersonalVideo,
|
|
||||||
contentDescription = "Menu",
|
contentDescription = "Menu",
|
||||||
tint = Color(0xFFCCCCCC),
|
tint = Color(0xFFCCCCCC),
|
||||||
)
|
)
|
||||||
|
Text(
|
||||||
|
text = AppGraph.tokenStore.username?.substringBefore('@') ?: "Account",
|
||||||
|
color = Color(0xFFCCCCCC),
|
||||||
|
style = androidx.compose.material3.MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -255,6 +261,37 @@ private fun TopBar(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RoundTopButton(
|
||||||
|
bg: Color,
|
||||||
|
icon: ImageVector,
|
||||||
|
desc: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
var focused by remember { mutableStateOf(false) }
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.onFocusChanged { focused = it.isFocused }
|
||||||
|
.background(bg)
|
||||||
|
.then(
|
||||||
|
if (focused) Modifier.border(
|
||||||
|
3.dp, Color.White, CircleShape
|
||||||
|
) else Modifier
|
||||||
|
)
|
||||||
|
.focusable()
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = desc,
|
||||||
|
tint = Color.White,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Slide-in menu drawer: destination list + sign out, readable on a dark background
|
* Slide-in menu drawer: destination list + sign out, readable on a dark background
|
||||||
* (UX report: unselected grey was illegible). Selected item gets the red accent.
|
* (UX report: unselected grey was illegible). Selected item gets the red accent.
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ fun VideoListScreen(
|
||||||
// Which card was opened last — re-focus it when playback ends (UX report BUG-3).
|
// Which card was opened last — re-focus it when playback ends (UX report BUG-3).
|
||||||
var lastOpenedIndex by androidx.compose.runtime.saveable.rememberSaveable(epoch) { mutableStateOf(-1) }
|
var lastOpenedIndex by androidx.compose.runtime.saveable.rememberSaveable(epoch) { mutableStateOf(-1) }
|
||||||
LaunchedEffect(items.isNotEmpty(), source) {
|
LaunchedEffect(items.isNotEmpty(), source) {
|
||||||
if (items.isNotEmpty()) runCatching { initialFocus.requestFocus() }
|
if (items.isNotEmpty()) runCatching { requesters[0]?.requestFocus() ?: initialFocus.requestFocus() }
|
||||||
}
|
}
|
||||||
LaunchedEffect(restoreToken) {
|
LaunchedEffect(restoreToken) {
|
||||||
if (restoreToken > 0) {
|
if (restoreToken > 0) {
|
||||||
|
|
@ -67,7 +67,7 @@ fun VideoListScreen(
|
||||||
viewModel.refresh()
|
viewModel.refresh()
|
||||||
}
|
}
|
||||||
// focus the card that was playing; fall back to the first card
|
// focus the card that was playing; fall back to the first card
|
||||||
val target = requesters[lastOpenedIndex] ?: initialFocus
|
val target = requesters[lastOpenedIndex] ?: requesters[0] ?: initialFocus
|
||||||
runCatching { target.requestFocus() }
|
runCatching { target.requestFocus() }
|
||||||
lastOpenedIndex = -1
|
lastOpenedIndex = -1
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,9 @@
|
||||||
# be tested over scrcpy (ATD/system quirks make scrcpy fail against headless
|
# be tested over scrcpy (ATD/system quirks make scrcpy fail against headless
|
||||||
# in-container emulators) or when you simply want to watch it.
|
# in-container emulators) or when you simply want to watch it.
|
||||||
#
|
#
|
||||||
# The agent container bridges rutherford:5556 -> 127.0.0.1:5601 with socat, so after
|
# Boots on console :5600 (adbd :5601); the agent container bridges
|
||||||
# this script the emulator is reachable as:
|
# rutherford:5556 -> 127.0.0.1:5601 with socat, so after this script the emulator is
|
||||||
|
# reachable from anywhere as:
|
||||||
#
|
#
|
||||||
# adb connect rutherford:5556 && scrcpy -s rutherford:5556
|
# adb connect rutherford:5556 && scrcpy -s rutherford:5556
|
||||||
#
|
#
|
||||||
|
|
@ -17,14 +18,34 @@ set -euo pipefail
|
||||||
SDK="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}"
|
SDK="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}"
|
||||||
EMU="$SDK/emulator/emulator"
|
EMU="$SDK/emulator/emulator"
|
||||||
ADB="$SDK/platform-tools/adb"
|
ADB="$SDK/platform-tools/adb"
|
||||||
|
SDKM="$SDK/cmdline-tools/latest/bin/sdkmanager"
|
||||||
|
AVDM="$SDK/cmdline-tools/latest/bin/avdmanager"
|
||||||
AVD="${1:-jtubesc}"
|
AVD="${1:-jtubesc}"
|
||||||
PORT=5600 # console port; adbd listens on 5601 (the container bridges 5556 -> 5601)
|
PORT=5600
|
||||||
|
IMAGE="system-images;android-36;android-tv;x86_64"
|
||||||
|
|
||||||
[ -x "$EMU" ] || { echo "emulator not found at $EMU"; exit 1; }
|
[ -x "$EMU" ] || { echo "emulator not found at $EMU — install Android SDK cmdline-tools first"; exit 1; }
|
||||||
[ -f "$HOME/.android/avd/$AVD.ini" ] || {
|
|
||||||
echo "AVD $AVD not found; list: (ls ~/.android/avd)"; exit 1; }
|
|
||||||
|
|
||||||
# Free stale emulator instances holding our ports (only qemu from OUR SDK path).
|
# --- bootstrap: java, system image, AVD -------------------------------------
|
||||||
|
if ! command -v java >/dev/null; then
|
||||||
|
echo "no java on PATH — required by sdkmanager/AVD tools"
|
||||||
|
echo " install it (Arch): sudo pacman -S --noconfirm jdk21-openjdk"
|
||||||
|
echo " then re-run this script"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! "$SDKM" --list_installed 2>/dev/null | grep -q "$IMAGE"; then
|
||||||
|
echo "installing $IMAGE (~1.5 GB, once)…"
|
||||||
|
yes | "$SDKM" "$IMAGE" > /dev/null
|
||||||
|
echo "installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$HOME/.android/avd/$AVD.ini" ]; then
|
||||||
|
echo "creating AVD '$AVD'…"
|
||||||
|
echo no | "$AVDM" create avd -n "$AVD" -k "$IMAGE" -d tv_1080p > /dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- free stale emulator instances holding our port --------------------------
|
||||||
for pid in $(ps aux | awk '/[q]emu-system-x86_64/ {print $2}' 2>/dev/null); do
|
for pid in $(ps aux | awk '/[q]emu-system-x86_64/ {print $2}' 2>/dev/null); do
|
||||||
if tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null | grep -q "\-port $PORT"; then
|
if tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null | grep -q "\-port $PORT"; then
|
||||||
echo "Killing stale emulator pid $pid"
|
echo "Killing stale emulator pid $pid"
|
||||||
|
|
@ -34,13 +55,20 @@ done
|
||||||
sleep 1
|
sleep 1
|
||||||
|
|
||||||
ACCEL=()
|
ACCEL=()
|
||||||
if [ -e /dev/kvm ]; then
|
WINDOWED=1
|
||||||
ACCEL+=(-accel kvm); echo "/dev/kvm found — hardware acceleration on"
|
if [ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]; then
|
||||||
|
echo "desktop session detected — emulator window + GPU host"
|
||||||
else
|
else
|
||||||
echo "no /dev/kvm — falling back to software rendering (slow)"
|
WINDOWED=0
|
||||||
|
ACCEL+=(-no-window)
|
||||||
|
echo "headless (no display) — running without a window, swiftshader rendering"
|
||||||
|
fi
|
||||||
|
if [ -e /dev/kvm ]; then
|
||||||
|
[ $WINDOWED -eq 1 ] && ACCEL+=(-accel kvm)
|
||||||
|
echo "/dev/kvm found — hardware acceleration on"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Booting AVD '$AVD' on console :$PORT (adbd :$((PORT+1))) …"
|
echo "Booting AVD '$AVD' …"
|
||||||
setsid nohup "$EMU" -avd "$AVD" -port "$PORT" -gpu host "${ACCEL[@]:-}" \
|
setsid nohup "$EMU" -avd "$AVD" -port "$PORT" -gpu host "${ACCEL[@]:-}" \
|
||||||
-no-snapshot > /tmp/jtube-emu.log 2>&1 < /dev/null &
|
-no-snapshot > /tmp/jtube-emu.log 2>&1 < /dev/null &
|
||||||
echo "emulator PID $! — log: /tmp/jtube-emu.log"
|
echo "emulator PID $! — log: /tmp/jtube-emu.log"
|
||||||
|
|
@ -51,6 +79,31 @@ for i in $(seq 1 90); do
|
||||||
sleep 5
|
sleep 5
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# --- authorize remote adb clients headlessly ---------------------------------
|
||||||
|
# The host console transport is always trusted; use it to whitelist client keys
|
||||||
|
# so `adb connect rutherford:5556` from other machines works without a popup.
|
||||||
|
# Drop extra public keys into /tmp/jtube-adbkeys/ (one per file), e.g. from
|
||||||
|
# Chester: cat ~/.android/adbkey.pub > /tmp/jtube-adbkeys/chester.pub
|
||||||
|
"$ADB" -s "emulator-$PORT" root > /dev/null 2>&1 || true
|
||||||
|
for key_file in "$HOME/.android/adbkey.pub" /tmp/jtube-adbkeys/*.pub; do
|
||||||
|
[ -f "$key_file" ] || continue
|
||||||
|
"$ADB" -s "emulator-$PORT" push "$key_file" "/data/misc/adb/adb_keys" > /dev/null 2>&1 || \
|
||||||
|
"$ADB" -s "emulator-$PORT" shell "cat > /data/misc/adb/adb_keys" < "$key_file" || {
|
||||||
|
echo "could not whitelist $key_file";
|
||||||
|
}
|
||||||
|
done
|
||||||
|
"$ADB" -s "emulator-$PORT" shell "setprop service.adb.tcp.port 5601; stop adbd; start adbd" > /dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# --- the bridge: advertise adbd on the LAN as :5556 ---------------------------
|
||||||
|
if ! ss -tln 2>/dev/null | grep -q ":5556"; then
|
||||||
|
if command -v socat > /dev/null; then
|
||||||
|
setsid nohup socat TCP-LISTEN:5556,bind=0.0.0.0,fork,reuseaddr TCP:127.0.0.1:$((PORT+1)) > /tmp/jtube-socat.log 2>&1 < /dev/null &
|
||||||
|
echo "socat bridge listening on :5556 (pid $!)"
|
||||||
|
else
|
||||||
|
echo "install socat for the remote bridge: sudo pacman -S --noconfirm socat"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# Keep the JTube TV app current + running so scrcpy lands on it.
|
# Keep the JTube TV app current + running so scrcpy lands on it.
|
||||||
APK="$HOME/Projects/jtube-tv/app/build/outputs/apk/debug/app-debug.apk"
|
APK="$HOME/Projects/jtube-tv/app/build/outputs/apk/debug/app-debug.apk"
|
||||||
if [ -f "$APK" ]; then
|
if [ -f "$APK" ]; then
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue