fix: prime widget data after saving config

This commit is contained in:
Mimikko-zeus
2026-06-23 11:53:02 +08:00
parent 4f1fb80851
commit 814a29eab8
6 changed files with 208 additions and 7 deletions

View File

@@ -14,6 +14,8 @@ import com.sub2api.monitor.data.MockSub2ApiRepository
import com.sub2api.monitor.sync.WorkManagerRefreshScheduler import com.sub2api.monitor.sync.WorkManagerRefreshScheduler
import com.sub2api.monitor.ui.ConfigScreen import com.sub2api.monitor.ui.ConfigScreen
import com.sub2api.monitor.ui.ConfigViewModel import com.sub2api.monitor.ui.ConfigViewModel
import com.sub2api.monitor.widget.GlanceWidgetUpdater
import com.sub2api.monitor.widget.WidgetStateRepository
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -22,11 +24,15 @@ class MainActivity : ComponentActivity() {
val configRepository = remember { DataStoreConfigRepository(applicationContext) } val configRepository = remember { DataStoreConfigRepository(applicationContext) }
val sub2ApiRepository = remember { MockSub2ApiRepository() } val sub2ApiRepository = remember { MockSub2ApiRepository() }
val refreshScheduler = remember { WorkManagerRefreshScheduler(applicationContext) } val refreshScheduler = remember { WorkManagerRefreshScheduler(applicationContext) }
val widgetStateRepository = remember { WidgetStateRepository(applicationContext) }
val widgetUpdater = remember { GlanceWidgetUpdater(applicationContext) }
val configViewModel: ConfigViewModel = viewModel( val configViewModel: ConfigViewModel = viewModel(
factory = ConfigViewModelFactory( factory = ConfigViewModelFactory(
configRepository = configRepository, configRepository = configRepository,
sub2ApiRepository = sub2ApiRepository, sub2ApiRepository = sub2ApiRepository,
refreshScheduler = refreshScheduler, refreshScheduler = refreshScheduler,
widgetStateRepository = widgetStateRepository,
widgetUpdater = widgetUpdater,
), ),
) )
Sub2ApiMonitorApp(configViewModel) Sub2ApiMonitorApp(configViewModel)
@@ -52,9 +58,17 @@ private class ConfigViewModelFactory(
private val configRepository: DataStoreConfigRepository, private val configRepository: DataStoreConfigRepository,
private val sub2ApiRepository: MockSub2ApiRepository, private val sub2ApiRepository: MockSub2ApiRepository,
private val refreshScheduler: WorkManagerRefreshScheduler, private val refreshScheduler: WorkManagerRefreshScheduler,
private val widgetStateRepository: WidgetStateRepository,
private val widgetUpdater: GlanceWidgetUpdater,
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ConfigViewModel(configRepository, sub2ApiRepository, refreshScheduler) as T return ConfigViewModel(
configRepository,
sub2ApiRepository,
refreshScheduler,
widgetStateRepository,
widgetUpdater,
) as T
} }
} }

View File

@@ -34,6 +34,8 @@ class ConfigViewModel(
private val configRepository: ConfigRepository, private val configRepository: ConfigRepository,
private val sub2ApiRepository: Sub2ApiRepository, private val sub2ApiRepository: Sub2ApiRepository,
private val refreshScheduler: RefreshScheduler, private val refreshScheduler: RefreshScheduler,
private val widgetSnapshotCache: WidgetSnapshotCache,
private val widgetUpdater: WidgetUpdater,
) : ViewModel() { ) : ViewModel() {
var uiState by mutableStateOf(ConfigUiState()) var uiState by mutableStateOf(ConfigUiState())
private set private set
@@ -80,9 +82,15 @@ class ConfigViewModel(
viewModelScope.launch { viewModelScope.launch {
uiState = uiState.copy(isSaving = true, statusMessage = null) uiState = uiState.copy(isSaving = true, statusMessage = null)
val config = uiState.toAppConfig() val config = uiState.toAppConfig()
configRepository.save(config) val result = saveConfigAndPrimeWidget(
refreshScheduler.schedule(config.refreshIntervalMinutes) config = config,
uiState = uiState.copy(isSaving = false, statusMessage = "配置已保存") configRepository = configRepository,
sub2ApiRepository = sub2ApiRepository,
refreshScheduler = refreshScheduler,
widgetSnapshotCache = widgetSnapshotCache,
widgetUpdater = widgetUpdater,
)
uiState = uiState.copy(isSaving = false, statusMessage = result.message)
} }
} }
} }

View File

@@ -0,0 +1,48 @@
package com.sub2api.monitor.ui
import com.sub2api.monitor.data.AppConfig
import com.sub2api.monitor.data.ConfigRepository
import com.sub2api.monitor.data.Sub2ApiRepository
import com.sub2api.monitor.domain.Sub2ApiSnapshot
import com.sub2api.monitor.sync.RefreshScheduler
interface WidgetSnapshotCache {
suspend fun saveSuccess(snapshot: Sub2ApiSnapshot)
suspend fun saveError(message: String)
}
interface WidgetUpdater {
suspend fun updateAll()
}
data class SaveConfigResult(
val message: String,
)
suspend fun saveConfigAndPrimeWidget(
config: AppConfig,
configRepository: ConfigRepository,
sub2ApiRepository: Sub2ApiRepository,
refreshScheduler: RefreshScheduler,
widgetSnapshotCache: WidgetSnapshotCache,
widgetUpdater: WidgetUpdater,
): SaveConfigResult {
configRepository.save(config)
refreshScheduler.schedule(config.refreshIntervalMinutes)
val message = runCatching {
sub2ApiRepository.fetchSnapshot()
}.fold(
onSuccess = { snapshot ->
widgetSnapshotCache.saveSuccess(snapshot)
"配置已保存,监控数据已刷新"
},
onFailure = { error ->
widgetSnapshotCache.saveError(error.message ?: "首次刷新失败")
"配置已保存,首次刷新失败"
},
)
widgetUpdater.updateAll()
return SaveConfigResult(message)
}

View File

@@ -0,0 +1,13 @@
package com.sub2api.monitor.widget
import android.content.Context
import androidx.glance.appwidget.updateAll
import com.sub2api.monitor.ui.WidgetUpdater
class GlanceWidgetUpdater(
private val context: Context,
) : WidgetUpdater {
override suspend fun updateAll() {
Sub2ApiMonitorWidget().updateAll(context)
}
}

View File

@@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.sub2api.monitor.domain.Sub2ApiSnapshot import com.sub2api.monitor.domain.Sub2ApiSnapshot
import com.sub2api.monitor.ui.WidgetSnapshotCache
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
@@ -16,7 +17,7 @@ private val Context.widgetStateDataStore by preferencesDataStore(name = "sub2api
class WidgetStateRepository( class WidgetStateRepository(
private val context: Context, private val context: Context,
private val json: Json = Json { ignoreUnknownKeys = true }, private val json: Json = Json { ignoreUnknownKeys = true },
) { ) : WidgetSnapshotCache {
val lastSuccessfulSnapshot: Flow<Sub2ApiSnapshot?> = context.widgetStateDataStore.data.map { preferences -> val lastSuccessfulSnapshot: Flow<Sub2ApiSnapshot?> = context.widgetStateDataStore.data.map { preferences ->
preferences[SNAPSHOT]?.let { value -> preferences[SNAPSHOT]?.let { value ->
runCatching { json.decodeFromString<Sub2ApiSnapshot>(value) }.getOrNull() runCatching { json.decodeFromString<Sub2ApiSnapshot>(value) }.getOrNull()
@@ -27,14 +28,14 @@ class WidgetStateRepository(
preferences[ERROR] preferences[ERROR]
} }
suspend fun saveSuccess(snapshot: Sub2ApiSnapshot) { override suspend fun saveSuccess(snapshot: Sub2ApiSnapshot) {
context.widgetStateDataStore.edit { preferences -> context.widgetStateDataStore.edit { preferences ->
preferences[SNAPSHOT] = json.encodeToString(snapshot) preferences[SNAPSHOT] = json.encodeToString(snapshot)
preferences.remove(ERROR) preferences.remove(ERROR)
} }
} }
suspend fun saveError(message: String) { override suspend fun saveError(message: String) {
context.widgetStateDataStore.edit { preferences -> context.widgetStateDataStore.edit { preferences ->
preferences[ERROR] = message preferences[ERROR] = message
} }

View File

@@ -0,0 +1,117 @@
package com.sub2api.monitor.ui
import com.sub2api.monitor.data.AppConfig
import com.sub2api.monitor.data.ConfigRepository
import com.sub2api.monitor.data.MockSub2ApiRepository
import com.sub2api.monitor.data.Sub2ApiRepository
import com.sub2api.monitor.domain.Sub2ApiSnapshot
import com.sub2api.monitor.sync.RefreshScheduler
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SaveConfigAndPrimeWidgetTest {
@Test
fun savingConfiguredAppImmediatelyPrimesWidgetSnapshot() = runTest {
val configRepository = FakeConfigRepository()
val scheduler = FakeRefreshScheduler()
val cache = FakeWidgetSnapshotCache()
val updater = FakeWidgetUpdater()
val config = AppConfig("https://sub2api.example.com", "secret", 30)
val result = saveConfigAndPrimeWidget(
config = config,
configRepository = configRepository,
sub2ApiRepository = MockSub2ApiRepository(),
refreshScheduler = scheduler,
widgetSnapshotCache = cache,
widgetUpdater = updater,
)
assertEquals(config, configRepository.savedConfig)
assertEquals(30, scheduler.scheduledInterval)
assertNotNull(cache.savedSnapshot)
assertEquals(null, cache.savedError)
assertTrue(updater.updated)
assertEquals("配置已保存,监控数据已刷新", result.message)
}
@Test
fun failedInitialRefreshStoresErrorAndStillSavesConfiguration() = runTest {
val configRepository = FakeConfigRepository()
val scheduler = FakeRefreshScheduler()
val cache = FakeWidgetSnapshotCache()
val updater = FakeWidgetUpdater()
val config = AppConfig("https://sub2api.example.com", "secret", 30)
val result = saveConfigAndPrimeWidget(
config = config,
configRepository = configRepository,
sub2ApiRepository = FailingSub2ApiRepository(),
refreshScheduler = scheduler,
widgetSnapshotCache = cache,
widgetUpdater = updater,
)
assertEquals(config, configRepository.savedConfig)
assertEquals(30, scheduler.scheduledInterval)
assertEquals(null, cache.savedSnapshot)
assertEquals("mock failure", cache.savedError)
assertTrue(updater.updated)
assertEquals("配置已保存,首次刷新失败", result.message)
}
}
private class FakeConfigRepository : ConfigRepository {
private val state = MutableStateFlow(AppConfig())
var savedConfig: AppConfig? = null
override val config: Flow<AppConfig> = state
override suspend fun save(config: AppConfig) {
savedConfig = config
state.value = config
}
}
private class FakeRefreshScheduler : RefreshScheduler {
var scheduledInterval: Int? = null
override fun schedule(intervalMinutes: Int) {
scheduledInterval = intervalMinutes
}
}
private class FakeWidgetSnapshotCache : WidgetSnapshotCache {
var savedSnapshot: Sub2ApiSnapshot? = null
var savedError: String? = null
override suspend fun saveSuccess(snapshot: Sub2ApiSnapshot) {
savedSnapshot = snapshot
savedError = null
}
override suspend fun saveError(message: String) {
savedError = message
}
}
private class FakeWidgetUpdater : WidgetUpdater {
var updated = false
override suspend fun updateAll() {
updated = true
}
}
private class FailingSub2ApiRepository : Sub2ApiRepository {
override suspend fun fetchSnapshot(): Sub2ApiSnapshot {
error("mock failure")
}
override suspend fun testConnection(config: AppConfig): Result<Unit> = Result.success(Unit)
}