diff --git a/android-ruler-debug.apk b/android-ruler-debug.apk index 63ad03c..e3d7647 100644 Binary files a/android-ruler-debug.apk and b/android-ruler-debug.apk differ diff --git a/app/src/main/java/com/example/androidruler/RulerApplication.kt b/app/src/main/java/com/example/androidruler/RulerApplication.kt index e461dd7..b508054 100644 --- a/app/src/main/java/com/example/androidruler/RulerApplication.kt +++ b/app/src/main/java/com/example/androidruler/RulerApplication.kt @@ -1,5 +1,11 @@ package com.example.androidruler import android.app.Application +import com.example.androidruler.util.DebugLogger -class RulerApplication : Application() +class RulerApplication : Application() { + override fun onCreate() { + super.onCreate() + DebugLogger.init(this) + } +} diff --git a/app/src/main/java/com/example/androidruler/theme/Theme.kt b/app/src/main/java/com/example/androidruler/theme/Theme.kt index 2e58167..c605ef7 100644 --- a/app/src/main/java/com/example/androidruler/theme/Theme.kt +++ b/app/src/main/java/com/example/androidruler/theme/Theme.kt @@ -25,7 +25,8 @@ fun AndroidRulerTheme( val view = LocalView.current if (!view.isInEditMode) { SideEffect { - val window = (view.context as Activity).window + val activity = view.context as? Activity ?: return@SideEffect + val window = activity.window window.statusBarColor = colorScheme.primary.toArgb() WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme } diff --git a/app/src/main/java/com/example/androidruler/ui/RulerCanvas.kt b/app/src/main/java/com/example/androidruler/ui/RulerCanvas.kt index 4f8d88e..c039714 100644 --- a/app/src/main/java/com/example/androidruler/ui/RulerCanvas.kt +++ b/app/src/main/java/com/example/androidruler/ui/RulerCanvas.kt @@ -31,6 +31,8 @@ fun RulerCanvas( val textMeasurer = rememberTextMeasurer() val isPortrait = LocalConfiguration.current.screenHeightDp > LocalConfiguration.current.screenWidthDp + if (pixelPerCm <= 0f) return + val scrollModifier = if (isPortrait) { Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/example/androidruler/ui/RulerScreen.kt b/app/src/main/java/com/example/androidruler/ui/RulerScreen.kt index a038acc..f47ab54 100644 --- a/app/src/main/java/com/example/androidruler/ui/RulerScreen.kt +++ b/app/src/main/java/com/example/androidruler/ui/RulerScreen.kt @@ -10,6 +10,10 @@ import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -26,8 +30,24 @@ fun RulerScreen( val context = LocalContext.current LaunchedEffect(Unit) { - val dpi = context.resources.displayMetrics.xdpi - viewModel.calculatePixelPerCm(dpi) + try { + val dpi = context.resources.displayMetrics.xdpi + viewModel.calculatePixelPerCm(dpi) + } catch (e: Exception) { + android.util.Log.e("RulerScreen", "Failed to calculate pixel per cm", e) + } + } + + // guard: don't render ruler until pixelPerCm is ready + if (viewModel.pixelPerCm <= 0f) { + Box(modifier = modifier.fillMaxSize()) { + Text( + text = "初始化中...", + modifier = Modifier.align(Alignment.Center), + fontSize = 16.sp + ) + } + return } val measuredDistance = viewModel.measuredDistance diff --git a/app/src/main/java/com/example/androidruler/ui/photo/CameraCapture.kt b/app/src/main/java/com/example/androidruler/ui/photo/CameraCapture.kt index b347736..6607aa9 100644 --- a/app/src/main/java/com/example/androidruler/ui/photo/CameraCapture.kt +++ b/app/src/main/java/com/example/androidruler/ui/photo/CameraCapture.kt @@ -26,8 +26,8 @@ import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat +import com.example.androidruler.util.DebugLogger import java.nio.ByteBuffer -import java.util.concurrent.Executors @Composable fun CameraCapture( @@ -55,20 +55,29 @@ fun CameraCapture( Button( onClick = { - cameraController.takePicture( - ContextCompat.getMainExecutor(context), - object : ImageCapture.OnImageCapturedCallback() { - override fun onCaptureSuccess(image: ImageProxy) { - val bitmap = imageProxyToBitmap(image) - image.close() - bitmap?.let { onPhotoTaken(it) } - } + try { + cameraController.takePicture( + ContextCompat.getMainExecutor(context), + object : ImageCapture.OnImageCapturedCallback() { + override fun onCaptureSuccess(image: ImageProxy) { + try { + val bitmap = imageProxyToBitmap(image) + bitmap?.let { onPhotoTaken(it) } + } catch (e: Exception) { + DebugLogger.e("CameraCapture", "Failed to process captured image", e) + } finally { + image.close() + } + } - override fun onError(exception: ImageCaptureException) { - exception.printStackTrace() + override fun onError(exception: ImageCaptureException) { + DebugLogger.e("CameraCapture", "Capture error", exception) + } } - } - ) + ) + } catch (e: Exception) { + DebugLogger.e("CameraCapture", "takePicture failed", e) + } }, modifier = Modifier .align(Alignment.BottomCenter) @@ -83,13 +92,25 @@ fun CameraCapture( } private fun imageProxyToBitmap(image: ImageProxy): Bitmap? { - val buffer: ByteBuffer = image.planes[0].buffer - val bytes = ByteArray(buffer.remaining()) - buffer.get(bytes) + return try { + if (image.planes.isEmpty()) { + DebugLogger.w("CameraCapture", "ImageProxy has no planes") + return null + } + val buffer: ByteBuffer = image.planes[0].buffer + val bytes = ByteArray(buffer.remaining()) + buffer.get(bytes) - val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null - val matrix = Matrix().apply { - postRotate(image.imageInfo.rotationDegrees.toFloat()) + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: run { + DebugLogger.w("CameraCapture", "Failed to decode image bytes") + return null + } + val matrix = Matrix().apply { + postRotate(image.imageInfo.rotationDegrees.toFloat()) + } + Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + } catch (e: Exception) { + DebugLogger.e("CameraCapture", "imageProxyToBitmap failed", e) + null } - return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) } diff --git a/app/src/main/java/com/example/androidruler/util/DebugLogger.kt b/app/src/main/java/com/example/androidruler/util/DebugLogger.kt new file mode 100644 index 0000000..ebfc7c3 --- /dev/null +++ b/app/src/main/java/com/example/androidruler/util/DebugLogger.kt @@ -0,0 +1,115 @@ +package com.example.androidruler.util + +import android.content.Context +import android.os.Build +import android.os.Environment +import java.io.File +import java.io.FileWriter +import java.io.PrintWriter +import java.io.StringWriter +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlin.system.exitProcess + +object DebugLogger { + + private var logFile: File? = null + private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US) + private var initialized = false + + fun init(context: Context) { + if (initialized) return + initialized = true + + try { + val logDir = File(context.getExternalFilesDir(null), "logs") + logDir.mkdirs() + logFile = File(logDir, "ruler_debug_${System.currentTimeMillis()}.log") + logFile?.createNewFile() + + d("DebugLogger", "Logger initialized. SDK=${Build.VERSION.SDK_INT}, Model=${Build.MODEL}, Manufacturer=${Build.MANUFACTURER}") + + // global crash handler + val defaultHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + e("CrashHandler", "Uncaught exception in thread '${thread.name}'", throwable) + flush() + // pass to default handler + defaultHandler?.uncaughtException(thread, throwable) + // if no default handler, kill process + exitProcess(1) + } + } catch (e: Exception) { + android.util.Log.e("DebugLogger", "Failed to initialize logger", e) + } + } + + @JvmStatic + fun d(tag: String, message: String) { + log("DEBUG", tag, message, null) + } + + @JvmStatic + fun i(tag: String, message: String) { + log("INFO", tag, message, null) + } + + @JvmStatic + fun w(tag: String, message: String, throwable: Throwable? = null) { + log("WARN", tag, message, throwable) + } + + @JvmStatic + fun e(tag: String, message: String, throwable: Throwable? = null) { + log("ERROR", tag, message, throwable) + } + + private fun log(level: String, tag: String, message: String, throwable: Throwable?) { + val timestamp = synchronized(dateFormat) { dateFormat.format(Date()) } + val sb = StringBuilder() + sb.append("$timestamp [$level] $tag: $message") + + if (throwable != null) { + sb.append("\n") + val sw = StringWriter() + throwable.printStackTrace(PrintWriter(sw)) + sb.append(sw.toString()) + } + + val line = sb.toString() + + // write to android logcat + when (level) { + "DEBUG" -> android.util.Log.d(tag, line) + "INFO" -> android.util.Log.i(tag, line) + "WARN" -> android.util.Log.w(tag, line) + "ERROR" -> android.util.Log.e(tag, line) + } + + // write to file + try { + logFile?.let { file -> + FileWriter(file, true).use { writer -> + writer.appendLine(line) + } + } + } catch (_: Exception) { + // best effort + } + } + + fun flush() { + // file writes are already flushed via use{} + } + + fun getLogFilePath(): String? = logFile?.absolutePath + + fun getRecentLogs(maxLines: Int = 200): String { + return try { + logFile?.readLines()?.takeLast(maxLines)?.joinToString("\n") ?: "No logs available" + } catch (e: Exception) { + "Failed to read logs: ${e.message}" + } + } +} diff --git a/app/src/main/java/com/example/androidruler/viewmodel/RulerViewModel.kt b/app/src/main/java/com/example/androidruler/viewmodel/RulerViewModel.kt index 7d5317d..5943d8a 100644 --- a/app/src/main/java/com/example/androidruler/viewmodel/RulerViewModel.kt +++ b/app/src/main/java/com/example/androidruler/viewmodel/RulerViewModel.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.example.androidruler.data.SettingsRepository +import com.example.androidruler.util.DebugLogger import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlin.math.abs @@ -36,10 +37,15 @@ class RulerViewModel(application: Application) : AndroidViewModel(application) { init { viewModelScope.launch { - cmCorrectionFactor = repo.correctionFactor.first() - isCalibrated = repo.isCalibrated.first() - defaultOrientation = repo.defaultOrientation.first() - overlayRulerLength = repo.defaultRulerLength.first() + try { + cmCorrectionFactor = repo.correctionFactor.first() + isCalibrated = repo.isCalibrated.first() + defaultOrientation = repo.defaultOrientation.first() + overlayRulerLength = repo.defaultRulerLength.first() + DebugLogger.i("RulerViewModel", "Settings loaded: correctionFactor=$cmCorrectionFactor, calibrated=$isCalibrated") + } catch (e: Exception) { + DebugLogger.e("RulerViewModel", "Failed to load settings, using defaults", e) + } } } @@ -77,13 +83,18 @@ class RulerViewModel(application: Application) : AndroidViewModel(application) { correctionFactor: Float ) { viewModelScope.launch { - defaultOrientation = orientation - overlayRulerLength = rulerLength - cmCorrectionFactor = correctionFactor - repo.setDefaultOrientation(orientation) - repo.setDefaultRulerLength(rulerLength) - repo.setCorrectionFactor(correctionFactor) - isCalibrated = true + try { + defaultOrientation = orientation + overlayRulerLength = rulerLength + cmCorrectionFactor = correctionFactor + repo.setDefaultOrientation(orientation) + repo.setDefaultRulerLength(rulerLength) + repo.setCorrectionFactor(correctionFactor) + isCalibrated = true + DebugLogger.i("RulerViewModel", "Settings saved: orientation=$orientation, length=$rulerLength, factor=$correctionFactor") + } catch (e: Exception) { + DebugLogger.e("RulerViewModel", "Failed to save settings", e) + } } } diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..df136b7 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..13ca8c4 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..56378ae Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..4a91a0e Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..d1b92f5 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ