diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index f99eb2a..3fb4381 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -3,9 +3,9 @@ version: 2
multi-ecosystem-groups:
all-dependencies:
schedule:
- interval: "weekly"
- day: "monday"
- time: "09:00"
+ # Every two months, on the 1st. Security updates are not bound to this schedule.
+ interval: "cron"
+ cronjob: "0 9 1 */2 *"
timezone: "Asia/Seoul"
open-pull-requests-limit: 5
labels:
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index e6c56c1..3d0ad98 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -47,7 +47,7 @@ jobs:
"${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" "cmake;3.22.1"
- name: Initialize CodeQL
- uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4
+ uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4
with:
languages: c-cpp
build-mode: manual
@@ -62,7 +62,7 @@ jobs:
--stacktrace
- name: Analyze C/C++
- uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4
+ uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4
with:
category: "/language:c-cpp"
@@ -92,7 +92,7 @@ jobs:
run: chmod +x gradlew
- name: Initialize CodeQL
- uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4
+ uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4
with:
languages: java-kotlin
build-mode: manual
@@ -116,6 +116,6 @@ jobs:
--stacktrace
- name: Analyze Kotlin
- uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4
+ uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4
with:
category: "/language:java-kotlin"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 672bfe8..d57ac74 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,27 @@
All notable changes to mobileGF2logger are documented here.
+## 2.6.0 - 2026-10-19
+
+### Added
+
+- Add standby mode from Home's moon button: an all-black, minimum-brightness
+ screen that keeps the phone awake and unlocked overnight. It shows what it is
+ for three seconds on every entry, then turns every pixel off; tapping the
+ screen, Back, the power button, or switching apps leaves it and the previous
+ brightness returns.
+- Offer the battery-optimization exemption once, the first time standby mode is
+ entered.
+- Record members automatically from standby mode: five minutes before a saved
+ server's game day ends, standby starts capture, opens the game, taps Start,
+ waits for the roster to be saved, closes the game, and locks the phone once
+ every chosen server is done. With Platoons on several servers, standby asks
+ which to record each time, one server per client, and runs them one after
+ another. Switching to another app or Home while the game is open stands
+ down without closing anything or locking. Needs the new GF2logger
+ accessibility service; a summary of each night's results appears on Home
+ until it is closed.
+
## 2.5.0 - 2026-10-16
### Added
diff --git a/README.md b/README.md
index 41ac6ae..a5bbcfc 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,14 @@ never stored.
- Keeps a newly detected Platoon's packets in bounded memory until the user confirms one of the verified client's compatible servers; unconfirmed data is discarded on force-stop or process death.
- Provides profile management for correcting server metadata without moving data and for deleting one isolated Platoon behind two confirmations and an exact-name check.
- Guides first-time users through Main, Settings, Platoon management, weekly controls, and parsed-packet pages, with a persistent English/Korean selector and Skip action; the guide can be replayed from Settings.
+- Offers an overnight standby mode from Home's moon button: an all-black,
+ minimum-brightness screen that keeps the phone awake. With the optional
+ GF2logger accessibility service turned on, standby records members by itself
+ five minutes before each chosen server's daily reset: it starts capture, opens
+ the game, taps Start on the title screen, waits for the roster, closes the game
+ from Recents, and locks the phone after the last server. The service only taps
+ while the game is in front and reads nothing on screen except the foreground
+ app and the app names on Recents cards.
- Draws edge to edge and keeps drafts, selections, and pending exports across rotation, theme or language changes, and process recreation.
- Supports English and Korean, System/Light/Dark themes, and the six known
Darkwinter/HaoPlay server-region reset presets converted to the phone timezone.
diff --git a/README_KR.md b/README_KR.md
index 1f7d499..a30d358 100644
--- a/README_KR.md
+++ b/README_KR.md
@@ -33,6 +33,7 @@ mobileGF2logger는 서클장을 위한 가벼운 비루팅 앱입니다. Android
- 새 서클은 확인된 클라이언트에 맞는 서버를 사용자가 선택할 때까지 패킷을 제한된 메모리에만 보관하며, 강제 종료 또는 프로세스 종료 시 확인되지 않은 데이터를 버립니다.
- 서버 정보는 데이터를 이동하지 않고 수정할 수 있으며, 서클 삭제는 두 번의 확인과 정확한 서클 이름 입력을 거쳐 해당 격리 데이터만 제거합니다.
- 첫 사용 시 메인, 설정, 서클 관리, 주간 기능, 파싱 패킷 화면을 안내하며, 한국어/English 전환과 건너뛰기를 지원합니다. 안내는 설정에서 다시 볼 수 있습니다.
+- 홈의 달 버튼으로 밤샘 대기 모드를 켤 수 있습니다. 화면을 완전히 검게, 밝기를 최소로 두고 휴대전화를 깨어 있게 유지합니다. 선택 사항인 GF2logger 접근성 서비스를 켜면, 선택한 서버마다 일일 초기화 5분 전에 캡처를 시작하고 게임을 열어 타이틀 화면의 시작을 누른 뒤 멤버 명단을 기다렸다가, 최근 앱 화면에서 게임을 닫고 마지막 서버가 끝나면 휴대전화를 잠급니다. 이 서비스는 게임이 앞에 있을 때만 누르며, 앞에 있는 앱과 최근 앱 카드의 앱 이름 외에는 화면 내용을 읽지 않습니다.
- 화면을 가장자리까지 그리며, 회전·테마·언어 변경이나 프로세스 재생성 후에도 작성 중인 내용과 선택, 대기 중인 내보내기를 유지합니다.
- 영어와 한국어, 시스템/라이트/다크 테마를 지원합니다. 알려진 Darkwinter/HaoPlay 6개 서버 지역의 초기화 시각을 기기 시간대로 환산하며, 감지된 서클을 선택하면 해당 프로필에 저장된 지역을 자동으로 따릅니다.
- HaoPlay(`com.haoplay.game.and.exilium`)와 Darkwinter(`com.Sunborn.SnqxExilium.Glo`) Android 클라이언트를 별도의 VPN 대상으로 등록합니다.
diff --git a/app/build.gradle b/app/build.gradle
index 9a77e2d..0d71448 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -154,8 +154,8 @@ android {
applicationId = 'dev.gf2log'
minSdk = 26
targetSdk = 36
- versionCode = 20500
- versionName = '2.5.0'
+ versionCode = 20600
+ versionName = '2.6.0'
testInstrumentationRunner = 'androidx.test.runner.AndroidJUnitRunner'
ndk {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index fc322b7..9368459 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -11,6 +11,11 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
= Build.VERSION_CODES.P) {
+ // Draw black under the camera cutout too, so no lit strip remains.
+ layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
+ }
+ }
+
+ // Shown on every entry, so someone who skipped the first-time explanation still learns
+ // what this black screen is and how to leave it.
+ val hint = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ addView(text(getString(R.string.standby_running), TextRole.TITLE) {
+ gravity = Gravity.CENTER
+ setTextColor(getColor(R.color.standby_hint))
+ })
+ addView(text(getString(R.string.standby_touch_to_leave), TextRole.SECONDARY) {
+ gravity = Gravity.CENTER
+ setTextColor(getColor(R.color.standby_hint))
+ setPadding(0, dp(6), 0, 0)
+ })
+ nextRecording()?.let { next ->
+ addView(text(next, TextRole.SECONDARY) {
+ gravity = Gravity.CENTER
+ setTextColor(getColor(R.color.standby_hint))
+ setPadding(0, dp(12), 0, 0)
+ })
+ }
+ }
+ val root = FrameLayout(this).apply {
+ setBackgroundColor(Color.BLACK)
+ contentDescription = getString(R.string.standby_touch_to_leave)
+ setOnClickListener { leave() }
+ addView(hint, FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ FrameLayout.LayoutParams.WRAP_CONTENT,
+ Gravity.CENTER,
+ ).apply { marginStart = dp(32); marginEnd = dp(32) })
+ }
+ setContentView(root)
+ WindowCompat.getInsetsController(window, root).apply {
+ systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+ hide(WindowInsetsCompat.Type.systemBars())
+ }
+ // The hint is the only lit content; removing it leaves every pixel off.
+ hint.postDelayed({ hint.visibility = View.INVISIBLE }, HINT_VISIBLE_MS)
+
+ onBackPressedDispatcher.addCallback(this) { leave() }
+
+ // A last guard independent of the automation: never outlive the planned deadline.
+ StandbyAutomation.deadline?.let { deadline ->
+ val delay = deadline.toEpochMilli() + DEADLINE_MARGIN_MS - System.currentTimeMillis()
+ root.postDelayed({ finishForAutomation() }, delay.coerceAtLeast(0))
+ }
+ }
+
+ override fun onStart() {
+ super.onStart()
+ StandbyAutomation.onStandbyShown(this)
+ }
+
+ override fun onStop() {
+ super.onStop()
+ // Screen off, lock, or switching apps ends standby; a configuration change does not.
+ if (isChangingConfigurations) return
+ StandbyAutomation.onStandbyStopped(this)
+ leaving = true
+ finish()
+ }
+
+ /** Closes standby without cancelling the plan, for the automation that owns it. */
+ internal fun finishForAutomation() {
+ if (leaving) return
+ leaving = true
+ finish()
+ }
+
+ private fun leave() {
+ if (leaving) return
+ leaving = true
+ StandbyAutomation.cancel()
+ finish()
+ }
+
+ /** "Next recording: HaoPlay South Korea · 04:55", or null when nothing is planned. */
+ private fun nextRecording(): String? {
+ val next = StandbyAutomation.pendingRuns().firstOrNull() ?: return null
+ val time = Labels.format(Labels.TIME, next.startAt, ZoneId.systemDefault())
+ return getString(R.string.standby_next_recording, Labels.region(this, next.server.region), time)
+ }
+
+ private companion object {
+ /** The lowest backlight level; it is not "off", which would let the screen time out. */
+ const val MIN_BRIGHTNESS = 0f
+ const val HINT_VISIBLE_MS = 3_000L
+ const val DEADLINE_MARGIN_MS = 60_000L
+ }
+}
diff --git a/app/src/main/java/dev/gf2log/app/StandbyFlow.kt b/app/src/main/java/dev/gf2log/app/StandbyFlow.kt
new file mode 100644
index 0000000..8a49a03
--- /dev/null
+++ b/app/src/main/java/dev/gf2log/app/StandbyFlow.kt
@@ -0,0 +1,280 @@
+package dev.gf2log.app
+
+import android.app.AlertDialog
+import android.content.Intent
+import android.net.VpnService
+import android.provider.Settings
+import android.view.View
+import android.widget.CheckBox
+import android.widget.LinearLayout
+import android.widget.ScrollView
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.content.edit
+import dev.gf2log.app.management.PlatoonProfileRegistry
+import dev.gf2log.app.settings.GameTimeZonePreferences
+import dev.gf2log.app.standby.StandbyAutomation
+import dev.gf2log.app.standby.StandbyAutomationService
+import dev.gf2log.app.standby.StandbyBatteryPrompt
+import dev.gf2log.app.standby.StandbyOutcome
+import dev.gf2log.app.standby.StandbyPlanner
+import dev.gf2log.app.standby.StandbyProfile
+import dev.gf2log.app.standby.StandbyResults
+import dev.gf2log.app.standby.StandbyServer
+import dev.gf2log.app.ui.BackgroundWork
+import dev.gf2log.app.ui.Dialogs
+import dev.gf2log.app.ui.Feedback
+import dev.gf2log.app.ui.Labels
+import dev.gf2log.app.ui.TextRole
+import dev.gf2log.app.ui.checkBox
+import dev.gf2log.app.ui.column
+import dev.gf2log.app.ui.dp
+import dev.gf2log.app.ui.lp
+import dev.gf2log.app.ui.text
+import java.time.Instant
+import java.time.ZoneId
+
+/**
+ * Home's standby entry: the one-time battery prompt, the automatic-recording prerequisites, and
+ * the server choice, ending with the armed plan and the standby screen.
+ */
+internal class StandbyFlow(
+ private val activity: BaseActivity,
+ private val work: BackgroundWork,
+) {
+ private var pendingServers: List = emptyList()
+ private var pendingPackages: Map = emptyMap()
+ private var resultsDialog: AlertDialog? = null
+
+ /** Standby opens once the system battery-optimization dialog closes, whatever was chosen. */
+ private val batteryExemption = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ prepareRecording()
+ }
+
+ private val vpnConsent = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ if (result.resultCode == android.app.Activity.RESULT_OK) {
+ chooseServers(pendingServers, pendingPackages)
+ } else {
+ Feedback.show(activity, R.string.standby_vpn_needed, long = true)
+ openStandby()
+ }
+ }
+
+ fun start() {
+ if (!StandbyBatteryPrompt.shouldAsk(activity)) return prepareRecording()
+ Dialogs.show(
+ Dialogs.builder(activity)
+ .setTitle(R.string.standby_battery_title)
+ .setMessage(R.string.standby_battery_message)
+ .setNegativeButton(R.string.standby_battery_later) { _, _ ->
+ StandbyBatteryPrompt.markAsked(activity)
+ prepareRecording()
+ }
+ .setPositiveButton(R.string.standby_battery_allow) { _, _ ->
+ StandbyBatteryPrompt.markAsked(activity)
+ batteryExemption.launch(StandbyBatteryPrompt.requestIntent(activity))
+ },
+ )
+ }
+
+ /**
+ * Shows the last standby session's results when Home opens; they count as seen only once the
+ * user closes them, so a Home briefly resumed during automation cannot use them up.
+ */
+ fun showUnseenResults() {
+ if (StandbyAutomation.isArmed || resultsDialog?.isShowing == true) return
+ val results = StandbyResults.unseen(activity)
+ if (results.isEmpty()) return
+ val zone = ZoneId.systemDefault()
+ val lines = results.joinToString("\n") { result ->
+ activity.getString(
+ R.string.standby_result_line,
+ Labels.region(activity, result.region),
+ activity.getString(outcomeLabel(result.outcome)),
+ Labels.format(Labels.TIME, Instant.ofEpochMilli(result.atMillis), zone),
+ )
+ }
+ resultsDialog = Dialogs.message(activity, activity.getString(R.string.standby_results_title), lines).apply {
+ setOnDismissListener {
+ StandbyResults.markSeen(activity, results)
+ resultsDialog = null
+ }
+ }
+ }
+
+ private fun prepareRecording() {
+ work.run(task = ::loadProfiles) { result ->
+ val profiles = result.getOrDefault(emptyList())
+ if (profiles.isEmpty()) {
+ Feedback.show(activity, R.string.standby_no_platoons, long = true)
+ return@run openStandby()
+ }
+ val servers = StandbyPlanner.servers(profiles)
+ val packages = profiles.associate { it.storageId to it.packageName }
+ if (!StandbyAutomationService.isEnabled(activity)) return@run askForAccessibility()
+ val consent = VpnService.prepare(activity)
+ if (consent != null) {
+ pendingServers = servers
+ pendingPackages = packages
+ return@run vpnConsent.launch(consent)
+ }
+ chooseServers(servers, packages)
+ }
+ }
+
+ private fun loadProfiles(): List =
+ PlatoonProfileRegistry(activity).list().map { profile ->
+ StandbyProfile(
+ storageId = profile.storageId,
+ packageName = profile.client.packageName,
+ region = profile.serverRegion,
+ zone = GameTimeZonePreferences.get(activity, profile.storageId),
+ platoonName = profile.platoonName,
+ )
+ }
+
+ private fun askForAccessibility() {
+ Dialogs.show(
+ Dialogs.builder(activity)
+ .setTitle(R.string.standby_accessibility_title)
+ .setMessage(R.string.standby_accessibility_message)
+ .setNegativeButton(R.string.standby_only) { _, _ -> openStandby() }
+ .setPositiveButton(R.string.standby_accessibility_open) { _, _ ->
+ runCatching { activity.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) }
+ },
+ )
+ }
+
+ /** One server is armed directly; several are offered every time, one per client at most. */
+ private fun chooseServers(servers: List, packages: Map) {
+ if (servers.size == 1) return arm(servers, packages)
+ val remembered = rememberedKeys()
+ val boxes = linkedMapOf()
+ lateinit var dialog: AlertDialog
+ val now = Instant.now()
+ val zone = ZoneId.systemDefault()
+
+ fun label(server: StandbyServer, start: Instant): String = activity.getString(
+ R.string.standby_server_option,
+ Labels.regionShort(activity, server.region),
+ server.platoonNames.joinToString(", "),
+ Labels.format(Labels.TIME, start, zone),
+ )
+
+ // Servers sharing a reset start one after another, so each label shows the start the
+ // current selection would really get.
+ fun refreshStart() {
+ val checked = boxes.filterValues(CheckBox::isChecked).keys
+ val valid = StandbyPlanner.isValidSelection(checked)
+ val planned = if (valid) StandbyPlanner.plan(checked, now).associate { it.server to it.startAt } else emptyMap()
+ boxes.forEach { (server, box) ->
+ box.text = label(server, planned[server] ?: StandbyPlanner.nextReset(server.zone, now).minus(StandbyPlanner.LEAD))
+ }
+ dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = valid
+ }
+
+ val body = activity.column {
+ setPadding(activity.dp(24), activity.dp(8), activity.dp(24), activity.dp(8))
+ clipToPadding = false
+ addView(activity.text(activity.getString(R.string.standby_servers_message), TextRole.SECONDARY), activity.lp(bottom = 8))
+ val byClient = servers.groupBy(StandbyServer::packageName)
+ byClient.values.forEachIndexed { clientIndex, clientServers ->
+ if (clientIndex > 0) addView(fullWidthDivider())
+ val client = dev.gf2log.app.management.PlatoonClient.fromPackage(clientServers.first().packageName)
+ addView(activity.text(client?.displayName ?: clientServers.first().packageName, TextRole.LABEL), activity.lp(top = 4))
+ // Default to the remembered server of this client, if it still exists.
+ val preferred = clientServers.firstOrNull { it.key in remembered }
+ clientServers.forEach { server ->
+ val start = StandbyPlanner.nextReset(server.zone, now).minus(StandbyPlanner.LEAD)
+ val box = activity.checkBox(label(server, start), server == preferred) { checked ->
+ // A client logs in to one server at a time: checking one clears its siblings.
+ if (checked) {
+ clientServers.filter { it != server }.forEach { boxes[it]?.isChecked = false }
+ }
+ refreshStart()
+ }
+ boxes[server] = box
+ addView(box, activity.lp())
+ }
+ }
+ }
+ dialog = Dialogs.show(
+ Dialogs.builder(activity)
+ .setTitle(R.string.standby_servers_title)
+ .setView(ScrollView(activity).apply { addView(body) })
+ .setNegativeButton(R.string.cancel, null)
+ .setPositiveButton(R.string.standby_start) { _, _ ->
+ val chosen = boxes.filterValues(CheckBox::isChecked).keys.toList()
+ if (StandbyPlanner.isValidSelection(chosen)) arm(chosen, packages)
+ },
+ ) { refreshStart() }
+ }
+
+ private fun arm(chosen: List, packages: Map) {
+ remember(chosen)
+ val rehearsal = rehearsal()
+ val now = Instant.now()
+ val plan = rehearsal?.let { StandbyPlanner.rehearsal(chosen, now, java.time.Duration.ofMinutes(it.minutes)) }
+ ?: StandbyPlanner.plan(chosen, now)
+ StandbyAutomation.arm(activity, plan, packages, lockWhenDone = rehearsal?.lock ?: true)
+ openStandby()
+ }
+
+ private class Rehearsal(val minutes: Long, val lock: Boolean)
+
+ /**
+ * A debug build with `files/standby_rehearsal` holding a number of minutes moves the plan so
+ * the first reset falls that far from now, for testing on a device; a second word `unlocked`
+ * leaves the phone unlocked at the end.
+ */
+ private fun rehearsal(): Rehearsal? {
+ val debuggable = activity.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE != 0
+ if (!debuggable) return null
+ val words = runCatching { java.io.File(activity.filesDir, REHEARSAL_FILE).readText().trim().split(Regex("\\s+")) }
+ .getOrNull() ?: return null
+ val minutes = words.first().toLongOrNull() ?: return null
+ return Rehearsal(minutes, lock = "unlocked" !in words)
+ }
+
+ private fun openStandby() {
+ activity.startActivity(Intent(activity, StandbyActivity::class.java))
+ }
+
+ /** A divider spanning the whole dialog width, through the content's side padding. */
+ private fun fullWidthDivider(): View = View(activity).apply {
+ setBackgroundColor(activity.getColor(R.color.outline_strong))
+ importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
+ layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, maxOf(1, activity.dp(1))).apply {
+ marginStart = -activity.dp(24)
+ marginEnd = -activity.dp(24)
+ topMargin = activity.dp(12)
+ bottomMargin = activity.dp(8)
+ }
+ }
+
+ private fun rememberedKeys(): Set =
+ prefs().getStringSet(KEY_SELECTED, emptySet()).orEmpty()
+
+ private fun remember(chosen: List) {
+ prefs().edit { putStringSet(KEY_SELECTED, chosen.mapTo(HashSet(), StandbyServer::key)) }
+ }
+
+ private fun prefs() = activity.applicationContext.getSharedPreferences(PREFS, android.content.Context.MODE_PRIVATE)
+
+ private fun outcomeLabel(outcome: StandbyOutcome): Int = when (outcome) {
+ StandbyOutcome.RECORDED -> R.string.standby_outcome_recorded
+ StandbyOutcome.OTHER_PLATOON -> R.string.standby_outcome_other_platoon
+ StandbyOutcome.TIMED_OUT -> R.string.standby_outcome_timed_out
+ StandbyOutcome.MISSED -> R.string.standby_outcome_missed
+ StandbyOutcome.NO_ACCESSIBILITY -> R.string.standby_outcome_no_accessibility
+ StandbyOutcome.NO_VPN_CONSENT -> R.string.standby_outcome_no_vpn
+ StandbyOutcome.NOT_INSTALLED -> R.string.standby_outcome_not_installed
+ StandbyOutcome.CAPTURE_FAILED -> R.string.standby_outcome_capture_failed
+ StandbyOutcome.CANCELLED -> R.string.standby_outcome_cancelled
+ }
+
+ private companion object {
+ const val PREFS = "standby_mode"
+ const val KEY_SELECTED = "selected_servers"
+ const val REHEARSAL_FILE = "standby_rehearsal"
+ }
+}
diff --git a/app/src/main/java/dev/gf2log/app/capture/CaptureFlowEngine.kt b/app/src/main/java/dev/gf2log/app/capture/CaptureFlowEngine.kt
index b1716b7..70bf453 100644
--- a/app/src/main/java/dev/gf2log/app/capture/CaptureFlowEngine.kt
+++ b/app/src/main/java/dev/gf2log/app/capture/CaptureFlowEngine.kt
@@ -102,7 +102,10 @@ internal class CaptureFlowEngine(
PlatoonCaptureSession(
context = context,
profile = profile,
- onRosterCaptured = { storageId -> tracker?.rosterCaptured(storageId) },
+ onRosterCaptured = { storageId ->
+ tracker?.rosterCaptured(storageId)
+ RosterEvents.persisted(storageId)
+ },
).also { sessions[flowId] = it }
override fun unbind(flowId: Long, session: PlatoonCaptureSession) {
diff --git a/app/src/main/java/dev/gf2log/app/capture/RosterEvents.kt b/app/src/main/java/dev/gf2log/app/capture/RosterEvents.kt
new file mode 100644
index 0000000..e9eaa1b
--- /dev/null
+++ b/app/src/main/java/dev/gf2log/app/capture/RosterEvents.kt
@@ -0,0 +1,25 @@
+package dev.gf2log.app.capture
+
+import java.util.concurrent.CopyOnWriteArraySet
+
+/**
+ * Tells listeners, on the main thread, which saved Platoon just had a captured roster persisted.
+ * Unlike [CaptureStatus] notices these are never coalesced, so every persisted roster is seen.
+ */
+internal object RosterEvents {
+ private val listeners = CopyOnWriteArraySet<(String) -> Unit>()
+
+ fun addListener(listener: (storageId: String) -> Unit) {
+ listeners += listener
+ }
+
+ fun removeListener(listener: (storageId: String) -> Unit) {
+ listeners -= listener
+ }
+
+ /** Called from the capture worker once a roster for [storageId] is stored or recovered. */
+ fun persisted(storageId: String) {
+ if (listeners.isEmpty()) return
+ MainThread.post { listeners.forEach { it(storageId) } }
+ }
+}
diff --git a/app/src/main/java/dev/gf2log/app/standby/StandbyAutomation.kt b/app/src/main/java/dev/gf2log/app/standby/StandbyAutomation.kt
new file mode 100644
index 0000000..956f06b
--- /dev/null
+++ b/app/src/main/java/dev/gf2log/app/standby/StandbyAutomation.kt
@@ -0,0 +1,437 @@
+package dev.gf2log.app.standby
+
+import android.app.ActivityManager
+import android.app.KeyguardManager
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.net.VpnService
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import dev.gf2log.app.StandbyActivity
+import dev.gf2log.app.capture.CaptureNotice
+import dev.gf2log.app.capture.CapturePhase
+import dev.gf2log.app.capture.CaptureSnapshot
+import dev.gf2log.app.capture.CaptureStatus
+import dev.gf2log.app.capture.CaptureVpnService
+import dev.gf2log.app.capture.RosterEvents
+import dev.gf2log.app.management.PlatoonProfileRegistry
+import java.lang.ref.WeakReference
+import java.time.Instant
+import java.util.concurrent.Executors
+
+/**
+ * Runs standby mode's planned recordings, one server at a time, on the main thread.
+ *
+ * Every path ends in [finish] or [cancel], and both always release the screen. [finish] locks the
+ * phone; if locking fails, standby closes and the phone's own screen timeout locks it. A watchdog
+ * deadline, checked every second, forces [finish] whatever state a run is in, and every screen-on
+ * hold also expires at that deadline on its own.
+ */
+internal object StandbyAutomation {
+
+ private enum class Phase { IDLE, WAITING, STARTING_CAPTURE, IN_GAME, RETURNING }
+
+ private val handler = Handler(Looper.getMainLooper())
+ private val worker = Executors.newSingleThreadExecutor { Thread(it, "GF2Standby") }
+ private val ticker = object : Runnable {
+ override fun run() {
+ runCatching(::tick).onFailure { error ->
+ Log.e(TAG, "Standby automation failed; finishing", error)
+ finish()
+ }
+ if (phase != Phase.IDLE) handler.postDelayed(this, TICK_MS)
+ }
+ }
+
+ private lateinit var appContext: Context
+ private var phase = Phase.IDLE
+ private var runs: List = emptyList()
+ private var index = 0
+ private var deadlineMillis = 0L
+ private var profilePackages: Map = emptyMap()
+ private val results = mutableListOf()
+
+ private var phaseStartedAt = 0L
+ private var runTimeoutAt = 0L
+ private var nextTapAt = 0L
+ private var tapsUntil = 0L
+ private var loggedIn = false
+ private var foreignSince = 0L
+ private var startedCapture = false
+ private var previousActive: String? = null
+ private var handingOff = false
+ private var lockWhenDone = true
+ private var openedGame: String? = null
+ private var standby = WeakReference(null)
+
+ private val rosterListener: (String) -> Unit = ::onRosterPersisted
+ private val captureListener: (CaptureSnapshot) -> Unit = ::onCaptureChanged
+ private val screenOffReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action == Intent.ACTION_SCREEN_OFF) cancel()
+ }
+ }
+
+ val isArmed: Boolean get() = phase != Phase.IDLE
+
+ /** The deadline standby must end by, or null when nothing is planned. */
+ val deadline: Instant? get() = if (isArmed) Instant.ofEpochMilli(deadlineMillis) else null
+
+ /** The planned runs that have not ended yet, for the standby hint. */
+ fun pendingRuns(): List = if (isArmed) runs.drop(index) else emptyList()
+
+ /**
+ * Arms the plan. [profilePackages] maps every saved Platoon to its client package, so a roster
+ * of another server's Platoon is recognised as a wrong login. [lockWhenDone] is false only for
+ * debug rehearsals, which leave the phone unlocked.
+ */
+ fun arm(context: Context, plan: List, profilePackages: Map, lockWhenDone: Boolean = true) {
+ if (isArmed) cancel()
+ if (plan.isEmpty()) return
+ appContext = context.applicationContext
+ runs = plan
+ index = 0
+ deadlineMillis = requireNotNull(StandbyPlanner.finalDeadline(plan)).toEpochMilli()
+ this.profilePackages = profilePackages
+ results.clear()
+ startedCapture = false
+ previousActive = null
+ handingOff = false
+ openedGame = null
+ this.lockWhenDone = lockWhenDone
+ enter(Phase.WAITING)
+ appContext.registerReceiver(screenOffReceiver, IntentFilter(Intent.ACTION_SCREEN_OFF))
+ RosterEvents.addListener(rosterListener)
+ CaptureStatus.addListener(captureListener)
+ handler.removeCallbacks(ticker)
+ handler.post(ticker)
+ }
+
+ /** A standby screen became visible; between runs it takes over keeping the screen on. */
+ fun onStandbyShown(activity: StandbyActivity) {
+ standby = WeakReference(activity)
+ if (phase == Phase.WAITING || phase == Phase.RETURNING) {
+ StandbyAutomationService.instance?.releaseScreen()
+ if (phase == Phase.RETURNING) enter(Phase.WAITING)
+ }
+ }
+
+ /**
+ * Called when the standby screen stops. Returns true when automation itself replaced it with
+ * the game; otherwise the user left standby, which cancels everything still planned.
+ */
+ fun onStandbyStopped(activity: StandbyActivity): Boolean {
+ if (standby.get() === activity) standby.clear()
+ if (handingOff) {
+ handingOff = false
+ return true
+ }
+ cancel()
+ return false
+ }
+
+ private fun tick() {
+ val now = System.currentTimeMillis()
+ if (now >= deadlineMillis) {
+ finish()
+ return
+ }
+ when (phase) {
+ Phase.IDLE -> Unit
+ Phase.WAITING -> if (now >= runs[index].startAt.toEpochMilli()) beginRun(now)
+ Phase.STARTING_CAPTURE -> when {
+ CaptureStatus.snapshot().phase == CapturePhase.RUNNING -> launchGame(now)
+ now - phaseStartedAt >= CAPTURE_START_TIMEOUT_MS -> endRun(StandbyOutcome.CAPTURE_FAILED)
+ }
+ Phase.IN_GAME -> when {
+ // Someone switched to another app or Home: they are awake, so stand down
+ // without closing anything or locking the phone in their hand.
+ userLeftGame(now) -> cancel()
+ CaptureStatus.snapshot().phase == CapturePhase.STOPPED -> endRun(StandbyOutcome.CAPTURE_FAILED)
+ now >= runTimeoutAt -> endRun(StandbyOutcome.TIMED_OUT)
+ // Taps only reach the title screen: they stop at login, and in any case after the
+ // window, so nothing inside the game is ever pressed.
+ now >= nextTapAt && now < tapsUntil && !loggedIn -> {
+ nextTapAt = now + TAP_INTERVAL_MS
+ val (x, y) = startButton(runs[index].server.packageName)
+ StandbyAutomationService.instance?.tapIfFront(runs[index].server.packageName, x, y)
+ }
+ }
+ // The standby screen did not come back between runs: stop rather than leave the game lit.
+ Phase.RETURNING -> if (now - phaseStartedAt >= RETURN_TIMEOUT_MS) finish()
+ }
+ }
+
+ /** True once something other than the game, or this app, has been in front for a while. */
+ private fun userLeftGame(now: Long): Boolean {
+ val front = StandbyAutomationService.instance?.frontPackage()
+ if (front == null || front == runs[index].server.packageName || front == appContext.packageName) {
+ foreignSince = 0L
+ return false
+ }
+ if (foreignSince == 0L) foreignSince = now
+ // Brief system windows during launch or login do not count.
+ return now - foreignSince >= USER_SWITCH_MS
+ }
+
+ private fun beginRun(now: Long) {
+ val run = runs[index]
+ if (now >= run.deadline.toEpochMilli() - MIN_RUN_MS) return skip(StandbyOutcome.MISSED)
+ val service = StandbyAutomationService.instance
+ if (service == null) {
+ // Without the service nothing can be tapped or locked; closing standby lets the
+ // phone's own screen timeout lock it.
+ record(StandbyOutcome.NO_ACCESSIBILITY)
+ index++
+ return finish()
+ }
+ if (isLocked()) return cancel()
+ if (VpnService.prepare(appContext) != null) return skip(StandbyOutcome.NO_VPN_CONSENT)
+ if (appContext.packageManager.getLaunchIntentForPackage(run.server.packageName) == null) {
+ return skip(StandbyOutcome.NOT_INSTALLED)
+ }
+ service.holdScreenOn(minOf(run.deadline.toEpochMilli(), deadlineMillis))
+ enter(Phase.STARTING_CAPTURE)
+ val target = run.server.storageIds.first()
+ worker.execute {
+ // Selecting the planned Platoon makes its client attribute captures to that server.
+ val registry = PlatoonProfileRegistry(appContext)
+ val previous = runCatching { registry.active()?.storageId }.getOrNull()
+ val selected = runCatching { registry.setActive(target) }.getOrDefault(false)
+ handler.post {
+ if (phase != Phase.STARTING_CAPTURE || runs.getOrNull(index) !== run) {
+ // The run ended meanwhile, so nothing else will put the user's Platoon back.
+ if (selected && previous != null && previous != target) {
+ worker.execute { runCatching { PlatoonProfileRegistry(appContext).setActive(previous) } }
+ }
+ return@post
+ }
+ previousActive = previous.takeIf { selected && it != target }
+ startCapture()
+ }
+ }
+ }
+
+ private fun startCapture() {
+ if (CaptureStatus.snapshot().phase != CapturePhase.STOPPED) return
+ val started = runCatching {
+ appContext.startForegroundService(
+ Intent(appContext, CaptureVpnService::class.java)
+ .setAction(CaptureVpnService.ACTION_START)
+ .putExtra(CaptureVpnService.EXTRA_CAPTURE_ONCE, false),
+ )
+ }
+ if (started.isSuccess) {
+ startedCapture = true
+ } else {
+ Log.w(TAG, "Capture could not start", started.exceptionOrNull())
+ endRun(StandbyOutcome.CAPTURE_FAILED)
+ }
+ }
+
+ private fun launchGame(now: Long) {
+ val run = runs[index]
+ val launch = appContext.packageManager.getLaunchIntentForPackage(run.server.packageName)
+ ?: return endRun(StandbyOutcome.NOT_INSTALLED)
+ // A game left running from earlier would skip its login, and the roster comes with login.
+ killInBackground(run.server.packageName)
+ val opener = standby.get()?.takeUnless { it.isFinishing } ?: StandbyAutomationService.instance
+ val launched = runCatching {
+ (opener ?: appContext).startActivity(launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
+ }.isSuccess
+ if (!launched) return endRun(StandbyOutcome.CAPTURE_FAILED)
+ openedGame = run.server.packageName
+ standby.get()?.let { screen ->
+ handingOff = true
+ screen.finishForAutomation()
+ }
+ runTimeoutAt = minOf(now + RUN_TIMEOUT_MS, run.deadline.toEpochMilli())
+ nextTapAt = now + FIRST_TAP_DELAY_MS
+ tapsUntil = now + TAP_WINDOW_MS
+ loggedIn = false
+ foreignSince = 0L
+ enter(Phase.IN_GAME)
+ }
+
+ /** Platoon notices only follow a login, so the title screen is gone once one arrives. */
+ private fun onCaptureChanged(snapshot: CaptureSnapshot) {
+ val notice = snapshot.notice ?: return
+ if (phase == Phase.IN_GAME && notice.notice in LOGIN_NOTICES && notice.atMillis >= phaseStartedAt) {
+ loggedIn = true
+ }
+ }
+
+ private fun onRosterPersisted(storageId: String) {
+ if (phase != Phase.IN_GAME) return
+ val run = runs[index]
+ when {
+ storageId in run.server.storageIds -> endRun(StandbyOutcome.RECORDED)
+ profilePackages[storageId] == run.server.packageName -> endRun(StandbyOutcome.OTHER_PLATOON)
+ }
+ }
+
+ /** Records [outcome] for the current run, stops what it started, and moves on. */
+ private fun endRun(outcome: StandbyOutcome) {
+ record(outcome)
+ releaseRun()
+ index++
+ if (index >= runs.size) return finish()
+ // A run that failed before the game opened is still on the standby screen: just wait.
+ if (standby.get()?.takeUnless { it.isFinishing } != null) {
+ StandbyAutomationService.instance?.releaseScreen()
+ enter(Phase.WAITING)
+ return
+ }
+ // Close the game, then back to the standby screen until the next run.
+ enter(Phase.RETURNING)
+ val service = StandbyAutomationService.instance ?: return finish()
+ closeOpenedGame(service) {
+ if (phase != Phase.RETURNING) return@closeOpenedGame
+ val shown = runCatching {
+ service.startActivity(
+ Intent(appContext, StandbyActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
+ )
+ }.isSuccess
+ if (!shown) finish()
+ }
+ }
+
+ private fun skip(outcome: StandbyOutcome) {
+ record(outcome)
+ index++
+ if (index >= runs.size) finish() else enter(Phase.WAITING)
+ }
+
+ /** Ends standby with the phone locked; also used by the watchdog deadline. */
+ private fun finish() {
+ if (phase == Phase.IDLE) return
+ val games = runs.map { it.server.packageName }.distinct()
+ if (phase == Phase.STARTING_CAPTURE || phase == Phase.IN_GAME) {
+ record(StandbyOutcome.TIMED_OUT)
+ index++
+ }
+ while (index < runs.size) {
+ record(StandbyOutcome.MISSED)
+ index++
+ }
+ teardown()
+ val service = StandbyAutomationService.instance
+ if (service == null) {
+ standby.get()?.finishForAutomation()
+ standby.clear()
+ return
+ }
+ standby.get()?.finishForAutomation()
+ standby.clear()
+ closeOpenedGame(service) {
+ service.goHome()
+ handler.postDelayed({
+ games.forEach(::killInBackground)
+ val locked = lockWhenDone && service.lockScreen()
+ // Released in either case: locked, or left to the phone's own screen timeout.
+ service.releaseScreen()
+ if (lockWhenDone && !locked) Log.w(TAG, "Lock action refused; leaving the phone to its screen timeout")
+ }, CLOSE_GAME_DELAY_MS)
+ }
+ }
+
+ /** Dismisses the Recents card of the game this run opened, if any, then runs [then]. */
+ private fun closeOpenedGame(service: StandbyAutomationService, then: () -> Unit) {
+ val game = openedGame ?: return then()
+ openedGame = null
+ service.closeFromRecents(game) { closed ->
+ Log.i(TAG, "Closing $game from Recents: $closed")
+ then()
+ }
+ }
+
+ /** The user is back (left standby or turned the screen off): stop without locking. */
+ fun cancel() {
+ if (phase == Phase.IDLE) return
+ while (index < runs.size) {
+ record(StandbyOutcome.CANCELLED)
+ index++
+ }
+ teardown()
+ StandbyAutomationService.instance?.releaseScreen()
+ }
+
+ private fun teardown() {
+ releaseRun()
+ enter(Phase.IDLE)
+ handler.removeCallbacks(ticker)
+ RosterEvents.removeListener(rosterListener)
+ CaptureStatus.removeListener(captureListener)
+ runCatching { appContext.unregisterReceiver(screenOffReceiver) }
+ handingOff = false
+ StandbyResults.save(appContext, results.toList())
+ }
+
+ /** Stops the capture this run started and restores the Platoon the user had selected. */
+ private fun releaseRun() {
+ if (startedCapture) {
+ startedCapture = false
+ runCatching {
+ appContext.startService(
+ Intent(appContext, CaptureVpnService::class.java).setAction(CaptureVpnService.ACTION_STOP),
+ )
+ }
+ }
+ previousActive?.let { previous ->
+ previousActive = null
+ worker.execute { runCatching { PlatoonProfileRegistry(appContext).setActive(previous) } }
+ }
+ }
+
+ private fun record(outcome: StandbyOutcome) {
+ val run = runs.getOrNull(index) ?: return
+ Log.i(TAG, "${run.server.key}: $outcome")
+ results += StandbyResult(run.server.packageName, run.server.region, outcome, System.currentTimeMillis())
+ }
+
+ private fun enter(next: Phase) {
+ Log.i(TAG, "$phase -> $next")
+ phase = next
+ phaseStartedAt = System.currentTimeMillis()
+ }
+
+ private fun isLocked(): Boolean =
+ appContext.getSystemService(KeyguardManager::class.java)?.isKeyguardLocked == true
+
+ /** Backup only: some phones and Android 14+ refuse to stop another app this way. */
+ private fun killInBackground(packageName: String) {
+ runCatching { appContext.getSystemService(ActivityManager::class.java)?.killBackgroundProcesses(packageName) }
+ .onFailure { Log.w(TAG, "Could not close $packageName", it) }
+ }
+
+ /** Where each client's title screen "Start" sits, as fractions of the landscape screen. */
+ private fun startButton(@Suppress("UNUSED_PARAMETER") packageName: String): Pair =
+ START_X to START_Y
+
+ private const val TAG = "GF2Standby"
+ private const val TICK_MS = 1_000L
+ private const val CAPTURE_START_TIMEOUT_MS = 20_000L
+ private const val FIRST_TAP_DELAY_MS = 15_000L
+ private const val TAP_INTERVAL_MS = 4_000L
+ private const val TAP_WINDOW_MS = 2 * 60_000L
+ private const val USER_SWITCH_MS = 5_000L
+ private const val RUN_TIMEOUT_MS = 4 * 60_000L
+ private const val MIN_RUN_MS = 60_000L
+ private const val RETURN_TIMEOUT_MS = 8_000L
+ private const val CLOSE_GAME_DELAY_MS = 1_500L
+ private const val START_X = 0.5f
+ private const val START_Y = 0.85f
+ private val LOGIN_NOTICES = setOf(
+ CaptureNotice.PLATOON_DETECTED,
+ CaptureNotice.PENDING_DETECTED,
+ CaptureNotice.PLATOON_CONFIRMED,
+ CaptureNotice.IDENTITY_CHANGED,
+ CaptureNotice.PENDING_IDENTITY_CHANGED,
+ CaptureNotice.ROSTER_SAVED,
+ CaptureNotice.ROSTER_UPDATED,
+ CaptureNotice.ROSTER_RECOVERED,
+ )
+}
diff --git a/app/src/main/java/dev/gf2log/app/standby/StandbyAutomationService.kt b/app/src/main/java/dev/gf2log/app/standby/StandbyAutomationService.kt
new file mode 100644
index 0000000..efff784
--- /dev/null
+++ b/app/src/main/java/dev/gf2log/app/standby/StandbyAutomationService.kt
@@ -0,0 +1,167 @@
+package dev.gf2log.app.standby
+
+import android.accessibilityservice.AccessibilityService
+import android.accessibilityservice.GestureDescription
+import android.content.ComponentName
+import android.content.Context
+import android.graphics.Color
+import android.graphics.Path
+import android.graphics.PixelFormat
+import android.hardware.display.DisplayManager
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import android.provider.Settings
+import android.util.DisplayMetrics
+import android.view.Display
+import android.view.Gravity
+import android.view.View
+import android.view.WindowManager
+import android.view.accessibility.AccessibilityEvent
+import android.view.accessibility.AccessibilityNodeInfo
+
+/**
+ * The accessibility service standby mode uses to act for the user while they sleep: tap the
+ * game's start screen, close the game from Recents, lock the phone, and keep the screen on
+ * between steps.
+ *
+ * It reads nothing on screen except which app is in front, so taps never land outside the game,
+ * and the app names on Recents cards, to close the right one. Every screen-on hold carries its own expiry, so a failure elsewhere can never keep the phone
+ * awake past the standby deadline.
+ */
+class StandbyAutomationService : AccessibilityService() {
+
+ private val handler = Handler(Looper.getMainLooper())
+ private var overlay: View? = null
+ private var foregroundPackage: String? = null
+ private val expireHold = Runnable { releaseScreen() }
+
+ override fun onServiceConnected() {
+ current = java.lang.ref.WeakReference(this)
+ }
+
+ override fun onAccessibilityEvent(event: AccessibilityEvent) {
+ if (event.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
+ event.packageName?.toString()?.let { foregroundPackage = it }
+ }
+ }
+
+ override fun onInterrupt() = Unit
+
+ override fun onDestroy() {
+ releaseScreen()
+ if (current.get() === this) current.clear()
+ super.onDestroy()
+ }
+
+ /** The package of the window currently in front, if known. */
+ fun frontPackage(): String? =
+ runCatching { rootInActiveWindow?.packageName?.toString() }.getOrNull() ?: foregroundPackage
+
+ /**
+ * Taps at a point given as fractions of the screen, but only while [packageName] is in front.
+ * Returns false when the tap was not sent.
+ */
+ fun tapIfFront(packageName: String, xFraction: Float, yFraction: Float): Boolean {
+ if (frontPackage() != packageName) return false
+ // The real size of the default display in its current rotation, system bars included,
+ // so the fractions match a full-screen game in landscape.
+ val metrics = DisplayMetrics()
+ @Suppress("DEPRECATION")
+ getSystemService(DisplayManager::class.java).getDisplay(Display.DEFAULT_DISPLAY).getRealMetrics(metrics)
+ val path = Path().apply { moveTo(metrics.widthPixels * xFraction, metrics.heightPixels * yFraction) }
+ val gesture = GestureDescription.Builder()
+ .addStroke(GestureDescription.StrokeDescription(path, 0, TAP_DURATION_MS))
+ .build()
+ return dispatchGesture(gesture, null, null)
+ }
+
+ fun goHome(): Boolean = performGlobalAction(GLOBAL_ACTION_HOME)
+
+ /**
+ * Closes [packageName] the way a person would, by dismissing its card in Recents; Android
+ * does not let one app stop another. [done] gets whether a card was dismissed.
+ */
+ fun closeFromRecents(packageName: String, done: (Boolean) -> Unit) {
+ val label = runCatching {
+ packageManager.getApplicationLabel(packageManager.getApplicationInfo(packageName, 0)).toString()
+ }.getOrNull()
+ if (label.isNullOrBlank() || !performGlobalAction(GLOBAL_ACTION_RECENTS)) return done(false)
+ handler.postDelayed({ done(dismissCard(label)) }, RECENTS_SETTLE_MS)
+ }
+
+ private fun dismissCard(label: String): Boolean {
+ val root = runCatching { rootInActiveWindow }.getOrNull() ?: return false
+ val card = root.findAccessibilityNodeInfosByText(label)
+ .firstOrNull { it.contentDescription?.toString() == label } ?: return false
+ val launcher = runCatching { packageManager.getResourcesForApplication(card.packageName.toString()) }.getOrNull()
+ // The launcher's own "Close" action on the card, recognised by its resource name so any
+ // language works; the standard dismiss action where a launcher offers that instead.
+ val close = card.actionList.firstOrNull { action ->
+ action.id == AccessibilityNodeInfo.AccessibilityAction.ACTION_DISMISS.id ||
+ (action.label != null && launcher != null && runCatching {
+ launcher.getResourceEntryName(action.id).contains("close", ignoreCase = true)
+ }.getOrDefault(false))
+ } ?: return false
+ return card.performAction(close.id)
+ }
+
+ /** Locks the phone; false when the platform refused, so the caller can fall back to timeout. */
+ fun lockScreen(): Boolean =
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && performGlobalAction(GLOBAL_ACTION_LOCK_SCREEN)
+
+ /**
+ * Keeps the screen on, without covering anything, until [releaseScreen] or [untilMillis]
+ * (wall clock), whichever comes first.
+ */
+ fun holdScreenOn(untilMillis: Long) {
+ handler.removeCallbacks(expireHold)
+ val delay = untilMillis - System.currentTimeMillis()
+ if (delay <= 0) {
+ releaseScreen()
+ return
+ }
+ if (overlay == null) {
+ val view = View(this).apply { setBackgroundColor(Color.TRANSPARENT) }
+ val params = WindowManager.LayoutParams(
+ 1,
+ 1,
+ WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
+ WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
+ WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
+ WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON,
+ PixelFormat.TRANSLUCENT,
+ ).apply { gravity = Gravity.TOP or Gravity.START }
+ val added = runCatching { getSystemService(WindowManager::class.java).addView(view, params) }.isSuccess
+ if (added) overlay = view
+ }
+ handler.postDelayed(expireHold, delay)
+ }
+
+ fun releaseScreen() {
+ handler.removeCallbacks(expireHold)
+ overlay?.let { view -> runCatching { getSystemService(WindowManager::class.java).removeView(view) } }
+ overlay = null
+ }
+
+ companion object {
+ private const val TAP_DURATION_MS = 60L
+ private const val RECENTS_SETTLE_MS = 1_200L
+
+ @Volatile
+ private var current = java.lang.ref.WeakReference(null)
+
+ /** The connected service, or null while the user has it turned off. */
+ val instance: StandbyAutomationService? get() = current.get()
+
+ /** Whether the user has turned this service on in the system accessibility settings. */
+ fun isEnabled(context: Context): Boolean {
+ val component = ComponentName(context, StandbyAutomationService::class.java)
+ val enabled = Settings.Secure.getString(
+ context.contentResolver,
+ Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES,
+ ) ?: return false
+ return enabled.split(':').any { ComponentName.unflattenFromString(it) == component }
+ }
+ }
+}
diff --git a/app/src/main/java/dev/gf2log/app/standby/StandbyBatteryPrompt.kt b/app/src/main/java/dev/gf2log/app/standby/StandbyBatteryPrompt.kt
new file mode 100644
index 0000000..81258b7
--- /dev/null
+++ b/app/src/main/java/dev/gf2log/app/standby/StandbyBatteryPrompt.kt
@@ -0,0 +1,38 @@
+package dev.gf2log.app.standby
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.Intent
+import android.os.PowerManager
+import android.provider.Settings
+import androidx.core.content.edit
+import androidx.core.net.toUri
+
+/**
+ * The one-time offer, made the first time standby mode is entered, to exclude the app from battery
+ * optimization so the system does not stop it during an overnight standby. It is asked only once;
+ * after that the user changes it in the app's battery settings.
+ */
+internal object StandbyBatteryPrompt {
+
+ fun shouldAsk(context: Context): Boolean =
+ !isExempt(context) && !prefs(context).getBoolean(KEY_ASKED, false)
+
+ fun markAsked(context: Context) {
+ prefs(context).edit { putBoolean(KEY_ASKED, true) }
+ }
+
+ fun isExempt(context: Context): Boolean =
+ context.getSystemService(PowerManager::class.java)?.isIgnoringBatteryOptimizations(context.packageName) == true
+
+ /** The system's own allow/deny dialog for this app; distributed outside Play, so the policy limit does not apply. */
+ @SuppressLint("BatteryLife")
+ fun requestIntent(context: Context): Intent =
+ Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, "package:${context.packageName}".toUri())
+
+ private fun prefs(context: Context) =
+ context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+
+ private const val PREFS = "standby_mode"
+ private const val KEY_ASKED = "battery_optimization_asked"
+}
diff --git a/app/src/main/java/dev/gf2log/app/standby/StandbyPlan.kt b/app/src/main/java/dev/gf2log/app/standby/StandbyPlan.kt
new file mode 100644
index 0000000..c105839
--- /dev/null
+++ b/app/src/main/java/dev/gf2log/app/standby/StandbyPlan.kt
@@ -0,0 +1,121 @@
+package dev.gf2log.app.standby
+
+import dev.gf2log.app.settings.GameServerRegion
+import java.time.Duration
+import java.time.Instant
+import java.time.ZoneId
+import java.time.ZonedDateTime
+
+/**
+ * One game server that standby mode can record: a client package logged in to one server region,
+ * and every saved Platoon on it. Its day ends at 05:00 in [zone].
+ */
+internal data class StandbyServer(
+ val packageName: String,
+ val region: GameServerRegion,
+ val zone: ZoneId,
+ val storageIds: Set,
+ val platoonNames: List,
+) {
+ /** Stable across launches, so the last checkbox choice can be restored. */
+ val key: String get() = "$packageName|${region.storedValue}|${zone.id}"
+}
+
+/** A saved Platoon as standby planning sees it. */
+internal data class StandbyProfile(
+ val storageId: String,
+ val packageName: String,
+ val region: GameServerRegion,
+ val zone: ZoneId,
+ val platoonName: String,
+)
+
+/** One unattended recording: start before [reset], finish by [deadline]. */
+internal data class StandbyRun(
+ val server: StandbyServer,
+ val reset: Instant,
+ val startAt: Instant,
+) {
+ val deadline: Instant get() = reset.plus(StandbyPlanner.GRACE)
+}
+
+/** Pure planning rules for standby mode's automatic recordings; no Android dependencies. */
+internal object StandbyPlanner {
+ /** How long before a server's day ends its recording starts. */
+ val LEAD: Duration = Duration.ofMinutes(5)
+
+ /** How long after the reset a late recording may still finish before it is abandoned. */
+ val GRACE: Duration = Duration.ofMinutes(5)
+
+ /** A reset closer than this is skipped in favour of the next day's, so a run is never rushed. */
+ val MIN_NOTICE: Duration = Duration.ofMinutes(1)
+
+ private val RESET_HOUR = GameServerRegion.RESET_TIME
+
+ /**
+ * Groups saved Platoons by the server they log in to. Platoons of one client and region share
+ * a login, so they are recorded together.
+ */
+ fun servers(profiles: List): List = profiles
+ .groupBy { Triple(it.packageName, it.region, it.zone) }
+ .map { (key, members) ->
+ StandbyServer(
+ packageName = key.first,
+ region = key.second,
+ zone = key.third,
+ storageIds = members.mapTo(linkedSetOf(), StandbyProfile::storageId),
+ platoonNames = members.map(StandbyProfile::platoonName).distinct(),
+ )
+ }
+ .sortedWith(compareBy { it.packageName }.thenBy { it.region.ordinal }.thenBy { it.zone.id })
+
+ /**
+ * A client logs in to one server at a time and standby cannot switch servers, so at most one
+ * server per client package can be chosen.
+ */
+ fun isValidSelection(selected: Collection): Boolean =
+ selected.isNotEmpty() && selected.groupBy(StandbyServer::packageName).values.all { it.size == 1 }
+
+ /** The next 05:00 in [zone] that is at least [MIN_NOTICE] after [now]. */
+ fun nextReset(zone: ZoneId, now: Instant): Instant {
+ val local = now.atZone(zone)
+ var reset = ZonedDateTime.of(local.toLocalDate(), RESET_HOUR, zone)
+ while (!reset.toInstant().isAfter(now.plus(MIN_NOTICE))) reset = reset.plusDays(1)
+ return reset.toInstant()
+ }
+
+ /**
+ * Orders the chosen servers by reset time. Servers sharing one reset instant run one after
+ * another, so the earlier ones start [LEAD] earlier per server still to follow; a start already
+ * in the past begins immediately.
+ */
+ fun plan(
+ selected: Collection,
+ now: Instant,
+ resetOf: (StandbyServer) -> Instant = { nextReset(it.zone, now) },
+ ): List {
+ require(isValidSelection(selected)) { "One server per client must be selected" }
+ return selected
+ .map { it to resetOf(it) }
+ .groupBy({ it.second }, { it.first })
+ .toSortedMap()
+ .flatMap { (reset, servers) ->
+ val ordered = servers.sortedBy(StandbyServer::packageName)
+ ordered.mapIndexed { index, server ->
+ val lead = LEAD.multipliedBy((ordered.size - index).toLong())
+ val start = reset.minus(lead)
+ StandbyRun(server, reset, if (start.isBefore(now)) now else start)
+ }
+ }
+ }
+
+ /**
+ * A plan in which every chosen server resets [resetIn] from [now], so the runs follow one
+ * another [LEAD] apart. Debug builds use it to rehearse a night on a device.
+ */
+ fun rehearsal(selected: Collection, now: Instant, resetIn: Duration): List =
+ plan(selected, now) { now.plus(resetIn) }
+
+ /** Standby must end, with the phone locked, by this instant whatever happened before. */
+ fun finalDeadline(runs: List): Instant? = runs.maxOfOrNull(StandbyRun::deadline)
+}
diff --git a/app/src/main/java/dev/gf2log/app/standby/StandbyResults.kt b/app/src/main/java/dev/gf2log/app/standby/StandbyResults.kt
new file mode 100644
index 0000000..025fb8d
--- /dev/null
+++ b/app/src/main/java/dev/gf2log/app/standby/StandbyResults.kt
@@ -0,0 +1,96 @@
+package dev.gf2log.app.standby
+
+import android.content.Context
+import androidx.core.content.edit
+import dev.gf2log.app.settings.GameServerRegion
+import org.json.JSONArray
+import org.json.JSONObject
+
+/** How one planned recording ended. */
+internal enum class StandbyOutcome {
+ /** A roster of a Platoon on the planned server was saved. */
+ RECORDED,
+
+ /** The client logged in to a different saved Platoon than the planned server's. */
+ OTHER_PLATOON,
+
+ /** No roster arrived before the run's time ran out. */
+ TIMED_OUT,
+
+ /** The run never started because an earlier one or the deadline left no time. */
+ MISSED,
+
+ /** The accessibility service was off, so the game could not be operated. */
+ NO_ACCESSIBILITY,
+
+ /** VPN consent was missing or revoked, so capture could not start. */
+ NO_VPN_CONSENT,
+
+ /** The game client is not installed. */
+ NOT_INSTALLED,
+
+ /** The capture service failed to start or stopped on its own. */
+ CAPTURE_FAILED,
+
+ /** The user left standby or turned the screen off first. */
+ CANCELLED,
+}
+
+internal data class StandbyResult(
+ val packageName: String,
+ val region: GameServerRegion,
+ val outcome: StandbyOutcome,
+ val atMillis: Long,
+)
+
+/** The last standby session's results, kept until the user closes them on Home. */
+internal object StandbyResults {
+
+ fun save(context: Context, results: List) {
+ if (results.isEmpty()) return
+ val json = JSONArray().apply {
+ results.forEach { result ->
+ put(
+ JSONObject()
+ .put(PACKAGE, result.packageName)
+ .put(REGION, result.region.storedValue)
+ .put(OUTCOME, result.outcome.name)
+ .put(AT, result.atMillis),
+ )
+ }
+ }
+ prefs(context).edit(commit = true) { putString(KEY_UNSEEN, json.toString()) }
+ }
+
+ /** The results not yet acknowledged on Home. */
+ fun unseen(context: Context): List {
+ val raw = prefs(context).getString(KEY_UNSEEN, null) ?: return emptyList()
+ return runCatching {
+ val array = JSONArray(raw)
+ (0 until array.length()).map { index ->
+ val item = array.getJSONObject(index)
+ StandbyResult(
+ packageName = item.getString(PACKAGE),
+ region = GameServerRegion.fromStored(item.getString(REGION)),
+ outcome = StandbyOutcome.valueOf(item.getString(OUTCOME)),
+ atMillis = item.getLong(AT),
+ )
+ }
+ }.getOrDefault(emptyList())
+ }
+
+ /** Clears [shown] once the user has closed them, unless a newer night replaced them. */
+ fun markSeen(context: Context, shown: List) {
+ if (unseen(context) == shown) prefs(context).edit { remove(KEY_UNSEEN) }
+ }
+
+ private fun prefs(context: Context) =
+ context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+
+ private const val PREFS = "standby_mode"
+ private const val KEY_UNSEEN = "unseen_results"
+ private const val PACKAGE = "package"
+ private const val REGION = "region"
+ private const val OUTCOME = "outcome"
+ private const val AT = "at"
+}
diff --git a/app/src/main/java/dev/gf2log/app/ui/Dialogs.kt b/app/src/main/java/dev/gf2log/app/ui/Dialogs.kt
index 138a854..5426197 100644
--- a/app/src/main/java/dev/gf2log/app/ui/Dialogs.kt
+++ b/app/src/main/java/dev/gf2log/app/ui/Dialogs.kt
@@ -11,9 +11,17 @@ import dev.gf2log.app.R
/** App-owned dialogs: one surface, one accent, and an explicit red treatment for destruction. */
internal object Dialogs {
+ private const val DISABLED_ALPHA = 97 // 38%, the platform's disabled-content opacity
+
fun builder(context: Context): AlertDialog.Builder = AlertDialog.Builder(context)
/** Shows the dialog and applies the shared button palette once its buttons exist. */
+ /** [color], dimmed while the button is disabled so an unavailable action looks unavailable. */
+ private fun buttonColors(color: Int) = android.content.res.ColorStateList(
+ arrayOf(intArrayOf(-android.R.attr.state_enabled), intArrayOf()),
+ intArrayOf(androidx.core.graphics.ColorUtils.setAlphaComponent(color, DISABLED_ALPHA), color),
+ )
+
fun show(
builder: AlertDialog.Builder,
destructive: Boolean = false,
@@ -23,12 +31,12 @@ internal object Dialogs {
dialog.setOnShowListener {
val context = dialog.context
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.apply {
- setTextColor(context.getColor(if (destructive) R.color.destructive_action else R.color.accent_text))
+ setTextColor(buttonColors(context.getColor(if (destructive) R.color.destructive_action else R.color.accent_text)))
isAllCaps = false
}
listOf(AlertDialog.BUTTON_NEGATIVE, AlertDialog.BUTTON_NEUTRAL).forEach { which ->
dialog.getButton(which)?.apply {
- setTextColor(context.getColor(R.color.text_secondary))
+ setTextColor(buttonColors(context.getColor(R.color.text_secondary)))
isAllCaps = false
}
}
diff --git a/app/src/main/res/drawable/ic_bedtime.xml b/app/src/main/res/drawable/ic_bedtime.xml
new file mode 100644
index 0000000..758a4b0
--- /dev/null
+++ b/app/src/main/res/drawable/ic_bedtime.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index 86ba3da..7b99178 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -598,4 +598,34 @@
캡처 중입니다
캡처 중지 후 계속
출석이 아니요이면 과업을 예로 둘 수 없습니다.
+ 대기 모드 켜기
+ 대기 모드 실행 중
+ 화면을 누르면 대기 모드가 해제됩니다.
+ 밤새 대기 모드 유지 허용
+ Android는 배터리를 아끼기 위해 백그라운드의 GF2logger를 중지할 수 있습니다. 배터리 최적화 예외로 설정하면 대기 모드가 밤새 유지됩니다. 이 설정은 나중에 앱의 배터리 설정에서 바꿀 수 있습니다.
+ 허용
+ 나중에
+ 대기 모드의 자동 기록에만 사용됩니다. 게임 시작 화면을 누르고, 최근 앱 화면에서 게임을 닫고, 홈으로 나간 뒤 휴대전화를 잠급니다. 어떤 앱이 앞에 있는지와 최근 앱 카드의 앱 이름만 확인하며, 그 밖의 화면 내용은 읽지 않습니다.
+ 자동 기록 켜기
+ 자는 동안 서클 멤버를 기록하려면 GF2logger의 접근성 서비스가 게임 시작 화면을 누르고, 게임을 닫은 뒤 휴대전화를 잠가야 합니다. 접근성 설정을 열어 GF2logger를 선택하고 켜세요. Android가 허용하지 않으면 먼저 GF2logger 앱 정보의 ⋮ 메뉴에서 제한된 설정을 허용하세요.
+ 설정 열기
+ 대기 모드만 켜기
+ 저장된 서클이 없어 대기 모드가 기록하지 않습니다. 먼저 서클을 한 번 캡처하세요.
+ 자동 기록에는 VPN 동의가 필요합니다. 이번 대기 모드에서는 기록하지 않습니다.
+ 기록할 서버 선택
+ 선택한 서버마다 게임 하루가 끝나기 5분 전에 기록한 뒤 휴대전화를 잠급니다. 클라이언트는 한 번에 한 서버에만 접속하므로 클라이언트마다 서버를 하나씩 선택하세요.
+ %1$s · %2$s · %3$s 시작
+ 대기 모드 시작
+ 다음 자동 기록: %1$s · %2$s
+ 대기 모드 자동 기록 결과
+ %1$s: %2$s (%3$s)
+ 멤버 기록 완료
+ 다른 서클로 접속됨
+ 제한 시간 안에 멤버 정보가 들어오지 않음
+ 시간 안에 시작하지 못함
+ 접근성 서비스가 꺼져 있음
+ VPN 동의가 없음
+ 게임 클라이언트가 설치되지 않음
+ 캡처를 시작하지 못함
+ 대기 모드가 종료되어 취소됨
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index f747f9a..6abfaa8 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -32,4 +32,6 @@
#D98A8A
#C77777
#321A1B
+
+ #6B6B6B
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d052e27..33ef992 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -606,4 +606,34 @@
Capture is running
Stop capture
Patrol can\'t be yes while login is no.
+ Turn on standby mode
+ Standby mode is on
+ Tap the screen to leave standby mode.
+ Allow standby mode to run overnight
+ Android may stop GF2logger in the background to save battery. Exclude it from battery optimization to keep standby mode running overnight. You can change this later in the app\'s battery settings.
+ Allow
+ Not now
+ Used only by standby mode\'s automatic recording: taps the game\'s start screen, closes the game from Recents, returns Home, and locks the phone afterwards. It reads only which app is in front and the app names on Recents cards, nothing else on screen.
+ Turn on automatic recording
+ To record your Platoon members while you sleep, GF2logger needs its accessibility service to tap the game\'s start screen, close the game, and lock the phone afterwards. Open Accessibility settings, choose GF2logger, and turn it on. If Android does not allow it, first allow restricted settings from the ⋮ menu in GF2logger\'s App info.
+ Open settings
+ Standby only
+ No Platoon is saved yet, so standby mode will not record. Capture your Platoon once first.
+ Automatic recording needs VPN consent. Standby mode will not record this time.
+ Choose servers to record
+ Each chosen server is recorded 5 minutes before its game day ends, then the phone locks. A client logs in to one server at a time, so choose one server per client.
+ %1$s · %2$s · starts %3$s
+ Start standby mode
+ Next recording: %1$s · %2$s
+ Standby recording results
+ %1$s: %2$s (%3$s)
+ Members recorded
+ Logged in to a different Platoon
+ No members data before the time limit
+ Not started in time
+ Accessibility service was off
+ VPN consent was missing
+ Game client not installed
+ Capture could not start
+ Cancelled because standby mode ended
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index 135cf21..3209d36 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -18,6 +18,13 @@
- @style/GF2PickerTheme
+
+
+