From 74340c1628dc6a61e60b4d650baad9b11223def7 Mon Sep 17 00:00:00 2001 From: Atsumi3 Date: Fri, 4 Sep 2026 20:30:25 +0000 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E7=B6=9A=E5=85=88=E3=82=92=E3=83=96?= =?UTF-8?q?=E3=83=A9=E3=82=A6=E3=82=B6=E3=81=A7=E9=96=8B=E3=81=8F=E3=83=9C?= =?UTF-8?q?=E3=82=BF=E3=83=B3=E3=81=A8=E6=9B=B4=E6=96=B0=E5=A4=B1=E6=95=97?= =?UTF-8?q?=E3=81=AE=E7=90=86=E7=94=B1=E8=A1=A8=E7=A4=BA=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/kidspos/api/ApiHttpException.kt | 5 ++ .../cuc/android/kidspos/api/ApkDownloader.kt | 2 +- .../kidspos/api/OpenApiAppUpdateService.kt | 2 +- .../kidspos/ui/settings/SettingsScreen.kt | 55 +++++++++++++- .../kidspos/update/AppUpdateManager.kt | 26 ++++++- app/src/main/res/values/strings.xml | 5 ++ .../kidspos/ui/settings/SettingsScreenTest.kt | 60 ++++++++++++++++ .../kidspos/update/AppUpdateManagerTest.kt | 72 ++++++++++++++++++- 8 files changed, 218 insertions(+), 9 deletions(-) create mode 100644 app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/api/ApiHttpException.kt diff --git a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/api/ApiHttpException.kt b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/api/ApiHttpException.kt new file mode 100644 index 0000000..a048510 --- /dev/null +++ b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/api/ApiHttpException.kt @@ -0,0 +1,5 @@ +package info.nukoneko.cuc.android.kidspos.api + +import java.io.IOException + +class ApiHttpException(val code: Int) : IOException("HTTP $code") diff --git a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/api/ApkDownloader.kt b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/api/ApkDownloader.kt index d2fb5cc..b7086b3 100644 --- a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/api/ApkDownloader.kt +++ b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/api/ApkDownloader.kt @@ -33,7 +33,7 @@ class OkHttpApkDownloader( client.newCall(Request.Builder().url(url).build()).execute().use { response -> if (!response.isSuccessful) { - throw Exception("Failed to download apk: ${response.code}") + throw ApiHttpException(response.code) } val body = response.body val total = body.contentLength().takeIf { it > 0 } ?: update.fileSize diff --git a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/api/OpenApiAppUpdateService.kt b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/api/OpenApiAppUpdateService.kt index 2a8023a..9c3ccc9 100644 --- a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/api/OpenApiAppUpdateService.kt +++ b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/api/OpenApiAppUpdateService.kt @@ -10,7 +10,7 @@ class OpenApiAppUpdateService( override suspend fun checkForUpdate(currentVersionCode: Int): AppUpdate? { val response = apkApi.checkApkUpdate(currentVersionCode) if (!response.isSuccessful) { - throw Exception("Failed to check app update: ${response.code()}") + throw ApiHttpException(response.code()) } val body = response.body()!! if (!body.hasUpdate) return null diff --git a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsScreen.kt index ff12b3e..93fa00e 100644 --- a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsScreen.kt @@ -1,5 +1,7 @@ package info.nukoneko.cuc.android.kidspos.ui.settings +import android.content.ActivityNotFoundException +import android.content.Context import android.content.Intent import android.net.Uri import android.os.Build @@ -23,6 +25,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator @@ -48,8 +51,10 @@ import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanOptions import info.nukoneko.cuc.android.kidspos.R import info.nukoneko.cuc.android.kidspos.update.UpdateFailure +import info.nukoneko.cuc.android.kidspos.update.UpdateFailureReason import info.nukoneko.cuc.android.kidspos.update.UpdateStatus import info.nukoneko.cuc.android.kidspos.util.Mode +import timber.log.Timber import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) @@ -59,6 +64,7 @@ fun SettingsScreen( viewModel: SettingsViewModel = hiltViewModel() ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current val scanLauncher = rememberLauncherForActivityResult(ScanContract()) { result -> val contents = result.contents @@ -94,6 +100,7 @@ fun SettingsScreen( ConnectionCard( serverAddress = state.serverAddress, onLoadSetting = { scanLauncher.launch(ScanOptions()) }, + onOpenInBrowser = { openInBrowser(context, state.serverAddress) }, modifier = Modifier.weight(1f) ) ModeCard( @@ -158,10 +165,25 @@ private fun ActionButton( } } +@Composable +private fun SecondaryActionButton( + text: String, + onClick: () -> Unit +) { + FilledTonalButton( + onClick = onClick, + modifier = Modifier.heightIn(min = 56.dp), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp) + ) { + Text(text = text, style = MaterialTheme.typography.titleMedium) + } +} + @Composable private fun ConnectionCard( serverAddress: String, onLoadSetting: () -> Unit, + onOpenInBrowser: () -> Unit, modifier: Modifier = Modifier ) { SettingsCard( @@ -169,7 +191,13 @@ private fun ConnectionCard( modifier = modifier ) { Text(text = serverAddress, style = MaterialTheme.typography.bodyLarge) - ActionButton(text = stringResource(R.string.load_setting), onClick = onLoadSetting) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ActionButton(text = stringResource(R.string.load_setting), onClick = onLoadSetting) + SecondaryActionButton( + text = stringResource(R.string.open_in_browser), + onClick = onOpenInBrowser + ) + } } } @@ -290,10 +318,26 @@ private fun AppUpdateSection( UpdateFailure.INSTALL -> stringResource(R.string.update_install_failed) } ) + status.reason?.let { reason -> + Text( + text = failureReasonText(reason), + style = MaterialTheme.typography.bodySmall + ) + } } } } +@Composable +private fun failureReasonText(reason: UpdateFailureReason): String = when (reason) { + is UpdateFailureReason.HttpStatus -> + stringResource(R.string.update_failure_http_format, reason.code) + UpdateFailureReason.Timeout -> stringResource(R.string.update_failure_timeout) + UpdateFailureReason.Unreachable -> stringResource(R.string.update_failure_unreachable) + is UpdateFailureReason.Other -> + stringResource(R.string.update_failure_detail_format, reason.description) +} + @Composable private fun UpdateConfirmDialog( status: UpdateStatus.Available, @@ -331,3 +375,12 @@ private fun UpdateConfirmDialog( } ) } + +private fun openInBrowser(context: Context, address: String) { + val uri = Uri.parse(address) + try { + context.startActivity(Intent(Intent.ACTION_VIEW, uri)) + } catch (e: ActivityNotFoundException) { + Timber.w(e, "No browser available to open %s", address) + } +} diff --git a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/update/AppUpdateManager.kt b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/update/AppUpdateManager.kt index 6737306..a9bb0aa 100644 --- a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/update/AppUpdateManager.kt +++ b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/update/AppUpdateManager.kt @@ -1,5 +1,6 @@ package info.nukoneko.cuc.android.kidspos.update +import info.nukoneko.cuc.android.kidspos.api.ApiHttpException import info.nukoneko.cuc.android.kidspos.data.repository.AppUpdateRepository import info.nukoneko.cuc.android.kidspos.entity.AppUpdate import kotlinx.coroutines.CoroutineScope @@ -10,6 +11,9 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import timber.log.Timber +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException import javax.inject.Inject import javax.inject.Singleton @@ -19,6 +23,13 @@ enum class UpdateFailure { INSTALL } +sealed interface UpdateFailureReason { + data class HttpStatus(val code: Int) : UpdateFailureReason + data object Timeout : UpdateFailureReason + data object Unreachable : UpdateFailureReason + data class Other(val description: String) : UpdateFailureReason +} + sealed interface UpdateStatus { data object Idle : UpdateStatus data object Checking : UpdateStatus @@ -27,7 +38,7 @@ sealed interface UpdateStatus { data class Downloading(val progress: Float) : UpdateStatus data object Installing : UpdateStatus data object InstallNotPermitted : UpdateStatus - data class Failed(val cause: UpdateFailure) : UpdateStatus + data class Failed(val cause: UpdateFailure, val reason: UpdateFailureReason? = null) : UpdateStatus } /** @@ -71,7 +82,7 @@ class AppUpdateManager @Inject constructor( if (update == null) UpdateStatus.UpToDate else UpdateStatus.Available(update) } catch (e: Exception) { Timber.w(e, "Failed to check app update") - UpdateStatus.Failed(UpdateFailure.CHECK) + UpdateStatus.Failed(UpdateFailure.CHECK, reasonOf(e)) } _status.value = status } @@ -105,7 +116,7 @@ class AppUpdateManager @Inject constructor( } else { UpdateFailure.DOWNLOAD } - _status.value = UpdateStatus.Failed(cause) + _status.value = UpdateStatus.Failed(cause, reasonOf(e)) } } } @@ -115,6 +126,15 @@ class AppUpdateManager @Inject constructor( _status.value = UpdateStatus.Idle } + private fun reasonOf(e: Exception): UpdateFailureReason = when (e) { + is ApiHttpException -> UpdateFailureReason.HttpStatus(e.code) + is SocketTimeoutException -> UpdateFailureReason.Timeout + is ConnectException, is UnknownHostException -> UpdateFailureReason.Unreachable + else -> UpdateFailureReason.Other( + listOfNotNull(e::class.simpleName, e.message).joinToString(": ") + ) + } + // インストールは apkInstaller.install() から戻った後もブロードキャスト待ちが残るため、 // ジョブの生存だけでなく状態も見て進行中かどうかを判定する private fun isBusy(): Boolean = diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 311af0d..300a0e7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -34,6 +34,7 @@ エラー 現在の接続先 設定を読み込む + ブラウザで開く 練習モードに切り替える 現在のモード サーバーとの通信に失敗しました。\n接続を確認してください。 @@ -62,6 +63,10 @@ アップデートの確認に失敗しました アップデートのダウンロードに失敗しました アップデートのインストールに失敗しました + サーバーが %1$d を返しました + 通信がタイムアウトしました + サーバーに接続できません + 詳細: %1$s アプリを更新するには、不明なアプリのインストールを許可してください 設定を開く diff --git a/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsScreenTest.kt b/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsScreenTest.kt index 40ec304..b4b7cee 100644 --- a/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsScreenTest.kt +++ b/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsScreenTest.kt @@ -1,6 +1,8 @@ package info.nukoneko.cuc.android.kidspos.ui.settings +import android.app.Application import android.content.Context +import android.content.Intent import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.hasSetTextAction @@ -13,6 +15,7 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers import info.nukoneko.cuc.android.kidspos.R +import info.nukoneko.cuc.android.kidspos.api.ApiHttpException import info.nukoneko.cuc.android.kidspos.entity.AppUpdate import info.nukoneko.cuc.android.kidspos.testutil.FakeAppUpdateService import info.nukoneko.cuc.android.kidspos.testutil.MainDispatcherRule @@ -25,6 +28,7 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.RuleChain import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config // Robolectric の SDK 36 実行は JDK 21 が必要なため、CI の JDK 17 で動く SDK 35 に固定する @@ -153,4 +157,60 @@ class SettingsScreenTest { context.getString(R.string.update_version_format, "9.9.9", 99) ).assertIsDisplayed() } + + @Test + fun updateCheckHttpErrorShowsStatusCode() { + val updateService = FakeAppUpdateService() + updateService.checkForUpdateHandler = { throw ApiHttpException(503) } + composeRule.setContent { + SettingsScreen( + onNavigateBack = {}, + viewModel = createSettingsViewModel( + settingsRepository, + appUpdateService = updateService + ) + ) + } + + composeRule.onNodeWithText(context.getString(R.string.check_update)) + .performScrollTo() + .performClick() + composeRule.waitForIdle() + + composeRule.onNodeWithText(context.getString(R.string.update_check_failed)).assertExists() + composeRule.onNodeWithText( + context.getString(R.string.update_failure_http_format, 503) + ).assertExists() + } + + @Test + fun openInBrowserButtonIsShown() { + composeRule.setContent { + SettingsScreen( + onNavigateBack = {}, + viewModel = createSettingsViewModel(settingsRepository) + ) + } + + composeRule.onNodeWithText(context.getString(R.string.open_in_browser)) + .performScrollTo() + .assertIsDisplayed() + } + + @Test + fun openInBrowserButtonStartsViewIntentForServerAddress() { + val viewModel = createSettingsViewModel(settingsRepository) + composeRule.setContent { + SettingsScreen(onNavigateBack = {}, viewModel = viewModel) + } + + composeRule.onNodeWithText(context.getString(R.string.open_in_browser)) + .performScrollTo() + .performClick() + composeRule.waitForIdle() + + val intent = shadowOf(context as Application).nextStartedActivity + assertEquals(Intent.ACTION_VIEW, intent.action) + assertEquals(viewModel.uiState.value.serverAddress, intent.dataString) + } } diff --git a/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/update/AppUpdateManagerTest.kt b/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/update/AppUpdateManagerTest.kt index a42964c..74dc597 100644 --- a/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/update/AppUpdateManagerTest.kt +++ b/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/update/AppUpdateManagerTest.kt @@ -1,5 +1,6 @@ package info.nukoneko.cuc.android.kidspos.update +import info.nukoneko.cuc.android.kidspos.api.ApiHttpException import info.nukoneko.cuc.android.kidspos.entity.AppUpdate import info.nukoneko.cuc.android.kidspos.testutil.FakeApkDownloader import info.nukoneko.cuc.android.kidspos.testutil.FakeApkInstaller @@ -11,6 +12,8 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import java.io.File +import java.net.ConnectException +import java.net.SocketTimeoutException class AppUpdateManagerTest { private fun appUpdate(versionCode: Int = 99) = AppUpdate( @@ -68,7 +71,10 @@ class AppUpdateManagerTest { manager.checkForUpdate(10) - assertEquals(UpdateStatus.Failed(UpdateFailure.CHECK), manager.status.value) + assertEquals( + UpdateStatus.Failed(UpdateFailure.CHECK, UpdateFailureReason.Other("Exception: boom")), + manager.status.value + ) } @Test @@ -156,7 +162,64 @@ class AppUpdateManagerTest { manager.checkForUpdate(10) manager.startUpdate() - assertEquals(UpdateStatus.Failed(UpdateFailure.DOWNLOAD), manager.status.value) + assertEquals( + UpdateStatus.Failed(UpdateFailure.DOWNLOAD, UpdateFailureReason.Other("Exception: boom")), + manager.status.value + ) + } + + @Test + fun downloadHttpErrorReportsStatusCode() = runTest { + val downloader = FakeApkDownloader() + downloader.downloadHandler = { _, _ -> throw ApiHttpException(404) } + val manager = createAppUpdateManager( + appUpdateService = updateService(), + apkDownloader = downloader + ) + + manager.checkForUpdate(10) + manager.startUpdate() + + assertEquals( + UpdateStatus.Failed(UpdateFailure.DOWNLOAD, UpdateFailureReason.HttpStatus(404)), + manager.status.value + ) + } + + @Test + fun downloadTimeoutReportsTimeout() = runTest { + val downloader = FakeApkDownloader() + downloader.downloadHandler = { _, _ -> throw SocketTimeoutException("timeout") } + val manager = createAppUpdateManager( + appUpdateService = updateService(), + apkDownloader = downloader + ) + + manager.checkForUpdate(10) + manager.startUpdate() + + assertEquals( + UpdateStatus.Failed(UpdateFailure.DOWNLOAD, UpdateFailureReason.Timeout), + manager.status.value + ) + } + + @Test + fun downloadConnectionFailureReportsUnreachable() = runTest { + val downloader = FakeApkDownloader() + downloader.downloadHandler = { _, _ -> throw ConnectException("refused") } + val manager = createAppUpdateManager( + appUpdateService = updateService(), + apkDownloader = downloader + ) + + manager.checkForUpdate(10) + manager.startUpdate() + + assertEquals( + UpdateStatus.Failed(UpdateFailure.DOWNLOAD, UpdateFailureReason.Unreachable), + manager.status.value + ) } @Test @@ -171,7 +234,10 @@ class AppUpdateManagerTest { manager.checkForUpdate(10) manager.startUpdate() - assertEquals(UpdateStatus.Failed(UpdateFailure.INSTALL), manager.status.value) + assertEquals( + UpdateStatus.Failed(UpdateFailure.INSTALL, UpdateFailureReason.Other("Exception: boom")), + manager.status.value + ) } @Test