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
8 changes: 8 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ dependencies {
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material3.adaptive.navigation.suite)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
Expand All @@ -61,12 +62,19 @@ dependencies {
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.room.ktx)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.hilt.work)
implementation(libs.coil.compose)
implementation(libs.hilt.android)
implementation(libs.mlkit.text.recognition)
implementation(libs.mlkit.text.recognition.korean)
implementation(libs.play.services.oss.licenses)
implementation(libs.timber)
ksp(libs.androidx.hilt.compiler)
ksp(libs.androidx.room.compiler)
ksp(libs.hilt.compiler)
ksp(libs.kotlin.metadata.jvm)
testImplementation(libs.junit)
Expand Down
12 changes: 11 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
Expand All @@ -17,6 +18,15 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.RECAP">
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
tools:node="remove" />
</provider>

<activity
android:name=".MainActivity"
android:exported="true"
Expand Down
12 changes: 11 additions & 1 deletion app/src/main/java/com/chalkak/recap/RecapApplication.kt
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
package com.chalkak.recap

import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import dagger.hilt.android.HiltAndroidApp
import timber.log.Timber
import javax.inject.Inject

@HiltAndroidApp
class RecapApplication : Application() {
class RecapApplication : Application(), Configuration.Provider {
@Inject lateinit var workerFactory: HiltWorkerFactory

override fun onCreate() {
super.onCreate()

if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
}
}
Comment on lines 14 to 20

override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
}
12 changes: 2 additions & 10 deletions app/src/main/java/com/chalkak/recap/app/RecapNavHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.ui.NavDisplay
import com.chalkak.recap.feature.collection.CollectionScreen
import com.chalkak.recap.feature.home.HomeAction
import com.chalkak.recap.feature.home.HomeScreen
import com.chalkak.recap.feature.home.HomeRoute
import com.chalkak.recap.feature.mypage.MyPageScreen

@Composable
Expand All @@ -24,14 +23,7 @@ fun RecapNavHost(
entryProvider = { route ->
when (route) {
RecapRoute.Home -> NavEntry(route) {
HomeScreen(
onAction = { action ->
when (action) {
HomeAction.StartImport -> Unit
HomeAction.EnterDeveloperOptions -> onNavigateToDeveloper()
}
},
)
HomeRoute(onNavigateToDeveloper = onNavigateToDeveloper)
}

RecapRoute.Collection -> NavEntry(route) {
Expand Down
30 changes: 30 additions & 0 deletions app/src/main/java/com/chalkak/recap/core/data/DatabaseModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.chalkak.recap.core.data

import android.content.Context
import androidx.room.Room
import com.chalkak.recap.core.data.ocr.OcrDao
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 {
@Provides
@Singleton
fun provideRecapDatabase(
@ApplicationContext context: Context,
): RecapDatabase {
return Room.databaseBuilder(
context,
RecapDatabase::class.java,
"recap.db",
).build()
}

@Provides
fun provideOcrDao(database: RecapDatabase): OcrDao = database.ocrDao()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package com.chalkak.recap.core.data

import android.Manifest
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import androidx.core.content.ContextCompat
import com.chalkak.recap.core.model.ImageAccessLevel
import com.chalkak.recap.core.model.LocalImage
import com.chalkak.recap.core.model.OcrCleanupRange
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class LocalScreenshotDataSource @Inject constructor(
@param:ApplicationContext private val context: Context,
) {
fun imagePermissionRequest(): Array<String> {
return when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> arrayOf(
Manifest.permission.READ_MEDIA_IMAGES,
Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED,
)

Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> arrayOf(
Manifest.permission.READ_MEDIA_IMAGES,
)

else -> arrayOf(
Manifest.permission.READ_EXTERNAL_STORAGE,
)
}
}

fun currentImageAccessLevel(): ImageAccessLevel {
return when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
context.hasPermission(Manifest.permission.READ_MEDIA_IMAGES) -> ImageAccessLevel.Full

Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE &&
context.hasPermission(Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED) -> ImageAccessLevel.Selected

Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU &&
context.hasPermission(Manifest.permission.READ_EXTERNAL_STORAGE) -> ImageAccessLevel.Full

else -> ImageAccessLevel.Denied
}
}
Comment on lines +42 to +55

suspend fun countScreenshots(range: OcrCleanupRange): Int = withContext(Dispatchers.IO) {
if (currentImageAccessLevel() == ImageAccessLevel.Denied) {
0
} else {
queryScreenshots(range).size
}
}

suspend fun queryRecentScreenshots(limit: Int): List<LocalImage> = withContext(Dispatchers.IO) {
queryScreenshotImages(
cutoffSeconds = null,
limit = limit,
)
}

suspend fun queryScreenshots(range: OcrCleanupRange): List<LocalImage> = withContext(Dispatchers.IO) {
val cutoffSeconds = (System.currentTimeMillis() / MILLIS_PER_SECOND) - (range.days * SECONDS_PER_DAY)
queryScreenshotImages(
cutoffSeconds = cutoffSeconds,
limit = null,
)
}

private fun queryScreenshotImages(
cutoffSeconds: Long?,
limit: Int?,
): List<LocalImage> {
if (currentImageAccessLevel() == ImageAccessLevel.Denied) {
return emptyList()
}

val collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
val projection = arrayOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.DATE_ADDED,
)
val pathSelection = screenshotRelativePaths.joinToString(separator = " OR ") {
"${MediaStore.Images.Media.RELATIVE_PATH} = ?"
}
val selection = if (cutoffSeconds == null) {
"($pathSelection)"
} else {
"($pathSelection) AND ${MediaStore.Images.Media.DATE_ADDED} >= ?"
}
val selectionArgs = if (cutoffSeconds == null) {
screenshotRelativePaths
} else {
screenshotRelativePaths + cutoffSeconds.toString()
}
val queryArgs = Bundle().apply {
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, selection)
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, selectionArgs.toTypedArray())
putStringArray(
ContentResolver.QUERY_ARG_SORT_COLUMNS,
arrayOf(MediaStore.Images.Media.DATE_ADDED),
)
putInt(
ContentResolver.QUERY_ARG_SORT_DIRECTION,
ContentResolver.QUERY_SORT_DIRECTION_DESCENDING,
)
if (limit != null) {
putInt(ContentResolver.QUERY_ARG_LIMIT, limit)
}
}

return runCatching {
context.contentResolver.query(collection, projection, queryArgs, null)?.use { cursor ->
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
val displayNameColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)
val dateAddedColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED)
buildList {
while (cursor.moveToNext()) {
val id = cursor.getLong(idColumn)
add(
LocalImage(
uri = ContentUris.withAppendedId(collection, id).toString(),
displayName = cursor.getString(displayNameColumn).orEmpty()
.ifBlank { "screenshot-$id" },
dateAddedMillis = cursor.getLong(dateAddedColumn) * MILLIS_PER_SECOND,
),
)
}
}
}.orEmpty()
}.getOrDefault(emptyList())
}

private fun Context.hasPermission(permission: String): Boolean {
return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
}

private companion object {
const val MILLIS_PER_SECOND = 1_000L
const val SECONDS_PER_DAY = 86_400L

val screenshotRelativePaths = listOf(
"DCIM/Screenshots/",
)
Comment on lines +153 to +155

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include non-DCIM screenshot folders

On devices that store screenshots in Pictures/Screenshots/ instead of DCIM/Screenshots/, this exact RELATIVE_PATH filter makes countScreenshots() and queryScreenshots() return empty lists even after full photo permission. That leaves onboarding stuck on the empty/disabled cleanup path for users who do have screenshots, so include the other common screenshot location or detect screenshot buckets more broadly.

Useful? React with 👍 / 👎.

}
}
19 changes: 19 additions & 0 deletions app/src/main/java/com/chalkak/recap/core/data/RecapDatabase.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.chalkak.recap.core.data

import androidx.room.Database
import androidx.room.RoomDatabase
import com.chalkak.recap.core.data.ocr.OcrDao
import com.chalkak.recap.core.data.ocr.OcrJobEntity
import com.chalkak.recap.core.data.ocr.OcrResultEntity

@Database(
entities = [
OcrJobEntity::class,
OcrResultEntity::class,
],
version = 1,
exportSchema = false,
)
abstract class RecapDatabase : RoomDatabase() {
abstract fun ocrDao(): OcrDao
}
20 changes: 20 additions & 0 deletions app/src/main/java/com/chalkak/recap/core/data/WorkManagerModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.chalkak.recap.core.data

import android.content.Context
import androidx.work.WorkManager
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 WorkManagerModule {
@Provides
@Singleton
fun provideWorkManager(
@ApplicationContext context: Context,
): WorkManager = WorkManager.getInstance(context)
}
Loading