diff --git a/app/build.gradle.kts b/app/build.gradle.kts index db4a9c8c..1bbd6a14 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,12 +9,14 @@ plugins { alias(libs.plugins.kotlin.serialization) alias(libs.plugins.ksp) alias(libs.plugins.navigation.safeargs) - alias(libs.plugins.androidx.room) + alias(libs.plugins.androidx.room3) alias(libs.plugins.detekt.plugin) // Rust Android Gradle plugin - COMMENTED OUT due to Gradle 9.2 incompatibility // Use manual Rust build script instead (see rust-c2pa-ffi/build-android.sh) // id("org.mozilla.rust-android-gradle.rust-android") version "0.9.4" // Google Services plugins applied conditionally at bottom of file for GMS builds only + + alias(libs.plugins.koin.compiler) } fun loadLocalProperties(): Properties = Properties().apply { @@ -189,7 +191,7 @@ base { archivesName.set("save-${project.version}") } -room { +room3 { schemaDirectory("$projectDir/schemas") } @@ -268,9 +270,8 @@ dependencies { implementation(libs.androidx.work) // Room Database - implementation(libs.androidx.room.runtime) - implementation(libs.androidx.room.ktx) - ksp(libs.androidx.room.compiler) + implementation(libs.androidx.room3.runtime) + ksp(libs.androidx.room3.compiler) // Dependency Injection - Koin implementation(libs.koin.core) @@ -281,6 +282,8 @@ dependencies { implementation(libs.koin.compose.viewmodel) implementation(libs.koin.compose.viewmodel.navigation) + implementation(libs.koin.annotations) + // Networking implementation(libs.okhttp) implementation(libs.okhttp.logging) diff --git a/app/schemas/net.opendasharchive.openarchive.db.AppDatabase/3.json b/app/schemas/net.opendasharchive.openarchive.db.AppDatabase/3.json new file mode 100644 index 00000000..2e0a9b3d --- /dev/null +++ b/app/schemas/net.opendasharchive.openarchive.db.AppDatabase/3.json @@ -0,0 +1,597 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "7c435845a0996d16ffad32c0f0af9dc7", + "entities": [ + { + "tableName": "vaults", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `host` TEXT NOT NULL, `metaData` TEXT NOT NULL, `licenseUrl` TEXT, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "metaData", + "columnName": "metaData", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "licenseUrl", + "columnName": "licenseUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "archives", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `description` TEXT, `createdAt` INTEGER, `vaultId` INTEGER NOT NULL, `archived` INTEGER NOT NULL, `openSubmissionId` INTEGER NOT NULL, `licenseUrl` TEXT, `isRemote` INTEGER NOT NULL, FOREIGN KEY(`vaultId`) REFERENCES `vaults`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "vaultId", + "columnName": "vaultId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "archived", + "columnName": "archived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "openSubmissionId", + "columnName": "openSubmissionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "licenseUrl", + "columnName": "licenseUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "isRemote", + "columnName": "isRemote", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_archives_vaultId", + "unique": false, + "columnNames": [ + "vaultId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_archives_vaultId` ON `${TABLE_NAME}` (`vaultId`)" + } + ], + "foreignKeys": [ + { + "table": "vaults", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "vaultId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "submissions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `archiveId` INTEGER NOT NULL, `uploadedAt` INTEGER, `serverUrl` TEXT, FOREIGN KEY(`archiveId`) REFERENCES `archives`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "archiveId", + "columnName": "archiveId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uploadedAt", + "columnName": "uploadedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverUrl", + "columnName": "serverUrl", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_submissions_archiveId", + "unique": false, + "columnNames": [ + "archiveId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_submissions_archiveId` ON `${TABLE_NAME}` (`archiveId`)" + } + ], + "foreignKeys": [ + { + "table": "archives", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "archiveId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "evidence", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `originalFilePath` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `createdAt` INTEGER, `updatedAt` INTEGER, `uploadedAt` INTEGER, `serverUrl` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `author` TEXT NOT NULL, `location` TEXT NOT NULL, `tags` TEXT NOT NULL, `licenseUrl` TEXT, `mediaHashString` TEXT NOT NULL, `status` INTEGER NOT NULL, `statusMessage` TEXT NOT NULL, `archiveId` INTEGER NOT NULL, `submissionId` INTEGER NOT NULL, `contentLength` INTEGER NOT NULL, `progress` INTEGER NOT NULL, `flag` INTEGER NOT NULL, `priority` INTEGER NOT NULL, `thumbnail` BLOB, FOREIGN KEY(`archiveId`) REFERENCES `archives`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`submissionId`) REFERENCES `submissions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originalFilePath", + "columnName": "originalFilePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadedAt", + "columnName": "uploadedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverUrl", + "columnName": "serverUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "licenseUrl", + "columnName": "licenseUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaHashString", + "columnName": "mediaHashString", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusMessage", + "columnName": "statusMessage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "archiveId", + "columnName": "archiveId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "submissionId", + "columnName": "submissionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flag", + "columnName": "flag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnail", + "columnName": "thumbnail", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_evidence_archiveId", + "unique": false, + "columnNames": [ + "archiveId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_evidence_archiveId` ON `${TABLE_NAME}` (`archiveId`)" + }, + { + "name": "index_evidence_submissionId", + "unique": false, + "columnNames": [ + "submissionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_evidence_submissionId` ON `${TABLE_NAME}` (`submissionId`)" + }, + { + "name": "index_evidence_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_evidence_status` ON `${TABLE_NAME}` (`status`)" + }, + { + "name": "index_evidence_priority", + "unique": false, + "columnNames": [ + "priority" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_evidence_priority` ON `${TABLE_NAME}` (`priority`)" + }, + { + "name": "index_evidence_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_evidence_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "archives", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "archiveId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "submissions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "submissionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "migration_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `stage` TEXT NOT NULL, `processedCount` INTEGER NOT NULL, `totalCount` INTEGER NOT NULL, `completedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stage", + "columnName": "stage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "processedCount", + "columnName": "processedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "completedAt", + "columnName": "completedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "vault_dweb_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`vaultId` INTEGER NOT NULL, `vaultKey` TEXT NOT NULL, PRIMARY KEY(`vaultId`), FOREIGN KEY(`vaultId`) REFERENCES `vaults`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "vaultId", + "columnName": "vaultId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "vaultKey", + "columnName": "vaultKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "vaultId" + ] + }, + "foreignKeys": [ + { + "table": "vaults", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "vaultId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "archive_dweb_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`archiveId` INTEGER NOT NULL, `archiveKey` TEXT NOT NULL, `archiveHash` TEXT NOT NULL, `permissions` TEXT NOT NULL, PRIMARY KEY(`archiveId`), FOREIGN KEY(`archiveId`) REFERENCES `archives`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "archiveId", + "columnName": "archiveId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "archiveKey", + "columnName": "archiveKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "archiveHash", + "columnName": "archiveHash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "archiveId" + ] + }, + "foreignKeys": [ + { + "table": "archives", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "archiveId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "evidence_dweb_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`evidenceId` INTEGER NOT NULL, `isDownloaded` INTEGER NOT NULL, PRIMARY KEY(`evidenceId`), FOREIGN KEY(`evidenceId`) REFERENCES `evidence`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "evidenceId", + "columnName": "evidenceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDownloaded", + "columnName": "isDownloaded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "evidenceId" + ] + }, + "foreignKeys": [ + { + "table": "evidence", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "evidenceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7c435845a0996d16ffad32c0f0af9dc7')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/di/DatabaseModule.kt b/app/src/main/java/net/opendasharchive/openarchive/core/di/DatabaseModule.kt index 95569798..fa58740b 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/core/di/DatabaseModule.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/core/di/DatabaseModule.kt @@ -1,6 +1,6 @@ package net.opendasharchive.openarchive.core.di -import androidx.room.Room +import androidx.room3.Room import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.preferencesDataStoreFile import kotlinx.coroutines.Dispatchers diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/domain/Evidence.kt b/app/src/main/java/net/opendasharchive/openarchive/core/domain/Evidence.kt index b4fd6dad..83120696 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/core/domain/Evidence.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/core/domain/Evidence.kt @@ -15,6 +15,7 @@ import java.io.File data class Evidence( val id: Long = 0L, val originalFilePath: String = "", + val thumbnail: ByteArray? = null, val mimeType: String = "", val createdAt: LocalDateTime? = null, val updatedAt: LocalDateTime? = null, diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/domain/mappers/Mappers.kt b/app/src/main/java/net/opendasharchive/openarchive/core/domain/mappers/Mappers.kt index 5d69a81f..0ea87305 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/core/domain/mappers/Mappers.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/core/domain/mappers/Mappers.kt @@ -106,6 +106,7 @@ fun Submission.toSubmissionEntity(): SubmissionEntity = SubmissionEntity( fun EvidenceEntity.toDomain(vaultId: Long = 0L): Evidence = Evidence( id = this.id, originalFilePath = this.originalFilePath, + thumbnail = this.thumbnail, mimeType = this.mimeType, createdAt = this.createdAt, updatedAt = this.updatedAt, @@ -157,7 +158,8 @@ fun Evidence.toEvidenceEntity(): EvidenceEntity = EvidenceEntity( contentLength = this.contentLength, progress = this.progress, flag = this.isFlagged, - priority = this.priority + priority = this.priority, + thumbnail = this.thumbnail ) fun Evidence.toDwebEntity(): EvidenceDwebEntity = EvidenceDwebEntity( diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/domain/mappers/SugarMappers.kt b/app/src/main/java/net/opendasharchive/openarchive/core/domain/mappers/SugarMappers.kt index 97ef8902..b9d616a2 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/core/domain/mappers/SugarMappers.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/core/domain/mappers/SugarMappers.kt @@ -104,6 +104,7 @@ fun Submission.toEntity(): SugarCollection { fun Media.toDomain(): Evidence = Evidence( id = this.id ?: 0L, originalFilePath = this.originalFilePath, + thumbnail = null, mimeType = this.mimeType, createdAt = this.createDate?.toKotlinLocalDateTime(), updatedAt = this.updateDate?.toKotlinLocalDateTime(), diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/presentation/media/MediaThumbnail.kt b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/media/MediaThumbnail.kt index b60f7779..32ced48e 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/core/presentation/media/MediaThumbnail.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/media/MediaThumbnail.kt @@ -50,6 +50,9 @@ fun MediaThumbnail( onTitleVisibilityChanged: ((Boolean) -> Unit)? = null ) { val context = LocalContext.current + val storedThumbnail = remember(evidence.thumbnail) { + evidence.thumbnail?.takeIf { it.isNotEmpty() } + } val imageExists = remember(evidence.originalFilePath) { runCatching { evidence.file.exists() }.getOrDefault(false) } @@ -98,6 +101,31 @@ fun MediaThumbnail( onTitleVisibilityChanged?.invoke(false) } + storedThumbnail != null -> { + SubcomposeAsyncImage( + model = ImageRequest.Builder(context) + .data(storedThumbnail) + .error( + when { + evidence.mimeType.startsWith("video") -> R.drawable.ic_video + evidence.mimeType == "application/pdf" -> R.drawable.ic_pdf + evidence.mimeType.startsWith("audio") -> R.drawable.ic_music + evidence.mimeType.startsWith("image") -> R.drawable.ic_image + else -> R.drawable.ic_unknown_file + } + ) + .build(), + contentDescription = null, + contentScale = contentScale, + modifier = Modifier + .fillMaxSize() + .alpha(alpha) + ) { + SubcomposeAsyncImageContent() + } + onTitleVisibilityChanged?.invoke(false) + } + evidence.mimeType.startsWith("video") -> { MediaPlaceholderIcon( drawableRes = R.drawable.ic_video, diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/repositories/FileCleanupHelper.kt b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/FileCleanupHelper.kt index e91f504a..833d61ae 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/core/repositories/FileCleanupHelper.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/FileCleanupHelper.kt @@ -12,51 +12,55 @@ import java.io.File */ class FileCleanupHelper(private val context: Context) { + /** + * Deletes local upload artifacts after a successful upload while keeping the DB record. + */ + fun deleteUploadedMediaFiles(evidence: Evidence) { + deleteInternalMediaFile(evidence) + deleteC2paSidecar(evidence.mediaHashString) + } + /** * Deletes physical files associated with an Evidence domain object. */ fun deleteMediaFiles(evidence: Evidence) { - // Delete original media file - if (evidence.originalFilePath.isNotEmpty()) { - try { - val file = evidence.file - if (isInternalFile(file)) { - if (file.exists() && file.delete()) { - AppLogger.i("Deleted internal media file: ${file.path}") - } - } - } catch (e: Exception) { - AppLogger.e("Failed to delete media file for ${evidence.id}", e) - } - } - - // Delete C2PA sidecar manifest - if (evidence.mediaHashString.isNotEmpty()) { - C2paHelper.removeC2paFiles(context, evidence.mediaHashString) - } + deleteUploadedMediaFiles(evidence) } /** * Deletes physical files associated with a legacy Media Sugar entity. */ fun deleteMediaFiles(media: Media) { - // Delete original media file if (media.originalFilePath.isNotEmpty()) { try { val file = media.file - if (isInternalFile(file)) { - if (file.exists() && file.delete()) { - AppLogger.i("Deleted internal media file: ${file.path}") - } + if (isInternalFile(file) && file.exists() && file.delete()) { + AppLogger.i("Deleted internal media file: ${file.path}") } } catch (e: Exception) { AppLogger.e("Failed to delete legacy media file for ${media.id}", e) } } - // Delete C2PA sidecar manifest - if (media.mediaHashString.isNotEmpty()) { - C2paHelper.removeC2paFiles(context, media.mediaHashString) + deleteC2paSidecar(media.mediaHashString) + } + + private fun deleteInternalMediaFile(evidence: Evidence) { + if (evidence.originalFilePath.isNotEmpty()) { + try { + val file = evidence.file + if (isInternalFile(file) && file.exists() && file.delete()) { + AppLogger.i("Deleted internal media file: ${file.path}") + } + } catch (e: Exception) { + AppLogger.e("Failed to delete media file for ${evidence.id}", e) + } + } + } + + private fun deleteC2paSidecar(mediaHashString: String) { + if (mediaHashString.isNotEmpty()) { + C2paHelper.removeC2paFiles(context, mediaHashString) } } diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/AppDatabase.kt b/app/src/main/java/net/opendasharchive/openarchive/db/AppDatabase.kt index b447729f..42ed582c 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/db/AppDatabase.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/db/AppDatabase.kt @@ -1,12 +1,14 @@ package net.opendasharchive.openarchive.db -import androidx.room.Database -import androidx.room.RoomDatabase -import androidx.room.TypeConverters -import androidx.room.AutoMigration -import androidx.room.DeleteColumn -import androidx.room.migration.AutoMigrationSpec +import android.annotation.SuppressLint +import androidx.room3.Database +import androidx.room3.RoomDatabase +import androidx.room3.TypeConverters +import androidx.room3.AutoMigration +import androidx.room3.DeleteColumn +import androidx.room3.migration.AutoMigrationSpec +@SuppressLint("RestrictedApi") @Database( entities = [ VaultEntity::class, @@ -23,9 +25,13 @@ import androidx.room.migration.AutoMigrationSpec from = 1, to = 2, spec = RemoveVaultPasswordColumnMigration::class + ), + AutoMigration( + from = 2, + to = 3 ) ], - version = 2, + version = 3, exportSchema = true ) @TypeConverters(Converters::class) @@ -39,4 +45,4 @@ abstract class AppDatabase : RoomDatabase() { } @DeleteColumn(tableName = "vaults", columnName = "password") -class RemoveVaultPasswordColumnMigration : AutoMigrationSpec \ No newline at end of file +class RemoveVaultPasswordColumnMigration : AutoMigrationSpec diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/ArchiveDao.kt b/app/src/main/java/net/opendasharchive/openarchive/db/ArchiveDao.kt index 1ce01031..45431a02 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/db/ArchiveDao.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/db/ArchiveDao.kt @@ -1,6 +1,6 @@ package net.opendasharchive.openarchive.db -import androidx.room.* +import androidx.room3.* import kotlinx.coroutines.flow.Flow @Dao diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/ArchiveDwebEntity.kt b/app/src/main/java/net/opendasharchive/openarchive/db/ArchiveDwebEntity.kt index f58906f6..b0a06672 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/db/ArchiveDwebEntity.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/db/ArchiveDwebEntity.kt @@ -1,8 +1,8 @@ package net.opendasharchive.openarchive.db -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.PrimaryKey +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.PrimaryKey import net.opendasharchive.openarchive.core.domain.ArchivePermission @Entity( diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/ArchiveEntity.kt b/app/src/main/java/net/opendasharchive/openarchive/db/ArchiveEntity.kt index aa7098a2..ac16f9cc 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/db/ArchiveEntity.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/db/ArchiveEntity.kt @@ -1,9 +1,9 @@ package net.opendasharchive.openarchive.db -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.Index -import androidx.room.PrimaryKey +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.Index +import androidx.room3.PrimaryKey import kotlinx.datetime.LocalDateTime @Entity( diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/Converters.kt b/app/src/main/java/net/opendasharchive/openarchive/db/Converters.kt index 8c479b39..315d543f 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/db/Converters.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/db/Converters.kt @@ -1,6 +1,6 @@ package net.opendasharchive.openarchive.db -import androidx.room.TypeConverter +import androidx.room3.TypeConverter import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toInstant diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/DwebCompositeEntities.kt b/app/src/main/java/net/opendasharchive/openarchive/db/DwebCompositeEntities.kt index 20a4cb78..c427a3d3 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/db/DwebCompositeEntities.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/db/DwebCompositeEntities.kt @@ -1,7 +1,7 @@ package net.opendasharchive.openarchive.db -import androidx.room.Embedded -import androidx.room.Relation +import androidx.room3.Embedded +import androidx.room3.Relation data class VaultWithDweb( @Embedded val vault: VaultEntity, diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/DwebDao.kt b/app/src/main/java/net/opendasharchive/openarchive/db/DwebDao.kt index ed07080d..b0392344 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/db/DwebDao.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/db/DwebDao.kt @@ -1,6 +1,6 @@ package net.opendasharchive.openarchive.db -import androidx.room.* +import androidx.room3.* import kotlinx.coroutines.flow.Flow import kotlinx.datetime.LocalDateTime diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/EvidenceDao.kt b/app/src/main/java/net/opendasharchive/openarchive/db/EvidenceDao.kt index dfe2e47f..2b91d91e 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/db/EvidenceDao.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/db/EvidenceDao.kt @@ -1,6 +1,6 @@ package net.opendasharchive.openarchive.db -import androidx.room.* +import androidx.room3.* import kotlinx.coroutines.flow.Flow import net.opendasharchive.openarchive.core.domain.EvidenceStatus diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/EvidenceDwebEntity.kt b/app/src/main/java/net/opendasharchive/openarchive/db/EvidenceDwebEntity.kt index babe4058..e24cc1a9 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/db/EvidenceDwebEntity.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/db/EvidenceDwebEntity.kt @@ -1,8 +1,8 @@ package net.opendasharchive.openarchive.db -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.PrimaryKey +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.PrimaryKey @Entity( tableName = "evidence_dweb_metadata", diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/EvidenceEntity.kt b/app/src/main/java/net/opendasharchive/openarchive/db/EvidenceEntity.kt index b274cf69..e592a5c2 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/db/EvidenceEntity.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/db/EvidenceEntity.kt @@ -1,9 +1,9 @@ package net.opendasharchive.openarchive.db -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.Index -import androidx.room.PrimaryKey +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.Index +import androidx.room3.PrimaryKey import kotlinx.datetime.LocalDateTime import net.opendasharchive.openarchive.core.domain.EvidenceStatus diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/MigrationDao.kt b/app/src/main/java/net/opendasharchive/openarchive/db/MigrationDao.kt index 033fb5b1..275f1854 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/db/MigrationDao.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/db/MigrationDao.kt @@ -1,6 +1,6 @@ package net.opendasharchive.openarchive.db -import androidx.room.* +import androidx.room3.* @Dao interface MigrationDao { diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/MigrationStateEntity.kt b/app/src/main/java/net/opendasharchive/openarchive/db/MigrationStateEntity.kt index 7bb1a0b8..57239c58 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/db/MigrationStateEntity.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/db/MigrationStateEntity.kt @@ -1,7 +1,7 @@ package net.opendasharchive.openarchive.db -import androidx.room.Entity -import androidx.room.PrimaryKey +import androidx.room3.Entity +import androidx.room3.PrimaryKey @Entity(tableName = "migration_state") data class MigrationStateEntity( diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/SubmissionDao.kt b/app/src/main/java/net/opendasharchive/openarchive/db/SubmissionDao.kt index 3763426d..785658b6 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/db/SubmissionDao.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/db/SubmissionDao.kt @@ -1,11 +1,11 @@ package net.opendasharchive.openarchive.db -import androidx.room.Dao -import androidx.room.Delete -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import androidx.room.Upsert +import androidx.room3.Dao +import androidx.room3.Delete +import androidx.room3.Insert +import androidx.room3.OnConflictStrategy +import androidx.room3.Query +import androidx.room3.Upsert import kotlinx.coroutines.flow.Flow @Dao diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/SubmissionEntity.kt b/app/src/main/java/net/opendasharchive/openarchive/db/SubmissionEntity.kt index dde29956..021463ad 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/db/SubmissionEntity.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/db/SubmissionEntity.kt @@ -1,9 +1,9 @@ package net.opendasharchive.openarchive.db -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.Index -import androidx.room.PrimaryKey +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.Index +import androidx.room3.PrimaryKey import kotlinx.datetime.LocalDateTime @Entity( diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/VaultDao.kt b/app/src/main/java/net/opendasharchive/openarchive/db/VaultDao.kt index 99d0de88..cd019d91 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/db/VaultDao.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/db/VaultDao.kt @@ -1,6 +1,6 @@ package net.opendasharchive.openarchive.db -import androidx.room.* +import androidx.room3.* import kotlinx.coroutines.flow.Flow @Dao diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/VaultDwebEntity.kt b/app/src/main/java/net/opendasharchive/openarchive/db/VaultDwebEntity.kt index 5be8196a..3ec729f7 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/db/VaultDwebEntity.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/db/VaultDwebEntity.kt @@ -1,8 +1,8 @@ package net.opendasharchive.openarchive.db -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.PrimaryKey +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.PrimaryKey @Entity( tableName = "vault_dweb_metadata", diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/VaultEntity.kt b/app/src/main/java/net/opendasharchive/openarchive/db/VaultEntity.kt index bdcfeca3..8b5d800a 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/db/VaultEntity.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/db/VaultEntity.kt @@ -1,7 +1,7 @@ package net.opendasharchive.openarchive.db -import androidx.room.Entity -import androidx.room.PrimaryKey +import androidx.room3.Entity +import androidx.room3.PrimaryKey import kotlinx.datetime.LocalDateTime import net.opendasharchive.openarchive.core.domain.VaultType diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/MediaPicker.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/MediaPicker.kt index 161ad5e1..62d6b781 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/media/MediaPicker.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/MediaPicker.kt @@ -8,6 +8,7 @@ import net.opendasharchive.openarchive.core.domain.Evidence import net.opendasharchive.openarchive.core.logger.AppLogger import net.opendasharchive.openarchive.util.C2paHelper import net.opendasharchive.openarchive.util.DateUtils +import net.opendasharchive.openarchive.util.MediaThumbnailGenerator import net.opendasharchive.openarchive.util.Prefs import net.opendasharchive.openarchive.util.Utility import net.opendasharchive.openarchive.util.toLocalDateTime @@ -106,12 +107,20 @@ object MediaPicker { "" } + val thumbnail = try { + file?.let { MediaThumbnailGenerator.generateThumbnailBytes(it, mimeType) } + } catch (e: Exception) { + AppLogger.e("Failed to generate thumbnail for media", e) + null + } + // Create domain object val evidence = Evidence( archiveId = archive.id, submissionId = submissionId, title = title, originalFilePath = originalFilePath, + thumbnail = thumbnail, mimeType = mimeType, contentLength = contentLength, createdAt = createDate.toLocalDateTime(), diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/data/SnowbirdMappers.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/data/SnowbirdMappers.kt index ebb45d04..5e614f79 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/data/SnowbirdMappers.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/data/SnowbirdMappers.kt @@ -124,6 +124,7 @@ fun EvidenceWithDweb.toDomain(): Evidence { return Evidence( id = evidence.id, originalFilePath = evidence.originalFilePath, + thumbnail = evidence.thumbnail, mimeType = evidence.mimeType, createdAt = evidence.createdAt ?: DateUtils.nowDateTime, updatedAt = evidence.updatedAt ?: DateUtils.nowDateTime, diff --git a/app/src/main/java/net/opendasharchive/openarchive/upload/UploadService.kt b/app/src/main/java/net/opendasharchive/openarchive/upload/UploadService.kt index a5816330..9b6ccf79 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/upload/UploadService.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/upload/UploadService.kt @@ -25,6 +25,7 @@ import net.opendasharchive.openarchive.core.domain.Evidence import net.opendasharchive.openarchive.core.domain.EvidenceStatus import net.opendasharchive.openarchive.core.logger.AppLogger import net.opendasharchive.openarchive.core.repositories.CollectionRepository +import net.opendasharchive.openarchive.core.repositories.FileCleanupHelper import net.opendasharchive.openarchive.core.repositories.MediaRepository import net.opendasharchive.openarchive.core.repositories.ProjectRepository import net.opendasharchive.openarchive.core.repositories.SpaceRepository @@ -44,6 +45,7 @@ class UploadService : JobService() { private val projectRepository: ProjectRepository by inject() private val collectionRepository: CollectionRepository by inject() private val spaceRepository: SpaceRepository by inject() + private val fileCleanupHelper: FileCleanupHelper by inject() companion object { private const val NOTIFICATION_CHANNEL_ID = "oasave_channel_1" @@ -184,6 +186,9 @@ class UploadService : JobService() { AppLogger.i("Started uploading", updatedMedia) val uploadSuccess = upload(updatedMedia) if (uploadSuccess) { + serviceScope.launch { + fileCleanupHelper.deleteUploadedMediaFiles(updatedMedia) + } successCount++ } else { failedCount++ diff --git a/app/src/main/java/net/opendasharchive/openarchive/util/MediaThumbnailGenerator.kt b/app/src/main/java/net/opendasharchive/openarchive/util/MediaThumbnailGenerator.kt new file mode 100644 index 00000000..48c69dde --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/util/MediaThumbnailGenerator.kt @@ -0,0 +1,147 @@ +package net.opendasharchive.openarchive.util + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Matrix +import android.graphics.pdf.PdfRenderer +import android.media.MediaMetadataRetriever +import android.os.ParcelFileDescriptor +import net.opendasharchive.openarchive.core.logger.AppLogger +import java.io.ByteArrayOutputStream +import java.io.File + +object MediaThumbnailGenerator { + private const val MAX_DIMENSION_PX = 128 + private const val JPEG_QUALITY = 40 + + fun generateThumbnailBytes(file: File, mimeType: String): ByteArray? { + if (!file.exists()) return null + + val bitmap = try { + when { + mimeType.startsWith("image/") -> createImageThumbnail(file) + mimeType.startsWith("video/") -> createVideoThumbnail(file) + mimeType == "application/pdf" -> createPdfThumbnail(file) + else -> null + } + } catch (e: Exception) { + AppLogger.w("Failed to generate thumbnail for ${file.name}: ${e.message}") + null + } ?: return null + + return bitmap.useCompressedJpeg() + } + + private fun createImageThumbnail(file: File): Bitmap? { + val bounds = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + BitmapFactory.decodeFile(file.absolutePath, bounds) + + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + + val sampled = BitmapFactory.decodeFile( + file.absolutePath, + BitmapFactory.Options().apply { + inSampleSize = calculateInSampleSize(bounds.outWidth, bounds.outHeight, MAX_DIMENSION_PX) + inPreferredConfig = Bitmap.Config.RGB_565 + } + ) ?: return null + + return sampled.scaleDown(MAX_DIMENSION_PX) + } + + private fun createVideoThumbnail(file: File): Bitmap? { + val retriever = MediaMetadataRetriever() + return try { + retriever.setDataSource(file.absolutePath) + val frame = retriever.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC) + frame?.scaleDown(MAX_DIMENSION_PX) + } finally { + runCatching { retriever.release() } + } + } + + private fun createPdfThumbnail(file: File): Bitmap? { + val descriptor = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) + return try { + PdfRenderer(descriptor).use { renderer -> + if (renderer.pageCount == 0) return null + + renderer.openPage(0).use { page -> + val scale = minOf( + MAX_DIMENSION_PX.toFloat() / page.width, + MAX_DIMENSION_PX.toFloat() / page.height + ).coerceAtLeast(0.1f) + + val bitmap = Bitmap.createBitmap( + (page.width * scale).toInt().coerceAtLeast(1), + (page.height * scale).toInt().coerceAtLeast(1), + Bitmap.Config.ARGB_8888 + ) + + Canvas(bitmap).drawColor(Color.WHITE) + val matrix = Matrix().apply { postScale(scale, scale) } + + page.render( + bitmap, + null, + matrix, + PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY + ) + + bitmap + } + } + } finally { + descriptor.close() + } + } + + private fun Bitmap.scaleDown(maxDimensionPx: Int): Bitmap { + if (width <= maxDimensionPx && height <= maxDimensionPx) return this + + val scale = minOf( + maxDimensionPx.toFloat() / width, + maxDimensionPx.toFloat() / height + ) + val scaled = Bitmap.createScaledBitmap( + this, + (width * scale).toInt().coerceAtLeast(1), + (height * scale).toInt().coerceAtLeast(1), + true + ) + + if (scaled != this) { + recycle() + } + return scaled + } + + private fun Bitmap.useCompressedJpeg(): ByteArray? { + return try { + ByteArrayOutputStream().use { output -> + compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, output) + output.toByteArray() + } + } finally { + recycle() + } + } + + private fun calculateInSampleSize(width: Int, height: Int, maxDimensionPx: Int): Int { + var inSampleSize = 1 + var currentWidth = width + var currentHeight = height + + while (currentWidth / 2 >= maxDimensionPx || currentHeight / 2 >= maxDimensionPx) { + inSampleSize *= 2 + currentWidth /= 2 + currentHeight /= 2 + } + + return inSampleSize.coerceAtLeast(1) + } +} diff --git a/app/src/test/java/net/opendasharchive/openarchive/core/repositories/FileCleanupHelperTest.kt b/app/src/test/java/net/opendasharchive/openarchive/core/repositories/FileCleanupHelperTest.kt new file mode 100644 index 00000000..5ea46b32 --- /dev/null +++ b/app/src/test/java/net/opendasharchive/openarchive/core/repositories/FileCleanupHelperTest.kt @@ -0,0 +1,36 @@ +package net.opendasharchive.openarchive.core.repositories + +import android.app.Application +import android.net.Uri +import net.opendasharchive.openarchive.core.domain.Evidence +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class FileCleanupHelperTest { + + @Test + fun `deleteUploadedMediaFiles removes internal file and keeps db-backed thumbnail untouched`() { + val context = RuntimeEnvironment.getApplication() + val file = File(context.filesDir, "cleanup-uploaded-file.jpg").apply { + writeBytes(byteArrayOf(1, 2, 3)) + } + val evidence = Evidence( + originalFilePath = Uri.fromFile(file).toString(), + thumbnail = byteArrayOf(9, 8, 7), + mediaHashString = "" + ) + + FileCleanupHelper(context).deleteUploadedMediaFiles(evidence) + + assertFalse(file.exists()) + assertTrue(evidence.thumbnail!!.isNotEmpty()) + } +} diff --git a/app/src/test/java/net/opendasharchive/openarchive/util/MediaThumbnailGeneratorTest.kt b/app/src/test/java/net/opendasharchive/openarchive/util/MediaThumbnailGeneratorTest.kt new file mode 100644 index 00000000..fad1c75c --- /dev/null +++ b/app/src/test/java/net/opendasharchive/openarchive/util/MediaThumbnailGeneratorTest.kt @@ -0,0 +1,44 @@ +package net.opendasharchive.openarchive.util + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(application = android.app.Application::class) +class MediaThumbnailGeneratorTest { + + @Test + fun `generateThumbnailBytes creates a small jpeg thumbnail for image files`() { + val source = File.createTempFile("thumbnail-source", ".jpg") + source.outputStream().use { output -> + Bitmap.createBitmap(1200, 800, Bitmap.Config.ARGB_8888).apply { + eraseColor(android.graphics.Color.RED) + compress(Bitmap.CompressFormat.JPEG, 95, output) + recycle() + } + } + + val thumbnailBytes = MediaThumbnailGenerator.generateThumbnailBytes( + file = source, + mimeType = "image/jpeg" + ) + + assertNotNull(thumbnailBytes) + assertTrue(thumbnailBytes!!.size in 1 until 20_000) + + val bitmap = BitmapFactory.decodeByteArray(thumbnailBytes, 0, thumbnailBytes.size) + assertNotNull(bitmap) + assertTrue(bitmap!!.width <= 128) + assertTrue(bitmap.height <= 128) + + bitmap.recycle() + source.delete() + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 76bef054..aec05d1e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,7 +10,7 @@ plugins { // Build Tools alias(libs.plugins.ksp) apply false alias(libs.plugins.navigation.safeargs) apply false - alias(libs.plugins.androidx.room) apply false + alias(libs.plugins.androidx.room3) apply false // Code Quality alias(libs.plugins.detekt.plugin) apply false diff --git a/config/baseline.xml b/config/baseline.xml index d39e5777..f2f0eada 100644 --- a/config/baseline.xml +++ b/config/baseline.xml @@ -862,9 +862,9 @@ ImportOrdering:CameraScreen.kt$import android.content.Context import android.net.Uri import android.util.Size import androidx.camera.compose.CameraXViewfinder import androidx.camera.core.Camera import androidx.camera.core.CameraSelector import androidx.camera.core.ImageCapture import androidx.camera.core.Preview import androidx.camera.core.SurfaceRequest import androidx.camera.core.resolutionselector.ResolutionSelector import androidx.camera.core.resolutionselector.ResolutionStrategy import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.video.Quality import androidx.camera.video.QualitySelector import androidx.camera.video.Recorder import androidx.camera.video.VideoCapture import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.displayCutoutPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf 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.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat import androidx.lifecycle.viewmodel.compose.viewModel import com.google.common.util.concurrent.ListenableFuture import kotlinx.coroutines.delay import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.core.logger.AppLogger import net.opendasharchive.openarchive.util.ComposePermissionManager import java.util.concurrent.ExecutorService import java.util.concurrent.Executors ImportOrdering:ContentPickerLauncher.kt$import android.app.Activity import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable 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.platform.LocalContext import androidx.core.content.FileProvider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import net.opendasharchive.openarchive.core.domain.Archive import net.opendasharchive.openarchive.core.domain.Evidence import net.opendasharchive.openarchive.core.logger.AppLogger import net.opendasharchive.openarchive.core.repositories.ProjectRepository import net.opendasharchive.openarchive.core.repositories.MediaRepository import net.opendasharchive.openarchive.features.main.ui.AppRoute import net.opendasharchive.openarchive.features.main.ui.Navigator import org.koin.compose.koinInject import net.opendasharchive.openarchive.features.media.camera.CameraActivity import net.opendasharchive.openarchive.features.media.camera.CameraConfig import net.opendasharchive.openarchive.util.Prefs import net.opendasharchive.openarchive.util.Utility import java.io.File import kotlin.coroutines.cancellation.CancellationException ImportOrdering:ContentPickerSheet.kt$import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.core.presentation.theme.MontserratFontFamily import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme - ImportOrdering:Converters.kt$import androidx.room.TypeConverter import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime import net.opendasharchive.openarchive.core.domain.VaultType import net.opendasharchive.openarchive.core.domain.EvidenceStatus import kotlin.time.Instant + ImportOrdering:Converters.kt$import androidx.room3.TypeConverter import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime import net.opendasharchive.openarchive.core.domain.VaultType import net.opendasharchive.openarchive.core.domain.EvidenceStatus import kotlin.time.Instant ImportOrdering:CreateNewFolderViewModel.kt$import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.core.domain.Archive import net.opendasharchive.openarchive.core.logger.AppLogger import net.opendasharchive.openarchive.features.core.UiText import net.opendasharchive.openarchive.core.repositories.ProjectRepository import net.opendasharchive.openarchive.core.repositories.SpaceRepository import net.opendasharchive.openarchive.features.core.UiImage import net.opendasharchive.openarchive.features.core.dialog.ButtonData import net.opendasharchive.openarchive.features.core.dialog.DialogConfig import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager import net.opendasharchive.openarchive.features.core.dialog.DialogType import net.opendasharchive.openarchive.features.core.dialog.showSuccessDialog import net.opendasharchive.openarchive.features.main.ui.Navigator import net.opendasharchive.openarchive.util.DateUtils - ImportOrdering:DatabaseModule.kt$import androidx.room.Room import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.preferencesDataStoreFile import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asExecutor import net.opendasharchive.openarchive.core.logger.AppLogger import net.opendasharchive.openarchive.db.AppDatabase import net.opendasharchive.openarchive.core.repositories.* import org.koin.android.ext.koin.androidApplication import org.koin.android.ext.koin.androidContext import org.koin.core.qualifier.named import org.koin.dsl.module + ImportOrdering:DatabaseModule.kt$import androidx.room3.Room import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.preferencesDataStoreFile import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asExecutor import net.opendasharchive.openarchive.core.logger.AppLogger import net.opendasharchive.openarchive.db.AppDatabase import net.opendasharchive.openarchive.core.repositories.* import org.koin.android.ext.koin.androidApplication import org.koin.android.ext.koin.androidContext import org.koin.core.qualifier.named import org.koin.dsl.module ImportOrdering:DateUtils.kt$import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toInstant import kotlinx.datetime.toJavaLocalDateTime import kotlinx.datetime.toKotlinLocalDateTime import java.time.OffsetDateTime import java.util.Date import java.util.Locale import java.time.format.DateTimeFormatter import kotlin.time.Clock import kotlin.time.Instant ImportOrdering:Evidence.kt$import kotlinx.datetime.LocalDateTime import kotlinx.serialization.Serializable import android.net.Uri import androidx.core.net.toUri import androidx.core.net.toFile import java.io.File ImportOrdering:FeaturesModule.kt$import android.app.Application import android.content.ContentResolver import net.opendasharchive.openarchive.features.main.ui.HomeViewModel import net.opendasharchive.openarchive.features.main.ui.MainMediaViewModel import net.opendasharchive.openarchive.features.media.PreviewMediaViewModel import net.opendasharchive.openarchive.features.media.ReviewMediaViewModel import net.opendasharchive.openarchive.features.spaces.SpaceListViewModel import net.opendasharchive.openarchive.features.spaces.SpaceSetupViewModel import net.opendasharchive.openarchive.upload.JobSchedulerUploadJobScheduler import net.opendasharchive.openarchive.upload.UploadJobScheduler import net.opendasharchive.openarchive.upload.UploadManagerViewModel import net.opendasharchive.openarchive.features.settings.ProofModeSettingsViewModel import org.koin.android.ext.koin.androidApplication import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module @@ -2391,7 +2391,7 @@ NoUnusedImports:View.kt$net.opendasharchive.openarchive.util.extensions.View.kt NoUnusedImports:WebDavLoginScreen.kt$net.opendasharchive.openarchive.services.webdav.presentation.login.WebDavLoginScreen.kt NoUnusedImports:WebDavLoginViewModel.kt$net.opendasharchive.openarchive.services.webdav.presentation.login.WebDavLoginViewModel.kt - NoWildcardImports:ArchiveDao.kt$import androidx.room.* + NoWildcardImports:ArchiveDao.kt$import androidx.room3.* NoWildcardImports:ArchiveRepositoryImpl.kt$import kotlinx.coroutines.flow.* NoWildcardImports:BadgeDrawable.kt$import android.graphics.* NoWildcardImports:CameraActivity.kt$import androidx.compose.runtime.* @@ -2407,10 +2407,10 @@ NoWildcardImports:CreativeCommonsLicenseContent.kt$import androidx.compose.material3.* NoWildcardImports:CreativeCommonsLicenseContent.kt$import androidx.compose.runtime.* NoWildcardImports:DatabaseModule.kt$import net.opendasharchive.openarchive.core.repositories.* - NoWildcardImports:DwebDao.kt$import androidx.room.* + NoWildcardImports:DwebDao.kt$import androidx.room3.* NoWildcardImports:DwebRepositoryImpl.kt$import kotlinx.coroutines.flow.* NoWildcardImports:DwebRepositoryImpl.kt$import net.opendasharchive.openarchive.core.domain.mappers.* - NoWildcardImports:EvidenceDao.kt$import androidx.room.* + NoWildcardImports:EvidenceDao.kt$import androidx.room3.* NoWildcardImports:EvidenceRepositoryImpl.kt$import kotlinx.coroutines.flow.* NoWildcardImports:FoldersViewModel.kt$import kotlinx.coroutines.flow.* NoWildcardImports:Hbks.kt$import java.security.* @@ -2419,7 +2419,7 @@ NoWildcardImports:IaConduit.kt$import okhttp3.* NoWildcardImports:Mappers.kt$import net.opendasharchive.openarchive.db.* NoWildcardImports:Mappers.kt$import net.opendasharchive.openarchive.util.* - NoWildcardImports:MigrationDao.kt$import androidx.room.* + NoWildcardImports:MigrationDao.kt$import androidx.room3.* NoWildcardImports:MockSnowbirdRepository.kt$import net.opendasharchive.openarchive.db.* NoWildcardImports:MockSnowbirdRepository.kt$import net.opendasharchive.openarchive.services.snowbird.data.* NoWildcardImports:QRScanner.kt$import androidx.compose.animation.core.* @@ -2459,7 +2459,7 @@ NoWildcardImports:SubmissionRepositoryImpl.kt$import kotlinx.coroutines.flow.* NoWildcardImports:SugarMappers.kt$import net.opendasharchive.openarchive.util.* NoWildcardImports:UploadService.kt$import android.app.* - NoWildcardImports:VaultDao.kt$import androidx.room.* + NoWildcardImports:VaultDao.kt$import androidx.room3.* NoWildcardImports:VaultRepositoryImpl.kt$import kotlinx.coroutines.flow.* PackageName:PasscodeEntryActivity.kt$package net.opendasharchive.openarchive.features.settings.passcode.passcode_entry PackageName:PasscodeEntryScreen.kt$package net.opendasharchive.openarchive.features.settings.passcode.passcode_entry @@ -2809,7 +2809,7 @@ UnusedPrivateProperty:SnowbirdGroupListViewModel.kt$SnowbirdGroupListViewModel$route: AppRoute.SnowbirdGroupListRoute UnusedPrivateProperty:WebDavLoginViewModel.kt$WebDavLoginViewModel.Companion$private const val REMOTE_PHP_ADDRESS = "/remote.php/webdav/" UseCheckOrError:ArchiveRepositoryImpl.kt$ArchiveRepositoryImpl$throw IllegalStateException("Project not found") - WildcardImport:ArchiveDao.kt$import androidx.room.* + WildcardImport:ArchiveDao.kt$import androidx.room3.* WildcardImport:ArchiveRepositoryImpl.kt$import kotlinx.coroutines.flow.* WildcardImport:BadgeDrawable.kt$import android.graphics.* WildcardImport:CameraActivity.kt$import androidx.compose.runtime.* @@ -2825,10 +2825,10 @@ WildcardImport:CreativeCommonsLicenseContent.kt$import androidx.compose.material3.* WildcardImport:CreativeCommonsLicenseContent.kt$import androidx.compose.runtime.* WildcardImport:DatabaseModule.kt$import net.opendasharchive.openarchive.core.repositories.* - WildcardImport:DwebDao.kt$import androidx.room.* + WildcardImport:DwebDao.kt$import androidx.room3.* WildcardImport:DwebRepositoryImpl.kt$import kotlinx.coroutines.flow.* WildcardImport:DwebRepositoryImpl.kt$import net.opendasharchive.openarchive.core.domain.mappers.* - WildcardImport:EvidenceDao.kt$import androidx.room.* + WildcardImport:EvidenceDao.kt$import androidx.room3.* WildcardImport:EvidenceRepositoryImpl.kt$import kotlinx.coroutines.flow.* WildcardImport:FoldersViewModel.kt$import kotlinx.coroutines.flow.* WildcardImport:Hbks.kt$import java.security.* @@ -2837,7 +2837,7 @@ WildcardImport:IaConduit.kt$import okhttp3.* WildcardImport:Mappers.kt$import net.opendasharchive.openarchive.db.* WildcardImport:Mappers.kt$import net.opendasharchive.openarchive.util.* - WildcardImport:MigrationDao.kt$import androidx.room.* + WildcardImport:MigrationDao.kt$import androidx.room3.* WildcardImport:MockSnowbirdRepository.kt$import net.opendasharchive.openarchive.db.* WildcardImport:MockSnowbirdRepository.kt$import net.opendasharchive.openarchive.services.snowbird.data.* WildcardImport:QRScanner.kt$import androidx.compose.animation.core.* @@ -2877,7 +2877,7 @@ WildcardImport:SubmissionRepositoryImpl.kt$import kotlinx.coroutines.flow.* WildcardImport:SugarMappers.kt$import net.opendasharchive.openarchive.util.* WildcardImport:UploadService.kt$import android.app.* - WildcardImport:VaultDao.kt$import androidx.room.* + WildcardImport:VaultDao.kt$import androidx.room3.* WildcardImport:VaultRepositoryImpl.kt$import kotlinx.coroutines.flow.* Wrapping:BaseButton.kt$( Wrapping:BaseDialog.kt$( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 97beeb71..eebd2562 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -activity = "1.12.4" +activity = "1.13.0" agp = "9.1.0" androidx-exifinterface = "1.4.2" androidx-security-crypto = "1.1.0" @@ -10,25 +10,25 @@ bitcoinj-core = "0.16.2" bouncycastle-bcpg = "1.71" bouncycastle-bcpkix = "1.72" bouncycastle-bcprov = "1.72" -camerax = "1.6.0-rc01" +camerax = "1.6.0" clean-insights = "2.8.0" coil = "3.4.0" -compose = "1.11.0-alpha06" -compose-preference = "2.1.1" +compose = "1.11.0-beta02" +compose-preference = "2.2.0" compose-stability = "0.7.0" constraintlayout = "2.2.1" constraintlayout-compos = "1.1.1" coordinatorlayout = "1.3.0" -core-ktx = "1.17.0" +core-ktx = "1.18.0" core-splashscreen = "1.2.0" -datastore = "1.3.0-alpha06" +datastore = "1.3.0-alpha07" coroutines = "1.10.2" detekt = "1.23.8" detekt-compose = "0.5.6" detekt-rules-compose = "1.4.0" dotsindicator = "5.1.0" espresso-core = "3.5.1" -firebase-analytics = "23.0.0" +firebase-analytics = "23.2.0" firebase-crashlytics = "20.0.4" fragment = "1.8.9" google-api-client-android = "1.26.0" @@ -48,21 +48,21 @@ jsoup = "1.17.2" jtorctl = "0.4.5.7" junit = "4.13.2" junit-android = "1.3.0" -koin = "4.2.0-RC1" -koin-plugin = "0.3.0" -kotlin = "2.3.20-RC2" +koin = "4.2.0" +koin-plugin = "0.4.1" +kotlin = "2.3.20" kotlinx-collections-immutable = "0.4.0" kotlinx-datetime = "0.7.1" ksp = "2.3.6" lifecycle = "2.10.0" material = "1.13.0" -material3 = "1.5.0-alpha15" +material3 = "1.5.0-alpha16" material-adaptive = "1.2.0" mlkit-barcode = "17.3.0" -media3 = "1.9.2" -mixpanel = "8.3.0" +media3 = "1.9.3" +mixpanel = "8.4.0" navigation = "2.9.7" -navigation3 = "1.1.0-alpha05" +navigation3 = "1.1.0-rc01" navigation-events = "1.1.0-alpha01" accompanist = "0.37.3" netcipher = "2.2.0-alpha" @@ -77,14 +77,14 @@ recyclerview-selection = "1.2.0" reorderable = "3.0.0" retrofit = "3.0.0" robolectric = "4.16.1" -room = "2.8.4" +room = "3.0.0-alpha02" satyan-sugar = "1.5" serialization = "1.10.0" swiperefreshlayout = "1.2.0" timber = "5.0.1" -tor-android = "0.4.9.5" +tor-android = "0.4.9.5.1" viewpager2 = "1.1.0" -work = "2.11.1" +work = "2.11.2" zxing-core = "3.5.4" zxing-android-embedded = "4.3.0" @@ -163,9 +163,9 @@ androidx-datastore-preferences = { group = "androidx.datastore", name = "datasto androidx-work = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } # AndroidX - Room -androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } -androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } -androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +androidx-room3-runtime = { group = "androidx.room3", name = "room3-runtime", version.ref = "room" } +androidx-room3-ktx = { group = "androidx.room3", name = "room3-ktx", version.ref = "room" } +androidx-room3-compiler = { group = "androidx.room3", name = "room3-compiler", version.ref = "room" } # AndroidX - Testing androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junit-android" } @@ -227,6 +227,8 @@ koin-compose-viewmodel = { group = "io.insert-koin", name = "koin-compose-viewmo koin-compose-viewmodel-navigation = { group = "io.insert-koin", name = "koin-compose-viewmodel-navigation", version.ref = "koin" } koin-compose-navigation3 = { group = "io.insert-koin", name = "koin-compose-navigation3", version.ref = "koin" } +koin-annotations = { module = "io.insert-koin:koin-annotations", version.ref = "koin" } + # Kotlin kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" } @@ -272,7 +274,7 @@ tor-android = { group = "info.guardianproject", name = "tor-android", version.re [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } -androidx-room = { id = "androidx.room", version.ref = "room" } +androidx-room3 = { id = "androidx.room3", version.ref = "room" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } detekt-plugin = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } google-firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "google-firebase-crashlytics" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 1f51fe8e..32e52279 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip \ No newline at end of file +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-all.zip \ No newline at end of file