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