diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 97e6dc6..e9e62da 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -112,6 +112,19 @@ dependencies { implementation(libs.hilt.navigation.compose) ksp(libs.hilt.compiler) + // WorkManager + Hilt Worker + implementation(libs.work.runtime) + implementation(libs.hilt.work) + ksp(libs.hilt.work.compiler) + + // Room + implementation(libs.room.runtime) + implementation(libs.room.ktx) + ksp(libs.room.compiler) + + // ML Kit GenAI (Gemini Nano on-device) + implementation(libs.mlkit.genai.prompt) + // Navigation implementation(libs.navigation.compose) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7dac3d8..b55a08a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,12 @@ + + + + + + (DownloadState.Idle) + val downloadState: StateFlow = _downloadState.asStateFlow() + + companion object { + private const val TAG = "CrashInsightService" + private const val NOTIFICATION_CHANNEL_ID = "gemini_nano_download" + private const val NOTIFICATION_ID = 9001 + } + + init { + createNotificationChannel() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + "AI Model Download", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Shows download progress for Gemini Nano AI model" + setShowBadge(false) + } + + val notificationManager = context.getSystemService(NotificationManager::class.java) + notificationManager?.createNotificationChannel(channel) + Log.d(TAG, "Notification channel created: $NOTIFICATION_CHANNEL_ID") + } + } + + /** + * Check if on-device AI (Gemini Nano) is available. + * Returns true if the feature is ready to use. + */ + suspend fun isAvailable(): Boolean = withContext(Dispatchers.IO) { + try { + Log.d(TAG, "Checking Gemini Nano availability...") + val model = getOrCreateModel() + currentStatus = model.checkStatus() + Log.d(TAG, "Gemini Nano status: ${statusToString(currentStatus)}") + currentStatus == FeatureStatus.AVAILABLE + } catch (e: Exception) { + Log.e(TAG, "Failed to check Gemini Nano availability", e) + false + } + } + + /** + * Get current feature status for UI display. + */ + @FeatureStatus + fun getStatus(): Int = currentStatus + + private fun statusToString(@FeatureStatus status: Int): String { + return when (status) { + FeatureStatus.AVAILABLE -> "AVAILABLE" + FeatureStatus.DOWNLOADABLE -> "DOWNLOADABLE" + FeatureStatus.DOWNLOADING -> "DOWNLOADING" + FeatureStatus.UNAVAILABLE -> "UNAVAILABLE" + else -> "UNKNOWN($status)" + } + } + + /** + * Start downloading the model in background with progress notifications. + * This can continue even if the user leaves the screen. + */ + fun startBackgroundDownload() { + serviceScope.launch { + downloadModelWithProgress() + } + } + + /** + * Download the model if it's in DOWNLOADABLE state. + * Shows progress via notifications and state flow. + */ + suspend fun downloadModelWithProgress(): Boolean = withContext(Dispatchers.IO) { + try { + val model = getOrCreateModel() + val status = model.checkStatus() + Log.d(TAG, "Download requested. Current status: ${statusToString(status)}") + + if (status == FeatureStatus.AVAILABLE) { + Log.d(TAG, "Model already available, no download needed") + _downloadState.value = DownloadState.Completed + return@withContext true + } + + if (status != FeatureStatus.DOWNLOADABLE && status != FeatureStatus.DOWNLOADING) { + Log.w(TAG, "Cannot download - status is ${statusToString(status)}") + _downloadState.value = + DownloadState.Failed("Model not available for download on this device") + return@withContext false + } + + _downloadState.value = DownloadState.Starting + showDownloadNotification("Starting download...", 0, true) + Log.d(TAG, "Starting Gemini Nano model download...") + + model.download().collect { downloadStatus -> + when (downloadStatus) { + is DownloadStatus.DownloadStarted -> { + Log.d(TAG, "Download started") + _downloadState.value = DownloadState.Starting + showDownloadNotification("Download started...", 0, true) + } + + is DownloadStatus.DownloadProgress -> { + val bytes = downloadStatus.totalBytesDownloaded + val megabytes = bytes / (1024.0 * 1024.0) + Log.d( + TAG, + "Download progress: ${ + String.format( + java.util.Locale.US, + "%.2f", + megabytes + ) + } MB downloaded" + ) + _downloadState.value = DownloadState.InProgress(bytes) + showDownloadNotification( + "Downloading: ${ + String.format( + java.util.Locale.US, + "%.1f", + megabytes + ) + } MB", + -1, // Indeterminate since we don't know total size + true + ) + } + + DownloadStatus.DownloadCompleted -> { + Log.d(TAG, "Download completed successfully!") + _downloadState.value = DownloadState.Completed + showDownloadNotification("Download complete!", 100, false) + // Dismiss notification after a delay + kotlinx.coroutines.delay(2000) + cancelDownloadNotification() + } + + is DownloadStatus.DownloadFailed -> { + Log.e(TAG, "Download failed: $downloadStatus") + _downloadState.value = DownloadState.Failed("Download failed") + showDownloadNotification("Download failed", 0, false) + } + } + } + + currentStatus = model.checkStatus() + Log.d(TAG, "Post-download status: ${statusToString(currentStatus)}") + currentStatus == FeatureStatus.AVAILABLE + } catch (e: Exception) { + Log.e(TAG, "Failed to download Gemini Nano model", e) + _downloadState.value = DownloadState.Failed(e.message ?: "Download failed") + showDownloadNotification("Download failed: ${e.message}", 0, false) + false + } + } + + private fun showDownloadNotification(message: String, progress: Int, ongoing: Boolean) { + // Check notification permission on Android 13+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) { + Log.d(TAG, "No notification permission, skipping notification") + return + } + } + + val builder = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle("Gemini Nano AI Model") + .setContentText(message) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setOngoing(ongoing) + .setAutoCancel(!ongoing) + + when { + progress == 100 -> { + // Completed - remove progress bar + builder.setProgress(0, 0, false) + } + + progress < 0 -> { + // Indeterminate progress + builder.setProgress(0, 0, true) + } + + else -> { + // Determinate progress + builder.setProgress(100, progress, false) + } + } + + try { + NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, builder.build()) + } catch (e: SecurityException) { + Log.w(TAG, "Cannot show notification - permission denied", e) + } + } + + private fun cancelDownloadNotification() { + try { + NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID) + } catch (e: Exception) { + Log.w(TAG, "Failed to cancel notification", e) + } + } + + private fun getOrCreateModel(): GenerativeModel { + return generativeModel ?: Generation.getClient().also { + generativeModel = it + Log.d(TAG, "GenerativeModel client created") + } + } + + /** + * Analyze a crash log and return AI-powered insights. + * First checks for cached insight, otherwise calls AI and caches the result. + */ + suspend fun analyzeCrash(crash: CrashLog): InsightResult = withContext(Dispatchers.IO) { + try { + Log.d(TAG, "Starting crash analysis for: ${crash.packageName} (id=${crash.id})") + + // Check for cached insight first + val cachedInsight = crashInsightDao.getInsightForCrash(crash.id) + if (cachedInsight != null) { + Log.d(TAG, "Found cached insight for crash ${crash.id}") + return@withContext InsightResult.Success(cachedInsight.toCrashInsight()) + } + Log.d(TAG, "No cached insight found, proceeding with AI analysis") + + val model = getOrCreateModel() + val status = model.checkStatus() + currentStatus = status + Log.d(TAG, "Model status before analysis: ${statusToString(status)}") + + when (status) { + FeatureStatus.UNAVAILABLE -> { + Log.w(TAG, "Gemini Nano unavailable on this device") + return@withContext InsightResult.Unavailable + } + + FeatureStatus.DOWNLOADABLE -> { + Log.d(TAG, "Model downloadable - starting background download") + startBackgroundDownload() + return@withContext InsightResult.Downloading + } + + FeatureStatus.DOWNLOADING -> { + Log.d(TAG, "Model currently downloading") + return@withContext InsightResult.Downloading + } + + FeatureStatus.AVAILABLE -> { + Log.d(TAG, "Model available - proceeding with analysis") + } + } + + val prompt = buildPrompt(crash) + Log.d(TAG, "Prompt built, length: ${prompt.length} chars") + + val request = generateContentRequest(TextPart(prompt)) { + temperature = 0.2f + topK = 16 + } + Log.d(TAG, "Request created with temperature=0.2, topK=16") + + Log.d(TAG, "Calling generateContent...") + val startTime = System.currentTimeMillis() + val response = model.generateContent(request) + val elapsed = System.currentTimeMillis() - startTime + Log.d(TAG, "generateContent completed in ${elapsed}ms") + + val text = response.candidates.firstOrNull()?.text + if (text == null) { + Log.w(TAG, "Response has no text. Candidates: ${response.candidates.size}") + return@withContext InsightResult.Error("Empty response from AI") + } + + Log.d(TAG, "AI Response received (${text.length} chars):\n$text") + val result = parseInsightResponse(text) + + // Cache successful insight + if (result is InsightResult.Success) { + val entity = CrashInsightEntity.fromCrashInsight(crash.id, result.insight) + crashInsightDao.insert(entity) + Log.d(TAG, "Cached insight for crash ${crash.id}") + } + + result + } catch (e: Exception) { + Log.e(TAG, "Failed to analyze crash", e) + InsightResult.Error(e.message ?: "Failed to analyze crash") + } + } + + /** + * Get cached insight for a crash if available. + */ + suspend fun getCachedInsight(crashId: Long): CrashInsight? = withContext(Dispatchers.IO) { + crashInsightDao.getInsightForCrash(crashId)?.toCrashInsight() + } + + /** + * Check if a crash has a cached insight. + */ + suspend fun hasCachedInsight(crashId: Long): Boolean = withContext(Dispatchers.IO) { + crashInsightDao.getInsightForCrash(crashId) != null + } + + private fun buildPrompt(crash: CrashLog): String { + val stackTrace = crash.content.take(1500) // Limit to avoid token limits + + return """ +Analyze this Android crash log and provide insights. + +Crash Type: ${crash.tag.displayName} +App: ${crash.appName ?: crash.packageName ?: "Unknown"} +Package: ${crash.packageName ?: "Unknown"} + +Stack Trace: +$stackTrace + +Respond in this exact format: + +SUMMARY: [One sentence explaining what happened] + +ROOT_CAUSE: [The specific cause of the crash] + +SUGGESTED_FIX: [How to fix this issue] + +AFFECTED_LINE: [The key line from the stack trace, or N/A] +""".trimIndent() + } + + private fun parseInsightResponse(response: String): InsightResult { + try { + val summary = extractSection(response, "SUMMARY:") ?: "Unable to determine summary" + val rootCause = + extractSection(response, "ROOT_CAUSE:") ?: "Unable to determine root cause" + val suggestedFix = extractSection(response, "SUGGESTED_FIX:") ?: "Unable to suggest fix" + val affectedLine = extractSection(response, "AFFECTED_LINE:")?.takeIf { + it.isNotBlank() && !it.equals("N/A", ignoreCase = true) + } + + Log.d(TAG, "Parsed insight - Summary: ${summary.take(50)}...") + return InsightResult.Success( + CrashInsight( + summary = summary.trim(), + rootCause = rootCause.trim(), + suggestedFix = suggestedFix.trim(), + affectedLine = affectedLine?.trim() + ) + ) + } catch (e: Exception) { + Log.e(TAG, "Failed to parse AI response", e) + return InsightResult.Error("Failed to parse AI response") + } + } + + private fun extractSection(text: String, marker: String): String? { + val startIndex = text.indexOf(marker, ignoreCase = true) + if (startIndex == -1) return null + + val contentStart = startIndex + marker.length + val markers = listOf("SUMMARY:", "ROOT_CAUSE:", "SUGGESTED_FIX:", "AFFECTED_LINE:") + val nextMarkerIndex = markers + .filter { !it.equals(marker, ignoreCase = true) } + .mapNotNull { nextMarker -> + val idx = text.indexOf(nextMarker, contentStart, ignoreCase = true) + if (idx > 0) idx else null + } + .minOrNull() ?: text.length + + return text.substring(contentStart, nextMarkerIndex).trim() + } +} diff --git a/app/src/main/java/com/devbyjonathan/stacklens/data/local/StackLensDatabase.kt b/app/src/main/java/com/devbyjonathan/stacklens/data/local/StackLensDatabase.kt new file mode 100644 index 0000000..59b7dbc --- /dev/null +++ b/app/src/main/java/com/devbyjonathan/stacklens/data/local/StackLensDatabase.kt @@ -0,0 +1,23 @@ +package com.devbyjonathan.stacklens.data.local + +import androidx.room.Database +import androidx.room.RoomDatabase +import com.devbyjonathan.stacklens.data.local.dao.CrashInsightDao +import com.devbyjonathan.stacklens.data.local.dao.CrashLogDao +import com.devbyjonathan.stacklens.data.local.entity.CrashInsightEntity +import com.devbyjonathan.stacklens.data.local.entity.CrashLogEntity + +@Database( + entities = [CrashLogEntity::class, CrashInsightEntity::class], + version = 2, + exportSchema = false +) +abstract class StackLensDatabase : RoomDatabase() { + + abstract fun crashLogDao(): CrashLogDao + abstract fun crashInsightDao(): CrashInsightDao + + companion object { + const val DATABASE_NAME = "stacklens_db" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/devbyjonathan/stacklens/data/local/dao/CrashInsightDao.kt b/app/src/main/java/com/devbyjonathan/stacklens/data/local/dao/CrashInsightDao.kt new file mode 100644 index 0000000..2d09351 --- /dev/null +++ b/app/src/main/java/com/devbyjonathan/stacklens/data/local/dao/CrashInsightDao.kt @@ -0,0 +1,29 @@ +package com.devbyjonathan.stacklens.data.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.devbyjonathan.stacklens.data.local.entity.CrashInsightEntity + +@Dao +interface CrashInsightDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(insight: CrashInsightEntity) + + @Query("SELECT * FROM crash_insights WHERE crashId = :crashId LIMIT 1") + suspend fun getInsightForCrash(crashId: Long): CrashInsightEntity? + + @Query("DELETE FROM crash_insights WHERE crashId = :crashId") + suspend fun deleteForCrash(crashId: Long) + + @Query("DELETE FROM crash_insights WHERE crashId NOT IN (SELECT id FROM crash_logs)") + suspend fun deleteOrphanedInsights(): Int + + @Query("DELETE FROM crash_insights") + suspend fun deleteAll() + + @Query("SELECT COUNT(*) FROM crash_insights") + suspend fun getCount(): Int +} \ No newline at end of file diff --git a/app/src/main/java/com/devbyjonathan/stacklens/data/local/dao/CrashLogDao.kt b/app/src/main/java/com/devbyjonathan/stacklens/data/local/dao/CrashLogDao.kt new file mode 100644 index 0000000..82eb9bc --- /dev/null +++ b/app/src/main/java/com/devbyjonathan/stacklens/data/local/dao/CrashLogDao.kt @@ -0,0 +1,38 @@ +package com.devbyjonathan.stacklens.data.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.devbyjonathan.stacklens.data.local.entity.CrashLogEntity + +@Dao +interface CrashLogDao { + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertAll(crashes: List) + + @Query("SELECT * FROM crash_logs ORDER BY timestamp DESC") + suspend fun getAllCrashes(): List + + @Query("SELECT * FROM crash_logs WHERE timestamp >= :sinceTimestamp ORDER BY timestamp DESC") + suspend fun getCrashesSince(sinceTimestamp: Long): List + + @Query("SELECT * FROM crash_logs WHERE tag IN (:tags) AND timestamp >= :sinceTimestamp ORDER BY timestamp DESC") + suspend fun getCrashesByTagsSince( + tags: List, + sinceTimestamp: Long, + ): List + + @Query("DELETE FROM crash_logs WHERE timestamp < :olderThanTimestamp") + suspend fun deleteOlderThan(olderThanTimestamp: Long): Int + + @Query("SELECT MAX(timestamp) FROM crash_logs") + suspend fun getLatestTimestamp(): Long? + + @Query("SELECT COUNT(*) FROM crash_logs") + suspend fun getCount(): Int + + @Query("DELETE FROM crash_logs") + suspend fun deleteAll() +} diff --git a/app/src/main/java/com/devbyjonathan/stacklens/data/local/entity/CrashInsightEntity.kt b/app/src/main/java/com/devbyjonathan/stacklens/data/local/entity/CrashInsightEntity.kt new file mode 100644 index 0000000..534010f --- /dev/null +++ b/app/src/main/java/com/devbyjonathan/stacklens/data/local/entity/CrashInsightEntity.kt @@ -0,0 +1,50 @@ +package com.devbyjonathan.stacklens.data.local.entity + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import com.devbyjonathan.stacklens.ai.CrashInsight + +@Entity( + tableName = "crash_insights", + foreignKeys = [ + ForeignKey( + entity = CrashLogEntity::class, + parentColumns = ["id"], + childColumns = ["crashId"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [Index(value = ["crashId"], unique = true)] +) +data class CrashInsightEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val crashId: Long, + val summary: String, + val rootCause: String, + val suggestedFix: String, + val affectedLine: String?, + val createdAt: Long = System.currentTimeMillis(), +) { + fun toCrashInsight(): CrashInsight { + return CrashInsight( + summary = summary, + rootCause = rootCause, + suggestedFix = suggestedFix, + affectedLine = affectedLine + ) + } + + companion object { + fun fromCrashInsight(crashId: Long, insight: CrashInsight): CrashInsightEntity { + return CrashInsightEntity( + crashId = crashId, + summary = insight.summary, + rootCause = insight.rootCause, + suggestedFix = insight.suggestedFix, + affectedLine = insight.affectedLine + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/devbyjonathan/stacklens/data/local/entity/CrashLogEntity.kt b/app/src/main/java/com/devbyjonathan/stacklens/data/local/entity/CrashLogEntity.kt new file mode 100644 index 0000000..727fd82 --- /dev/null +++ b/app/src/main/java/com/devbyjonathan/stacklens/data/local/entity/CrashLogEntity.kt @@ -0,0 +1,46 @@ +package com.devbyjonathan.stacklens.data.local.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.devbyjonathan.stacklens.model.CrashLog +import com.devbyjonathan.stacklens.model.CrashType + +@Entity(tableName = "crash_logs") +data class CrashLogEntity( + @PrimaryKey val id: Long, + val tag: String, + val packageName: String?, + val appName: String?, + val timestamp: Long, + val content: String, + val processName: String?, + val pid: Int?, +) { + fun toCrashLog(): CrashLog { + return CrashLog( + id = id, + tag = CrashType.fromTag(tag) ?: CrashType.DATA_APP_CRASH, + packageName = packageName, + appName = appName, + timestamp = timestamp, + content = content, + processName = processName, + pid = pid + ) + } + + companion object { + fun fromCrashLog(crashLog: CrashLog): CrashLogEntity { + return CrashLogEntity( + id = crashLog.id, + tag = crashLog.tag.tag, + packageName = crashLog.packageName, + appName = crashLog.appName, + timestamp = crashLog.timestamp, + content = crashLog.content, + processName = crashLog.processName, + pid = crashLog.pid + ) + } + } +} diff --git a/app/src/main/java/com/devbyjonathan/stacklens/di/DatabaseModule.kt b/app/src/main/java/com/devbyjonathan/stacklens/di/DatabaseModule.kt new file mode 100644 index 0000000..2248f12 --- /dev/null +++ b/app/src/main/java/com/devbyjonathan/stacklens/di/DatabaseModule.kt @@ -0,0 +1,71 @@ +package com.devbyjonathan.stacklens.di + +import android.content.Context +import androidx.room.Room +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import com.devbyjonathan.stacklens.data.local.StackLensDatabase +import com.devbyjonathan.stacklens.data.local.dao.CrashInsightDao +import com.devbyjonathan.stacklens.data.local.dao.CrashLogDao +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DatabaseModule { + + private val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS crash_insights ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + crashId INTEGER NOT NULL, + summary TEXT NOT NULL, + rootCause TEXT NOT NULL, + suggestedFix TEXT NOT NULL, + affectedLine TEXT, + createdAt INTEGER NOT NULL, + FOREIGN KEY (crashId) REFERENCES crash_logs(id) ON DELETE CASCADE + ) + """.trimIndent() + ) + db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS index_crash_insights_crashId ON crash_insights(crashId)") + } + } + + @Provides + @Singleton + fun provideDatabase(@ApplicationContext context: Context): StackLensDatabase { + return Room.databaseBuilder( + context, + StackLensDatabase::class.java, + StackLensDatabase.DATABASE_NAME + ) + .addMigrations(MIGRATION_1_2) + // Enable foreign key constraints + .addCallback(object : androidx.room.RoomDatabase.Callback() { + override fun onOpen(db: SupportSQLiteDatabase) { + super.onOpen(db) + db.execSQL("PRAGMA foreign_keys = ON") + } + }) + .build() + } + + @Provides + @Singleton + fun provideCrashLogDao(database: StackLensDatabase): CrashLogDao { + return database.crashLogDao() + } + + @Provides + @Singleton + fun provideCrashInsightDao(database: StackLensDatabase): CrashInsightDao { + return database.crashInsightDao() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/devbyjonathan/stacklens/model/CrashGroup.kt b/app/src/main/java/com/devbyjonathan/stacklens/model/CrashGroup.kt new file mode 100644 index 0000000..5c2e721 --- /dev/null +++ b/app/src/main/java/com/devbyjonathan/stacklens/model/CrashGroup.kt @@ -0,0 +1,38 @@ +package com.devbyjonathan.stacklens.model + +/** + * Represents a group of similar crashes identified by the same signature. + * Used to aggregate duplicate/related crashes for easier analysis. + */ +data class CrashGroup( + val signature: String, + val exceptionType: String, + val crashes: List, + val count: Int, + val firstOccurrence: Long, + val lastOccurrence: Long, +) { + /** + * The most recent crash in this group, used as the representative crash. + */ + val latestCrash: CrashLog + get() = crashes.maxByOrNull { it.timestamp } ?: crashes.first() + + /** + * The app name from the latest crash. + */ + val appName: String? + get() = latestCrash.appName + + /** + * The package name from the latest crash. + */ + val packageName: String? + get() = latestCrash.packageName + + /** + * The crash type from the latest crash. + */ + val crashType: CrashType + get() = latestCrash.tag +} diff --git a/app/src/main/java/com/devbyjonathan/stacklens/repository/CrashLogRepository.kt b/app/src/main/java/com/devbyjonathan/stacklens/repository/CrashLogRepository.kt index 09584d3..8d25f6f 100644 --- a/app/src/main/java/com/devbyjonathan/stacklens/repository/CrashLogRepository.kt +++ b/app/src/main/java/com/devbyjonathan/stacklens/repository/CrashLogRepository.kt @@ -1,9 +1,13 @@ package com.devbyjonathan.stacklens.repository +import com.devbyjonathan.stacklens.data.local.dao.CrashLogDao +import com.devbyjonathan.stacklens.data.local.entity.CrashLogEntity import com.devbyjonathan.stacklens.model.CrashFilter +import com.devbyjonathan.stacklens.model.CrashGroup import com.devbyjonathan.stacklens.model.CrashLog import com.devbyjonathan.stacklens.model.CrashType import com.devbyjonathan.stacklens.service.CrashLogReader +import com.devbyjonathan.stacklens.service.CrashSignatureGenerator import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import javax.inject.Inject @@ -11,16 +15,50 @@ import javax.inject.Singleton @Singleton class CrashLogRepository @Inject constructor( - private val crashLogReader: CrashLogReader + private val crashLogReader: CrashLogReader, + private val crashLogDao: CrashLogDao, + private val signatureGenerator: CrashSignatureGenerator, ) { + companion object { + private const val RETENTION_DAYS = 7 + private const val RETENTION_MS = RETENTION_DAYS * 24 * 60 * 60 * 1000L + } suspend fun getCrashLogs(filter: CrashFilter): List { - val logs = crashLogReader.readCrashLogs( - types = filter.types.toList(), - sinceHours = filter.timeRangeHours - ) + // Clean up old entries first + cleanupOldEntries() + + // Read fresh crashes from DropBox + val freshLogs = try { + crashLogReader.readCrashLogs( + types = filter.types.toList(), + sinceHours = filter.timeRangeHours + ) + } catch (e: SecurityException) { + // Permission not granted - return only persisted data + emptyList() + } + + // Persist new crashes to database + if (freshLogs.isNotEmpty()) { + val entities = freshLogs.map { CrashLogEntity.fromCrashLog(it) } + crashLogDao.insertAll(entities) + } + + // Calculate time range for query + val sinceTimestamp = System.currentTimeMillis() - (filter.timeRangeHours * 60 * 60 * 1000L) + val tags = filter.types.map { it.tag } + + // Get persisted crashes within time range + val persistedLogs = crashLogDao.getCrashesByTagsSince(tags, sinceTimestamp) + .map { it.toCrashLog() } - return logs.filter { log -> + // Merge and deduplicate (ID is timestamp-based, so duplicates have same ID) + val allLogs = (freshLogs + persistedLogs) + .distinctBy { it.id } + .sortedByDescending { it.timestamp } + + return allLogs.filter { log -> // Filter by package name if specified val matchesPackage = filter.packageName?.let { log.packageName?.contains(it, ignoreCase = true) == true @@ -45,11 +83,32 @@ class CrashLogRepository @Inject constructor( * Get unique packages that have crashed */ suspend fun getCrashedPackages(): List { - val logs = crashLogReader.readCrashLogs( - types = CrashType.appCrashTags + CrashType.anrTags, - sinceHours = 168 // Last week - ) - return logs.mapNotNull { it.packageName }.distinct().sorted() + cleanupOldEntries() + + val freshLogs = try { + crashLogReader.readCrashLogs( + types = CrashType.appCrashTags + CrashType.anrTags, + sinceHours = 168 // Last week + ) + } catch (e: SecurityException) { + emptyList() + } + + // Persist to database + if (freshLogs.isNotEmpty()) { + val entities = freshLogs.map { CrashLogEntity.fromCrashLog(it) } + crashLogDao.insertAll(entities) + } + + // Get from database + val sinceTimestamp = System.currentTimeMillis() - (RETENTION_DAYS * 24 * 60 * 60 * 1000L) + val tags = (CrashType.appCrashTags + CrashType.anrTags).map { it.tag } + val persistedLogs = crashLogDao.getCrashesByTagsSince(tags, sinceTimestamp) + .map { it.toCrashLog() } + + val allLogs = (freshLogs + persistedLogs).distinctBy { it.id } + + return allLogs.mapNotNull { it.packageName }.distinct().sorted() } /** @@ -59,4 +118,41 @@ class CrashLogRepository @Inject constructor( val logs = crashLogReader.readCrashLogs(sinceHours = sinceHours) return logs.groupBy { it.tag }.mapValues { it.value.size } } + + /** + * Get crash logs grouped by signature. + * Similar crashes are grouped together with occurrence count. + */ + suspend fun getGroupedCrashLogs(filter: CrashFilter): List { + val logs = getCrashLogs(filter) + + return logs + .groupBy { crash -> + signatureGenerator.generateSignature(crash.content, crash.tag) + } + .map { (signature, crashes) -> + val sortedCrashes = crashes.sortedByDescending { it.timestamp } + val exceptionType = signatureGenerator.extractExceptionType( + sortedCrashes.first().content + ) + + CrashGroup( + signature = signature, + exceptionType = exceptionType, + crashes = sortedCrashes, + count = crashes.size, + firstOccurrence = crashes.minOf { it.timestamp }, + lastOccurrence = crashes.maxOf { it.timestamp } + ) + } + .sortedByDescending { it.lastOccurrence } + } + + /** + * Delete crash logs older than retention period (7 days) + */ + private suspend fun cleanupOldEntries() { + val cutoffTimestamp = System.currentTimeMillis() - RETENTION_MS + crashLogDao.deleteOlderThan(cutoffTimestamp) + } } diff --git a/app/src/main/java/com/devbyjonathan/stacklens/screen/detail/CrashDetailScreen.kt b/app/src/main/java/com/devbyjonathan/stacklens/screen/detail/CrashDetailScreen.kt index 8e8f688..066f371 100644 --- a/app/src/main/java/com/devbyjonathan/stacklens/screen/detail/CrashDetailScreen.kt +++ b/app/src/main/java/com/devbyjonathan/stacklens/screen/detail/CrashDetailScreen.kt @@ -1,6 +1,9 @@ package com.devbyjonathan.stacklens.screen.detail import android.content.Intent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.horizontalScroll @@ -15,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer @@ -22,22 +26,29 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.WrapText +import androidx.compose.material.icons.filled.AutoAwesome import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Share import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -49,11 +60,17 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.devbyjonathan.stacklens.ai.CrashInsightService +import com.devbyjonathan.stacklens.ai.DownloadState +import com.devbyjonathan.stacklens.ai.InsightResult import com.devbyjonathan.stacklens.common.CrashTypeBadge import com.devbyjonathan.stacklens.model.CrashLog import com.devbyjonathan.stacklens.model.CrashType import com.devbyjonathan.stacklens.theme.StackLensTheme +import com.devbyjonathan.stacklens.util.StackTraceColors +import com.devbyjonathan.stacklens.util.highlightStackTrace import com.devbyjonathan.uikit.theme.AppTypography +import kotlinx.coroutines.launch import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -62,12 +79,47 @@ import java.util.Locale @Composable fun CrashDetailScreen( crash: CrashLog, - onBackClick: () -> Unit + onBackClick: () -> Unit, + crashInsightService: CrashInsightService? = null, ) { val context = LocalContext.current val clipboardManager = LocalClipboardManager.current val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) } var wrapText by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + var aiAvailable by remember { mutableStateOf(null) } + var insightResult by remember { mutableStateOf(null) } + var showInsight by remember { mutableStateOf(false) } + val downloadState by crashInsightService?.downloadState?.collectAsState() + ?: remember { mutableStateOf(DownloadState.Idle) } + + LaunchedEffect(crashInsightService) { + crashInsightService?.let { + // Check if AI is available or downloadable (show button for both) + val available = it.isAvailable() + val status = it.getStatus() + aiAvailable = + available || status == com.google.mlkit.genai.common.FeatureStatus.DOWNLOADABLE + || status == com.google.mlkit.genai.common.FeatureStatus.DOWNLOADING + + // Check for cached insight and auto-expand if available + val cachedInsight = it.getCachedInsight(crash.id) + if (cachedInsight != null) { + insightResult = InsightResult.Success(cachedInsight) + showInsight = true + } + } + } + + // Auto-retry when download completes + LaunchedEffect(downloadState) { + if (downloadState is DownloadState.Completed && insightResult is InsightResult.Downloading) { + // Download completed, retry the analysis + insightResult = InsightResult.Loading + insightResult = crashInsightService?.analyzeCrash(crash) ?: InsightResult.Unavailable + } + } Scaffold( topBar = { @@ -88,6 +140,32 @@ fun CrashDetailScreen( Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } }, + actions = { + if (aiAvailable == true) { + IconButton( + onClick = { + showInsight = !showInsight + if (showInsight && insightResult == null) { + insightResult = InsightResult.Loading + scope.launch { + insightResult = crashInsightService?.analyzeCrash(crash) + ?: InsightResult.Unavailable + } + } + } + ) { + Icon( + Icons.Default.AutoAwesome, + contentDescription = "AI Insight", + tint = if (showInsight) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } + } + } ) }, bottomBar = { @@ -175,6 +253,25 @@ fun CrashDetailScreen( .padding(padding) .verticalScroll(rememberScrollState()) ) { + // AI Insight Card (collapsible) + AnimatedVisibility( + visible = showInsight, + enter = expandVertically(), + exit = shrinkVertically() + ) { + AiInsightCard( + result = insightResult, + downloadState = downloadState, + onRetry = { + insightResult = InsightResult.Loading + scope.launch { + insightResult = crashInsightService?.analyzeCrash(crash) + ?: InsightResult.Unavailable + } + } + ) + } + // Metadata card Card( modifier = Modifier @@ -208,7 +305,7 @@ fun CrashDetailScreen( Column(modifier = Modifier.padding(16.dp)) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( @@ -231,6 +328,25 @@ fun CrashDetailScreen( } } + // Syntax highlighting colors for stack trace + val stackTraceColors = StackTraceColors( + exception = MaterialTheme.colorScheme.error, + causedBy = MaterialTheme.colorScheme.error.copy(alpha = 0.8f), + atKeyword = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + className = MaterialTheme.colorScheme.onSurfaceVariant, + methodName = MaterialTheme.colorScheme.primary, + lineNumber = MaterialTheme.colorScheme.tertiary, + fileName = MaterialTheme.colorScheme.secondary, + nativeMethod = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + message = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.9f), + default = MaterialTheme.colorScheme.onSurfaceVariant + ) + + val highlightedContent = highlightStackTrace( + content = crash.content, + colors = stackTraceColors + ) + Box( modifier = if (wrapText) { Modifier.fillMaxWidth() @@ -242,11 +358,10 @@ fun CrashDetailScreen( ) { SelectionContainer { Text( - text = crash.content, + text = highlightedContent, style = MaterialTheme.typography.bodySmall.copy( fontFamily = FontFamily.Monospace - ), - color = MaterialTheme.colorScheme.onSurfaceVariant + ) ) } } @@ -258,6 +373,222 @@ fun CrashDetailScreen( } } +@Composable +private fun AiInsightCard( + result: InsightResult?, + downloadState: DownloadState, + onRetry: () -> Unit, +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Default.AutoAwesome, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Text( + text = "AI Insight", + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold + ), + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "(Gemini Nano)", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + when (result) { + is InsightResult.Loading -> { + Column( + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "Analyzing crash...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(4.dp), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.primaryContainer + ) + } + } + + is InsightResult.Downloading -> { + Column( + modifier = Modifier.fillMaxWidth() + ) { + // Show download progress based on state + val progressText = when (downloadState) { + is DownloadState.Idle -> "Preparing download..." + is DownloadState.Starting -> "Starting download..." + is DownloadState.InProgress -> { + val mb = downloadState.bytesDownloaded / (1024.0 * 1024.0) + "Downloading: ${String.format(java.util.Locale.US, "%.1f", mb)} MB" + } + + is DownloadState.Completed -> "Download complete!" + is DownloadState.Failed -> "Download failed: ${downloadState.error}" + } + + Text( + text = progressText, + style = MaterialTheme.typography.bodyMedium, + color = if (downloadState is DownloadState.Failed) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Show progress bar based on state + when (downloadState) { + is DownloadState.Failed -> { + // No progress bar for failed state + } + + is DownloadState.Completed -> { + LinearProgressIndicator( + progress = { 1f }, + modifier = Modifier + .fillMaxWidth() + .height(4.dp), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.primaryContainer + ) + } + + else -> { + // Indeterminate progress for downloading states + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(4.dp), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.primaryContainer + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "This is a one-time download. You can leave the app - download will continue in background.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedButton( + onClick = onRetry, + modifier = Modifier.align(Alignment.End) + ) { + Text(if (downloadState is DownloadState.Failed) "Retry" else "Check again") + } + } + } + + is InsightResult.Success -> { + val insight = result.insight + + InsightSection("Summary", insight.summary) + Spacer(modifier = Modifier.height(12.dp)) + InsightSection("Root Cause", insight.rootCause) + Spacer(modifier = Modifier.height(12.dp)) + InsightSection("Suggested Fix", insight.suggestedFix) + + insight.affectedLine?.let { line -> + Spacer(modifier = Modifier.height(12.dp)) + InsightSection("Affected Line", line, isCode = true) + } + } + + is InsightResult.Error -> { + Column { + Text( + text = result.message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton(onClick = onRetry) { + Text("Retry") + } + } + } + + is InsightResult.Unavailable -> { + Text( + text = "On-device AI is not available on this device. Requires Android 14+ with Gemini Nano support (Pixel 8+, Samsung S24+).", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + null -> { + Text( + text = "Tap to analyze this crash with on-device AI.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + +@Composable +private fun InsightSection( + title: String, + content: String, + isCode: Boolean = false, +) { + Column { + Text( + text = title, + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.SemiBold + ), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = content, + style = if (isCode) { + MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace + ) + } else { + MaterialTheme.typography.bodyMedium + }, + color = MaterialTheme.colorScheme.onSurface + ) + } +} + @Composable fun MetadataRow(label: String, value: String) { Row( diff --git a/app/src/main/java/com/devbyjonathan/stacklens/screen/list/CrashGroupItem.kt b/app/src/main/java/com/devbyjonathan/stacklens/screen/list/CrashGroupItem.kt new file mode 100644 index 0000000..d07e116 --- /dev/null +++ b/app/src/main/java/com/devbyjonathan/stacklens/screen/list/CrashGroupItem.kt @@ -0,0 +1,257 @@ +package com.devbyjonathan.stacklens.screen.list + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.devbyjonathan.stacklens.common.CrashTypeBadge +import com.devbyjonathan.stacklens.model.CrashGroup +import com.devbyjonathan.stacklens.model.CrashLog +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Composable +fun CrashGroupItem( + group: CrashGroup, + isExpanded: Boolean, + onGroupClick: () -> Unit, + onCrashClick: (CrashLog) -> Unit, + modifier: Modifier = Modifier, +) { + val dateFormat = remember { SimpleDateFormat("MMM dd, HH:mm", Locale.getDefault()) } + val (icon, color) = getCrashTypeIconAndColor(group.crashType) + + Column(modifier = modifier.fillMaxWidth()) { + // Group Header + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .background( + color = color.copy(alpha = 0.08f), + shape = RoundedCornerShape(12.dp) + ) + .clickable(onClick = onGroupClick) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Type icon + Surface( + color = color.copy(alpha = 0.15f), + shape = MaterialTheme.shapes.small, + modifier = Modifier.size(44.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = icon, + contentDescription = null, + tint = color, + modifier = Modifier.size(26.dp) + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = group.appName ?: group.packageName ?: "Unknown", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + // Occurrence count badge + Surface( + color = color.copy(alpha = 0.2f), + shape = RoundedCornerShape(12.dp) + ) { + Text( + text = "${group.count}x", + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = color + ) + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Exception type + Text( + text = group.exceptionType, + style = MaterialTheme.typography.bodyMedium, + color = color, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(4.dp)) + + // Package name and time range + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + val packageName = group.packageName + if (group.appName != null && packageName != null) { + Text( + text = packageName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + CrashTypeBadge(type = group.crashType) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + imageVector = if (isExpanded) { + Icons.Default.KeyboardArrowUp + } else { + Icons.Default.KeyboardArrowDown + }, + contentDescription = if (isExpanded) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Time info + Text( + text = "Last: ${dateFormat.format(Date(group.lastOccurrence))}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + // Expanded crashes list + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically(), + exit = shrinkVertically() + ) { + Column( + modifier = Modifier + .padding(start = 32.dp, top = 8.dp) + .fillMaxWidth() + ) { + group.crashes.forEach { crash -> + CrashGroupChildItem( + crash = crash, + color = color, + onClick = { onCrashClick(crash) } + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + } +} + +@Composable +private fun CrashGroupChildItem( + crash: CrashLog, + color: androidx.compose.ui.graphics.Color, + onClick: () -> Unit, +) { + val dateFormat = remember { SimpleDateFormat("MMM dd, HH:mm:ss", Locale.getDefault()) } + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(end = 16.dp) + .background( + color = color.copy(alpha = 0.03f), + shape = RoundedCornerShape(8.dp) + ) + .clickable(onClick = onClick) + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Small indicator + Box( + modifier = Modifier + .size(8.dp) + .background( + color = color.copy(alpha = 0.5f), + shape = RoundedCornerShape(4.dp) + ) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + val preview = crash.content.lines() + .firstOrNull { it.contains("Exception") || it.contains("Error") } + ?: crash.content.lines().firstOrNull() + ?: "" + + Text( + text = preview, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(2.dp)) + + Text( + text = dateFormat.format(Date(crash.timestamp)), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} diff --git a/app/src/main/java/com/devbyjonathan/stacklens/screen/list/CrashLogListScreen.kt b/app/src/main/java/com/devbyjonathan/stacklens/screen/list/CrashLogListScreen.kt index cf5e73c..1e72c9d 100644 --- a/app/src/main/java/com/devbyjonathan/stacklens/screen/list/CrashLogListScreen.kt +++ b/app/src/main/java/com/devbyjonathan/stacklens/screen/list/CrashLogListScreen.kt @@ -87,7 +87,8 @@ fun CrashLogListContent( onCrashClick: (CrashLog) -> Unit, onTimeRangeChange: (Int) -> Unit, onSortOrderChange: (SortOrder) -> Unit, - onTypeFilterChange: (CrashTypeFilter) -> Unit + onTypeFilterChange: (CrashTypeFilter) -> Unit, + onGroupExpand: (String) -> Unit = {}, ) { var searchQuery by remember { mutableStateOf("") } var showDurationSheet by remember { mutableStateOf(false) } @@ -121,7 +122,8 @@ fun CrashLogListContent( }, showSortSheet = { showSortSheet = true - } + }, + onGroupExpand = onGroupExpand ) } @@ -448,6 +450,7 @@ fun CrashLogList( onTypeFilterChange: (CrashTypeFilter) -> Unit, showDurationSheet: () -> Unit, showSortSheet: () -> Unit, + onGroupExpand: (String) -> Unit = {}, ) { LazyColumn( modifier = Modifier.fillMaxSize(), @@ -490,14 +493,21 @@ fun CrashLogList( ErrorMessage(message = uiState.error) } } - uiState.crashLogs.isEmpty() -> { + uiState.crashGroups.isEmpty() -> { item { EmptyState() } } + else -> { - items(uiState.crashLogs, key = { "${it.id}_${it.tag}" }) { crash -> - CrashLogItem(crash = crash, onClick = { onCrashClick(crash) }) + // Grouped view (always enabled) + items(uiState.crashGroups, key = { it.signature }) { group -> + CrashGroupItem( + group = group, + isExpanded = group.signature in uiState.expandedGroups, + onGroupClick = { onGroupExpand(group.signature) }, + onCrashClick = onCrashClick + ) } } } diff --git a/app/src/main/java/com/devbyjonathan/stacklens/screen/list/CrashLogViewModel.kt b/app/src/main/java/com/devbyjonathan/stacklens/screen/list/CrashLogViewModel.kt index 144b647..cb3f5e4 100644 --- a/app/src/main/java/com/devbyjonathan/stacklens/screen/list/CrashLogViewModel.kt +++ b/app/src/main/java/com/devbyjonathan/stacklens/screen/list/CrashLogViewModel.kt @@ -4,6 +4,7 @@ import android.app.Application import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.devbyjonathan.stacklens.model.CrashFilter +import com.devbyjonathan.stacklens.model.CrashGroup import com.devbyjonathan.stacklens.model.CrashLog import com.devbyjonathan.stacklens.model.CrashType import com.devbyjonathan.stacklens.model.CrashTypeFilter @@ -74,9 +75,21 @@ class CrashLogViewModel @Inject constructor( SortOrder.OLDEST_FIRST -> logs.sortedBy { it.timestamp } } + // Load grouped crashes (always enabled) + val groups = repository.getGroupedCrashLogs(filter).let { allGroups -> + // Apply type filter to groups + when (filter.typeFilter) { + CrashTypeFilter.ALL -> allGroups + CrashTypeFilter.CRASHES -> allGroups.filter { it.crashType in CrashType.appCrashTags } + CrashTypeFilter.ANRS -> allGroups.filter { it.crashType in CrashType.anrTags } + CrashTypeFilter.NATIVE -> allGroups.filter { it.crashType in CrashType.nativeTags } + } + } + _uiState.value = _uiState.value.copy( isLoading = false, crashLogs = logs, + crashGroups = groups, stats = stats ) } catch (e: SecurityException) { @@ -139,6 +152,16 @@ class CrashLogViewModel @Inject constructor( fun refresh() { loadCrashLogs() } + + fun toggleGroupExpansion(signature: String) { + val currentExpanded = _uiState.value.expandedGroups + val newExpanded = if (signature in currentExpanded) { + currentExpanded - signature + } else { + currentExpanded + signature + } + _uiState.value = _uiState.value.copy(expandedGroups = newExpanded) + } } data class CrashLogUiState( @@ -148,7 +171,9 @@ data class CrashLogUiState( val hasUsageStatsPermission: Boolean = false, val hasDropBoxDataPermission: Boolean = false, val crashLogs: List = emptyList(), + val crashGroups: List = emptyList(), + val expandedGroups: Set = emptySet(), val stats: Map = emptyMap(), val filter: CrashFilter = CrashFilter(), - val error: String? = null + val error: String? = null, ) diff --git a/app/src/main/java/com/devbyjonathan/stacklens/screen/main/HomeScreen.kt b/app/src/main/java/com/devbyjonathan/stacklens/screen/main/HomeScreen.kt index 03058ec..13f3391 100644 --- a/app/src/main/java/com/devbyjonathan/stacklens/screen/main/HomeScreen.kt +++ b/app/src/main/java/com/devbyjonathan/stacklens/screen/main/HomeScreen.kt @@ -71,10 +71,11 @@ fun HomeScreen( onTimeRangeChange: (Int) -> Unit, onSortOrderChange: (SortOrder) -> Unit, onTypeFilterChange: (CrashTypeFilter) -> Unit, + onGroupExpand: (String) -> Unit, onThemeChange: (ThemeMode) -> Unit, onDynamicColorChange: (Boolean) -> Unit, onTermsClick: () -> Unit, - onPrivacyClick: () -> Unit + onPrivacyClick: () -> Unit, ) { val navItems = listOf( BottomNavItem( @@ -179,7 +180,8 @@ fun HomeScreen( onCrashClick = onCrashClick, onTimeRangeChange = onTimeRangeChange, onSortOrderChange = onSortOrderChange, - onTypeFilterChange = onTypeFilterChange + onTypeFilterChange = onTypeFilterChange, + onGroupExpand = onGroupExpand ) 1 -> SettingsScreen( modifier = Modifier.padding(padding), @@ -209,6 +211,7 @@ fun HomeScreenPreview() { onTimeRangeChange = {}, onSortOrderChange = {}, onTypeFilterChange = {}, + onGroupExpand = {}, onThemeChange = {}, onDynamicColorChange = {}, onTermsClick = {}, @@ -231,6 +234,7 @@ fun HomeScreenDarkPreview() { onTimeRangeChange = {}, onSortOrderChange = {}, onTypeFilterChange = {}, + onGroupExpand = {}, onThemeChange = {}, onDynamicColorChange = {}, onTermsClick = {}, diff --git a/app/src/main/java/com/devbyjonathan/stacklens/screen/main/MainActivity.kt b/app/src/main/java/com/devbyjonathan/stacklens/screen/main/MainActivity.kt index 3ab42dc..5aaed99 100644 --- a/app/src/main/java/com/devbyjonathan/stacklens/screen/main/MainActivity.kt +++ b/app/src/main/java/com/devbyjonathan/stacklens/screen/main/MainActivity.kt @@ -20,6 +20,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import com.devbyjonathan.stacklens.ai.CrashInsightService import com.devbyjonathan.stacklens.navigation.Screen import com.devbyjonathan.stacklens.screen.detail.CrashDetailScreen import com.devbyjonathan.stacklens.screen.list.CrashLogViewModel @@ -39,6 +40,9 @@ class MainActivity : ComponentActivity() { @Inject lateinit var themeManager: ThemeManager + @Inject + lateinit var crashInsightService: CrashInsightService + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) installSplashScreen() @@ -148,6 +152,7 @@ class MainActivity : ComponentActivity() { onTimeRangeChange = { vm.setTimeRange(it) }, onSortOrderChange = { vm.setSortOrder(it) }, onTypeFilterChange = { vm.setTypeFilter(it) }, + onGroupExpand = { vm.toggleGroupExpansion(it) }, onThemeChange = { themeManager.setThemeMode(it) }, onDynamicColorChange = { themeManager.setDynamicColorEnabled(it) }, onTermsClick = { navController.navigate(Screen.Terms.route) }, @@ -159,7 +164,8 @@ class MainActivity : ComponentActivity() { selectedCrash?.let { crash -> CrashDetailScreen( crash = crash, - onBackClick = { navController.popBackStack() } + onBackClick = { navController.popBackStack() }, + crashInsightService = crashInsightService ) } } diff --git a/app/src/main/java/com/devbyjonathan/stacklens/service/CrashSignatureGenerator.kt b/app/src/main/java/com/devbyjonathan/stacklens/service/CrashSignatureGenerator.kt new file mode 100644 index 0000000..a8613ab --- /dev/null +++ b/app/src/main/java/com/devbyjonathan/stacklens/service/CrashSignatureGenerator.kt @@ -0,0 +1,90 @@ +package com.devbyjonathan.stacklens.service + +import com.devbyjonathan.stacklens.model.CrashType +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Generates unique signatures for crash logs to enable grouping of similar crashes. + * Signature format: "{ExceptionType}:{CrashTag}:{topFramesHash}" + */ +@Singleton +class CrashSignatureGenerator @Inject constructor() { + + /** + * Generate a signature from crash content for grouping similar crashes. + * Crashes with the same signature are considered duplicates/related. + */ + fun generateSignature(content: String, crashType: CrashType): String { + val exceptionType = extractExceptionType(content) + val topFrames = extractTopStackFrames(content, count = 3) + val framesHash = if (topFrames.isNotEmpty()) { + topFrames.joinToString("|").hashCode().toString(16) + } else { + content.take(200).hashCode().toString(16) + } + return "${exceptionType}:${crashType.tag}:$framesHash" + } + + /** + * Extract the exception type from crash content. + * Returns the exception class name (e.g., "NullPointerException", "IllegalStateException") + */ + fun extractExceptionType(content: String, crashType: CrashType? = null): String { + // Try various patterns to extract exception type + val patterns = listOf( + // Standard Java exception: "java.lang.NullPointerException: message" + Regex("([\\w.]*(?:Exception|Error))(?::|\\s|$)"), + // Caused by pattern: "Caused by: java.lang.NullPointerException" + Regex("Caused by:\\s*([\\w.]*(?:Exception|Error))"), + // FATAL EXCEPTION pattern + Regex("FATAL EXCEPTION:.*\\n.*?([\\w.]*(?:Exception|Error))"), + // ANR pattern + Regex("ANR in ([\\w.]+)"), + // Native crash signal + Regex("signal\\s+(\\d+)\\s+\\(([A-Z]+)\\)") + ) + + for (pattern in patterns) { + val match = pattern.find(content) + if (match != null) { + val exceptionType = match.groupValues.getOrNull(1) ?: match.groupValues[0] + // Return just the class name without package + return exceptionType.substringAfterLast('.') + } + } + + // Fallback based on crash type + return when (crashType) { + CrashType.DATA_APP_ANR, CrashType.SYSTEM_APP_ANR -> "ANR" + CrashType.SYSTEM_TOMBSTONE -> "NativeCrash" + null -> "UnknownException" + else -> "UnknownException" + } + } + + /** + * Extract the top N stack frames from the crash content. + * These are used to create a unique hash for grouping. + */ + private fun extractTopStackFrames(content: String, count: Int): List { + // Match stack frame patterns like "at com.example.Class.method(File.java:123)" + val framePattern = Regex("\\s+at\\s+([\\w.$<>]+\\([^)]*\\))") + + return framePattern.findAll(content) + .map { match -> + // Normalize the frame by removing line numbers for better grouping + val frame = match.groupValues[1] + frame.replace(Regex(":\\d+\\)"), ")") + } + .filter { frame -> + // Filter out common framework frames that don't help with grouping + !frame.startsWith("android.") && + !frame.startsWith("java.lang.reflect.") && + !frame.startsWith("dalvik.") && + !frame.startsWith("com.android.internal.") + } + .take(count) + .toList() + } +} diff --git a/app/src/main/java/com/devbyjonathan/stacklens/util/StackTraceHighlighter.kt b/app/src/main/java/com/devbyjonathan/stacklens/util/StackTraceHighlighter.kt new file mode 100644 index 0000000..343defc --- /dev/null +++ b/app/src/main/java/com/devbyjonathan/stacklens/util/StackTraceHighlighter.kt @@ -0,0 +1,241 @@ +package com.devbyjonathan.stacklens.util + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle + +/** + * Syntax highlighting colors for stack traces. + */ +data class StackTraceColors( + val exception: Color, // Exception class names (e.g., NullPointerException) + val causedBy: Color, // "Caused by:" prefix + val atKeyword: Color, // "at" keyword + val className: Color, // Class names in stack frames + val methodName: Color, // Method names + val lineNumber: Color, // Line numbers (e.g., :42) + val fileName: Color, // File names (e.g., MainActivity.kt) + val nativeMethod: Color, // (Native Method) or (Unknown Source) + val message: Color, // Exception message + val default: Color, // Default text color +) + +/** + * Highlights a stack trace with syntax coloring. + */ +@Composable +fun highlightStackTrace( + content: String, + colors: StackTraceColors, +): AnnotatedString { + return buildAnnotatedString { + val lines = content.split("\n") + + lines.forEachIndexed { index, line -> + when { + // Exception line: "java.lang.NullPointerException: message" + line.matches(Regex("^[a-zA-Z][\\w.]*Exception.*")) || + line.matches(Regex("^[a-zA-Z][\\w.]*Error.*")) -> { + highlightExceptionLine(line, colors) + } + + // Caused by line + line.trimStart().startsWith("Caused by:") -> { + highlightCausedByLine(line, colors) + } + + // Stack frame: " at com.example.Class.method(File.java:42)" + line.trimStart().startsWith("at ") -> { + highlightStackFrame(line, colors) + } + + // Suppressed exceptions + line.trimStart().startsWith("Suppressed:") -> { + withStyle( + SpanStyle( + color = colors.causedBy, + fontWeight = FontWeight.SemiBold + ) + ) { + append(line) + } + } + + // "... X more" lines + line.trimStart().matches(Regex("^\\.\\.\\. \\d+ more.*")) -> { + withStyle(SpanStyle(color = colors.atKeyword)) { + append(line) + } + } + + // Default + else -> { + withStyle(SpanStyle(color = colors.default)) { + append(line) + } + } + } + + // Add newline except for last line + if (index < lines.size - 1) { + append("\n") + } + } + } +} + +private fun AnnotatedString.Builder.highlightExceptionLine( + line: String, + colors: StackTraceColors, +) { + val colonIndex = line.indexOf(':') + if (colonIndex > 0) { + // Exception class name + withStyle(SpanStyle(color = colors.exception, fontWeight = FontWeight.Bold)) { + append(line.substring(0, colonIndex)) + } + // Colon and message + withStyle(SpanStyle(color = colors.message)) { + append(line.substring(colonIndex)) + } + } else { + // Just exception name without message + withStyle(SpanStyle(color = colors.exception, fontWeight = FontWeight.Bold)) { + append(line) + } + } +} + +private fun AnnotatedString.Builder.highlightCausedByLine( + line: String, + colors: StackTraceColors, +) { + val leadingWhitespace = line.takeWhile { it.isWhitespace() } + val trimmedLine = line.trimStart() + + append(leadingWhitespace) + + // "Caused by:" prefix + val causedByPrefix = "Caused by: " + if (trimmedLine.startsWith(causedByPrefix)) { + withStyle(SpanStyle(color = colors.causedBy, fontWeight = FontWeight.Bold)) { + append(causedByPrefix) + } + // Rest is exception name + message + val rest = trimmedLine.substring(causedByPrefix.length) + val colonIndex = rest.indexOf(':') + if (colonIndex > 0) { + withStyle(SpanStyle(color = colors.exception, fontWeight = FontWeight.Bold)) { + append(rest.substring(0, colonIndex)) + } + withStyle(SpanStyle(color = colors.message)) { + append(rest.substring(colonIndex)) + } + } else { + withStyle(SpanStyle(color = colors.exception, fontWeight = FontWeight.Bold)) { + append(rest) + } + } + } else { + withStyle(SpanStyle(color = colors.causedBy)) { + append(trimmedLine) + } + } +} + +private fun AnnotatedString.Builder.highlightStackFrame( + line: String, + colors: StackTraceColors, +) { + val leadingWhitespace = line.takeWhile { it.isWhitespace() } + val trimmedLine = line.trimStart() + + append(leadingWhitespace) + + // "at " keyword + withStyle(SpanStyle(color = colors.atKeyword)) { + append("at ") + } + + val rest = trimmedLine.substring(3) // Skip "at " + + // Parse: com.example.Class.method(File.java:42) + val parenStart = rest.indexOf('(') + val parenEnd = rest.lastIndexOf(')') + + if (parenStart > 0 && parenEnd > parenStart) { + val methodPart = rest.substring(0, parenStart) + val locationPart = rest.substring(parenStart + 1, parenEnd) + + // Split class.method + val lastDot = methodPart.lastIndexOf('.') + if (lastDot > 0) { + val className = methodPart.substring(0, lastDot) + val methodName = methodPart.substring(lastDot + 1) + + // Class name + withStyle(SpanStyle(color = colors.className)) { + append(className) + } + append(".") + // Method name + withStyle(SpanStyle(color = colors.methodName, fontWeight = FontWeight.SemiBold)) { + append(methodName) + } + } else { + withStyle(SpanStyle(color = colors.methodName)) { + append(methodPart) + } + } + + // Opening paren + withStyle(SpanStyle(color = colors.default)) { + append("(") + } + + // Location: File.java:42 or Native Method + if (locationPart == "Native Method" || locationPart == "Unknown Source") { + withStyle(SpanStyle(color = colors.nativeMethod)) { + append(locationPart) + } + } else { + val colonIndex = locationPart.lastIndexOf(':') + if (colonIndex > 0) { + val fileName = locationPart.substring(0, colonIndex) + val lineNumber = locationPart.substring(colonIndex) + + withStyle(SpanStyle(color = colors.fileName)) { + append(fileName) + } + withStyle(SpanStyle(color = colors.lineNumber, fontWeight = FontWeight.Bold)) { + append(lineNumber) + } + } else { + withStyle(SpanStyle(color = colors.fileName)) { + append(locationPart) + } + } + } + + // Closing paren + withStyle(SpanStyle(color = colors.default)) { + append(")") + } + + // Any remaining text after closing paren + if (parenEnd < rest.length - 1) { + withStyle(SpanStyle(color = colors.default)) { + append(rest.substring(parenEnd + 1)) + } + } + } else { + // Couldn't parse, just output as-is + withStyle(SpanStyle(color = colors.className)) { + append(rest) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d55f237..7dffd11 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,6 +20,10 @@ lifecycleRuntimeKtx = "2.8.7" material = "1.12.0" navigation = "2.8.9" splashScreen = "1.0.1" +work = "2.10.0" +hiltWork = "1.2.0" +room = "2.6.1" +mlkit-genai = "1.0.0-alpha1" [libraries] @@ -52,6 +56,19 @@ hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", ve # Navigation navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" } +# WorkManager +work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "work" } +hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltWork" } +hilt-work-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltWork" } + +# Room +room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } +room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } +room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } + +# ML Kit GenAI (Gemini Nano on-device) +mlkit-genai-prompt = { module = "com.google.mlkit:genai-prompt", version.ref = "mlkit-genai" } + gson = { module = "com.google.code.gson:gson", version.ref = "gson" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } junit = { group = "junit", name = "junit", version.ref = "junit" }