Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package info.nukoneko.cuc.android.kidspos.api

import java.io.IOException

class ApiHttpException(val code: Int) : IOException("HTTP $code")
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -94,6 +100,7 @@ fun SettingsScreen(
ConnectionCard(
serverAddress = state.serverAddress,
onLoadSetting = { scanLauncher.launch(ScanOptions()) },
onOpenInBrowser = { openInBrowser(context, state.serverAddress) },
modifier = Modifier.weight(1f)
)
ModeCard(
Expand Down Expand Up @@ -158,18 +165,39 @@ 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(
title = stringResource(R.string.current_connetion_target),
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
)
}
}
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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
}

/**
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -105,7 +116,7 @@ class AppUpdateManager @Inject constructor(
} else {
UpdateFailure.DOWNLOAD
}
_status.value = UpdateStatus.Failed(cause)
_status.value = UpdateStatus.Failed(cause, reasonOf(e))
}
}
}
Expand All @@ -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 =
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
<string name="error">エラー</string>
<string name="current_connetion_target">現在の接続先</string>
<string name="load_setting">設定を読み込む</string>
<string name="open_in_browser">ブラウザで開く</string>
<string name="change_mode">練習モードに切り替える</string>
<string name="current_mode">現在のモード</string>
<string name="store_fetch_failed">サーバーとの通信に失敗しました。\n接続を確認してください。</string>
Expand Down Expand Up @@ -62,6 +63,10 @@
<string name="update_check_failed">アップデートの確認に失敗しました</string>
<string name="update_download_failed">アップデートのダウンロードに失敗しました</string>
<string name="update_install_failed">アップデートのインストールに失敗しました</string>
<string name="update_failure_http_format">サーバーが %1$d を返しました</string>
<string name="update_failure_timeout">通信がタイムアウトしました</string>
<string name="update_failure_unreachable">サーバーに接続できません</string>
<string name="update_failure_detail_format">詳細: %1$s</string>
<string name="install_permission_required">アプリを更新するには、不明なアプリのインストールを許可してください</string>
<string name="open_install_permission_setting">設定を開く</string>

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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 に固定する
Expand Down Expand Up @@ -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)
}
}
Loading
Loading