CICD: сборка APK на release → F-Droid; versionCode из тега; фикс скролла каталога и масштаба
build / Android APK to F-Droid (release) Failing after 36s

This commit is contained in:
2026-08-21 19:27:02 +03:00
parent ee86b9dc50
commit ee9700c558
11 changed files with 267 additions and 7 deletions
+50
View File
@@ -0,0 +1,50 @@
name: build
on:
release:
types: [published]
jobs:
android:
name: Android APK to F-Droid
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Setup Java 21
uses: actions/setup-java@v4
with:
java-version: 21
distribution: adopt
- name: Setup Android SDK
shell: bash
run: |
set -euo pipefail
export ANDROID_HOME=$HOME/android-sdk
mkdir -p "$ANDROID_HOME/cmdline-tools"
if [ ! -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then
curl -sSL -o /tmp/cmdtools.zip https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
unzip -q -o /tmp/cmdtools.zip -d "$ANDROID_HOME/cmdline-tools"
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
fi
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null || true
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-35" "build-tools;36.0.0" > /dev/null
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
- name: Build APKs
shell: bash
run: |
chmod +x gradlew
./gradlew :app-phone:assembleRelease :app-glasses:assembleRelease \
-PversionCode=${{ gitea.ref_name }} \
--no-daemon --stacktrace
- name: Publish phone to F-Droid
uses: https://git.binom.pw/subochev/devops/fdroid-publish@main
with:
apk_path: "app-phone/build/outputs/apk/release/app-phone-release-unsigned.apk"
- name: Publish glasses to F-Droid
uses: https://git.binom.pw/subochev/devops/fdroid-publish@main
with:
apk_path: "app-glasses/build/outputs/apk/release/app-glasses-release-unsigned.apk"
+106
View File
@@ -0,0 +1,106 @@
# Задача: масштаб на всех экранах очков + сохранение скролла каталога
## Баг 1: ConnectionScreen игнорирует масштаб
**Симптом (пользователь):** «в режиме НЕ просмотра видео (пока ничего не включено)
очки игнорируют выставленный масштаб». Плюс «масштаб будто бы не сохраняется —
сразу после старта масштаб 1».
**Корень:** масштаб (`GlassesApp.instance.scale`, 0.5..1) применяется ТОЛЬКО в
`GlassesMovieScreen.kt` (контейнер `width((screenWidthPx * scale).toInt().px)` +
offset к центру). А `ConnectionScreen()` в
`app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt` (строка ~354)
рисует контент на ПОЛНУЮ половину глаза (`Modifier.width(screenWidthPx.px)`) —
scale не читает вообще. Поэтому и «не сохраняется»: сохранённый scale в prefs есть,
но на не-видео экране он не виден.
**Фикс:** в `ConnectionScreen()` применять scale так же, как в GlassesMovieScreen:
```kotlin
fun ConnectionScreen() {
val connection = GlassesApp.instance.hostConnection
val connected by connection.connected.collectAsState()
val status by connection.status.collectAsState()
val messages by connection.messages.collectAsState()
val scale by GlassesApp.instance.scale.collectAsState() // ← НОВОЕ
Binocular(scale = false) {
val screenWidthPx = BinocularScreenSize.current.width
val screenHeightPx = BinocularScreenSize.current.height
// масштаб через layout — как в GlassesMovieScreen
val containerW = (screenWidthPx * scale).toInt()
val containerH = (screenHeightPx * scale).toInt()
val offsetX = (screenWidthPx * (1f - scale)).toInt() // сдвиг к носу (левый глаз)
Box(
modifier = Modifier
.width(containerW.px)
.height(containerH.px)
.offset(x = offsetX.px)
.background(Color.Black),
) {
... существующий контент ...
}
}
}
```
Внутри контейнера всё остаётся: Column с текстами, StatusPanelCompose,
оверлей распознавания — но всё это теперь в уменьшенном контейнере.
Импорт `androidx.compose.foundation.layout.offset` и `width/height` уже есть.
⚠️ ВАЖНО: `Modifier.offset(x=...)` — смещение внутри родителя. Внутри
`Binocular(scale=false)` контент рисуется на половину 640. При scale<1 контейнер
уже (например 448 при 0.7) и сдвинут к центру (к носу): offsetX = 640*(1-0.7)=192.
Для левого глаза сдвиг вправо (к центру 640), правый глаз — как в GlassesMovieScreen
(offset 0, контейнер прижат к центру слева). Смотри `scaleOffsetX` в
GlassesMovieScreen.kt:143 — скопировать ту же логику (side из BinocularContent,
там есть `Side.LEFT/RIGHT` через CompositionLocal).
## Баг 2: скролл каталога теряется при навигации
**Симптом (пользователь):** «кручу вниз до нужной папки, захожу, тыкаю назад
(кнопку или аппаратную) и я оказываюсь в предыдущем окне в самом начале (скролл
не сохранился). Надо сохранять скролл, даже если зашёл в папку, потом ещё в
папку, потом ещё — и т.д.»
**Корень:** `app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/CatalogScreen.kt`:
`LazyVerticalGrid` (строка 220) БЕЗ `LazyGridState` — позиция не хранится.
`CatalogState` живёт на корне (переживает переходы), но позицию скролла не помнит.
**Фикс:** добавить в `CatalogState`:
- `private var scrollIndex by mutableStateOf(0)` / `scrollOffset by mutableStateOf(0)` — текущая позиция
- `private val savedScroll = mutableMapOf<Path, Pair<Int, Int>>()` — сохранённые позиции по пути
- в `openSeries`/`openSeason`/`openFolder`: **сначала** `savedScroll[path] = scrollIndex to scrollOffset`, **потом** менять path
- в `back()`: после смены path — восстановить `(scrollIndex, scrollOffset) = savedScroll[path] ?: (0,0)`
- методы `setScroll(index, offset)` (для snapshotFlow из UI) и `getSavedScroll(): Pair<Int,Int>` (текущий path)
В `CatalogScreen` composable:
```kotlin
val gridState = rememberLazyGridState()
// восстанавливаем позицию при смене пути/загрузке
LaunchedEffect(state.path, state.items) {
val (idx, off) = state.savedScrollForCurrentPath()
gridState.scrollToItem(idx, off)
}
// сохраняем позицию при скролле
LaunchedEffect(gridState) {
snapshotFlow {
gridState.firstVisibleItemIndex to gridState.firstVisibleItemScrollOffset
}.collect { (idx, off) -> state.setScroll(idx, off) }
}
```
и `LazyVerticalGrid(state = gridState, ...)`.
Ключ сохранения — РОДИТЕЛЬСКИЙ path (тот, откуда ушли). При back() path меняется
на родителя — достаём сохранённое. Вложенные папки работают автоматически:
каждый уровень сохраняет свою позицию.
Импорты: `androidx.compose.foundation.lazy.grid.rememberLazyGridState`,
`androidx.compose.runtime.snapshotFlow`.
## Критерии приёмки
1. Сборка: `./gradlew :app-phone:assembleDebug :app-glasses:assembleDebug` — успех.
2. Юнит-тесты: `./gradlew :lib-core:testDebugUnitTest :app-phone:testDebugUnitTest :app-glasses:testDebugUnitTest` — зелёные.
3. Ручная проверка на очках: выставить масштаб 0.7 → ConnectionScreen (без видео) тоже уменьшен (чёрные поля по краям, картинка/текст сдвинуты к носу).
4. Ручная проверка: скролл каталога вниз → зайти в папку → назад → позиция на месте; то же для вложенных папок.
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "pw.binom.viewmate.glasses"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
versionCode = (project.findProperty("versionCode") as? String)?.toIntOrNull() ?: 1
versionName = (project.findProperty("versionName") as? String) ?: "0.1.0"
}
buildTypes {
@@ -45,6 +45,9 @@ class GlassesApp : Application() {
/** Текст ответа ассистента (обновляется на ShowText); остаётся до новой сессии. */
val assistantText = MutableStateFlow("")
/** Текущая Activity (для закрытия приложения по команде с телефона). */
var mainActivity: MainActivity? = null
/** Применить и сохранить масштаб. */
fun applyScale(value: Float) {
val v = value.coerceIn(0.5f, 1f)
@@ -31,6 +31,7 @@ import pw.binom.viewmate.core.protocol.PlayVideo
import pw.binom.viewmate.core.protocol.PlaybackPosition
import pw.binom.viewmate.core.protocol.SetMode
import pw.binom.viewmate.core.protocol.SetScale
import pw.binom.viewmate.core.protocol.Shutdown
import pw.binom.viewmate.core.protocol.ShowText
import pw.binom.viewmate.core.protocol.SttCancel
import pw.binom.viewmate.core.protocol.SttDone
@@ -288,6 +289,14 @@ class HostConnection(
log("stt", "отменено")
GlassesApp.instance.listening.value = false
}
is Shutdown -> {
addMessage("Shutdown: закрываю приложение")
// как в pair-view: finish() Activity очков
GlassesApp.instance.mainActivity?.finishAffinity()
// страховка: процесс завершится сам, но kill убирает зависшие потоки (RayNeo)
android.os.Process.killProcess(android.os.Process.myPid())
}
}
}
@@ -19,10 +19,10 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
@@ -56,9 +56,12 @@ import pw.binom.viewmate.glasses.gestures.templeActionToCommand
import pw.binom.viewmate.glasses.status.StatusPanelCompose
import pw.binom.viewmate.glasses.stt.SttMicStream
import pw.binom.viewmate.glasses.ui.Binocular
import pw.binom.viewmate.glasses.ui.BinocularScreenSide
import pw.binom.viewmate.glasses.ui.BinocularScreenSize
import pw.binom.viewmate.glasses.ui.GlassesMovieScreen
import pw.binom.viewmate.glasses.ui.Side
import pw.binom.viewmate.glasses.ui.px
import pw.binom.viewmate.glasses.ui.scaleOffsetX
class MainActivity : BaseEventActivity() {
private var resumeAfterScreenOn = false
@@ -112,6 +115,7 @@ class MainActivity : BaseEventActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
GlassesApp.instance.mainActivity = this
collectTempleActions()
disableTrackpadSounds()
WindowCompat.setDecorFitsSystemWindows(window, false)
@@ -311,6 +315,7 @@ class MainActivity : BaseEventActivity() {
}
override fun onDestroy() {
GlassesApp.instance.mainActivity = null
super.onDestroy()
unregisterReceiver(screenReceiver)
unregisterReceiver(debugReceiver)
@@ -354,12 +359,27 @@ fun ConnectionScreen() {
val connected by connection.connected.collectAsState()
val status by connection.status.collectAsState()
val messages by connection.messages.collectAsState()
val scale by GlassesApp.instance.scale.collectAsState()
// Бинокулярно: контент дублируется в оба глаза, панель — по центру каждой
// половины (явная ширина половины, иначе fillMaxSize растянет на 1280).
Binocular(scale = false) {
val screenWidthPx = BinocularScreenSize.current.width
Box(modifier = Modifier.width(screenWidthPx.px).fillMaxHeight().background(Color.Black)) {
val screenHeightPx = BinocularScreenSize.current.height
val side = BinocularScreenSide.current
// Масштаб через layout: контейнер каждого глаза — scale от половины экрана,
// сдвинут к центру (к носу) на (1 - scale) своей ширины.
val containerW = (screenWidthPx * scale).toInt()
val containerH = (screenHeightPx * scale).toInt()
Box(
modifier = Modifier
.width(containerW.px)
.height(containerH.px)
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px)
.background(Color.Black),
) {
Column(
modifier = Modifier.fillMaxSize().padding(start = 24.dp, end = 24.dp, top = 56.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -377,6 +397,7 @@ fun ConnectionScreen() {
StatusPanelCompose(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
scale = scale,
)
// Оверлей распознавания: затемнение + накопленный текст (поверх всего).
+2 -2
View File
@@ -37,8 +37,8 @@ android {
applicationId = "pw.binom.viewmate.phone"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
versionCode = (project.findProperty("versionCode") as? String)?.toIntOrNull() ?: 1
versionName = (project.findProperty("versionName") as? String) ?: "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// LLM-ключ из local.properties (llm.apiKey) — вне git; пустой = ещё не задан
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
@@ -26,6 +27,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -77,6 +79,13 @@ class CatalogState(
private set
var path by mutableStateOf<Path>(Path.Root)
private set
var scrollIndex by mutableStateOf(0)
private set
var scrollOffset by mutableStateOf(0)
private set
/** Сохранённые позиции скролла по пути (ключ — путь, из которого ушли). */
private val savedScroll = mutableMapOf<CatalogState.Path, Pair<Int, Int>>()
private var searchJob: Job? = null
private var loadJob: Job? = null
@@ -102,16 +111,19 @@ class CatalogState(
}
fun openSeries(item: JellyfinItem) {
savedScroll[path] = scrollIndex to scrollOffset
path = Path.Series(item)
refresh()
}
fun openSeason(season: JellyfinItem, series: JellyfinItem) {
savedScroll[path] = scrollIndex to scrollOffset
path = Path.Season(season, series)
refresh()
}
fun openFolder(item: JellyfinItem) {
savedScroll[path] = scrollIndex to scrollOffset
path = Path.Folder(item)
refresh()
}
@@ -123,9 +135,27 @@ class CatalogState(
is Path.Season -> Path.Series(p.series)
is Path.Folder -> Path.Root
}
val (idx, off) = savedScroll[path] ?: Pair(0, 0)
scrollIndex = idx
scrollOffset = off
refresh()
}
/** Сохранить позицию для текущего пути (перед уходом на детали и т.п.). */
fun saveScrollNow() {
savedScroll[path] = scrollIndex to scrollOffset
}
/** Сохранить текущую позицию (вызывается при скролле из UI). */
fun setScroll(index: Int, offset: Int) {
scrollIndex = index
scrollOffset = offset
}
/** Позиция скролла для текущего пути (восстановление после навигации). */
fun savedScrollForCurrentPath(): Pair<Int, Int> =
savedScroll[path] ?: Pair(0, 0)
/** Заголовок под шапкой: текущая вкладка или хлебная крошка внутри сериала/папки. */
fun subtitle(): String = when (val p = path) {
Path.Root -> when (tab) {
@@ -200,6 +230,21 @@ fun CatalogScreen(
) {
val app = PhoneApp.instance
val userId by app.userId.collectAsState()
// Позиция скролла каталога: сохраняется/восстанавливается между навигациями.
val gridState = rememberLazyGridState()
// Восстановить позицию при смене пути/загрузке.
LaunchedEffect(state.path, state.items) {
val (idx, off) = state.savedScrollForCurrentPath()
gridState.scrollToItem(idx, off)
}
// Сохранять позицию при скролле.
LaunchedEffect(gridState) {
snapshotFlow {
gridState.firstVisibleItemIndex to gridState.firstVisibleItemScrollOffset
}.collect { (idx, off) -> state.setScroll(idx, off) }
}
val downloaded by app.state.downloadedFlow.collectAsState()
val localDownloaded by app.downloadManager.downloadedIds.collectAsState()
@@ -218,6 +263,7 @@ fun CatalogScreen(
ScreenPlaceholder(if (state.query.isNotBlank()) "ничего не нашлось" else "пусто")
} else {
LazyVerticalGrid(
state = gridState,
columns = GridCells.Fixed(2),
contentPadding = PaddingValues(14.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
@@ -246,7 +292,11 @@ fun CatalogScreen(
state.openSeason(item, series)
}
item.Type in CatalogState.CONTAINER_TYPES -> state.openFolder(item)
else -> onItemClick(item)
else -> {
// уходим на детали — сохранить позицию, чтобы назад вернул скролл
state.saveScrollNow()
onItemClick(item)
}
}
},
)
@@ -213,6 +213,15 @@ fun GlassesScreen(
Spacer(modifier = Modifier.height(12.dp))
RemoteButton(
text = "⏻ Выключить очки",
big = true,
onClick = {
scope.launch { app.actions?.shutdownGlasses() }
},
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp),
)
RemoteButton(
text = "Скачанное на очках →",
big = true,
@@ -18,6 +18,7 @@ import pw.binom.viewmate.core.protocol.PlayVideo
import pw.binom.viewmate.core.protocol.SetMode
import pw.binom.viewmate.core.protocol.SetScale
import pw.binom.viewmate.core.protocol.ShowText
import pw.binom.viewmate.core.protocol.Shutdown
/**
* Действия «телефона»: каталог Jellyfin, зеркала media-mirror, очки.
@@ -267,5 +268,11 @@ class PhoneActions(
return "масштаб → $clamped"
}
/** Закрыть приложение на очках (телефон остаётся открытым). */
suspend fun shutdownGlasses(): String {
sender.send(Shutdown())
return "выключение очков"
}
private fun ticksToMinutes(ticks: Long): Long = ticks / 10_000_000 / 60
}
@@ -95,3 +95,8 @@ data class SttDone(val full: String) : HostToGlasses
@Serializable
@SerialName("stt_cancel")
class SttCancel : HostToGlasses
/** Полное выключение приложения на очках (по команде с телефона). */
@Serializable
@SerialName("shutdown")
class Shutdown : HostToGlasses