Merge pull request #998 from Droid-ify/room-entity

Room entity
This commit is contained in:
LooKeR 2025-06-08 17:23:47 +05:30 committed by GitHub
commit 249df47bef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
63 changed files with 3169 additions and 169 deletions

View File

@ -7,8 +7,8 @@ trim_trailing_whitespace = true
[*.{kt,kts}] [*.{kt,kts}]
indent_size = 4 indent_size = 4
ij_kotlin_allow_trailing_comma = true ij_kotlin_allow_trailing_comma=true
ij_kotlin_allow_trailing_comma_on_call_site = true ij_kotlin_allow_trailing_comma_on_call_site=true
ij_kotlin_name_count_to_use_star_import = 999 ij_kotlin_name_count_to_use_star_import = 999
ij_kotlin_name_count_to_use_star_import_for_members = 999 ij_kotlin_name_count_to_use_star_import_for_members = 999

View File

@ -20,8 +20,8 @@ android {
applicationId = "com.looker.droidify" applicationId = "com.looker.droidify"
versionCode = 650 versionCode = 650
versionName = latestVersionName versionName = latestVersionName
vectorDrawables.useSupportLibrary = false vectorDrawables.useSupportLibrary = true
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "com.looker.droidify.TestRunner"
} }
compileOptions { compileOptions {
@ -40,15 +40,13 @@ android {
) )
} }
androidResources { ksp {
generateLocaleConfig = true arg("room.schemaLocation", "$projectDir/schemas")
arg("room.generateKotlin", "true")
} }
sourceSets.forEach { source -> androidResources {
val javaDir = source.java.srcDirs.find { it.name == "java" } generateLocaleConfig = true
source.java {
srcDir(File(javaDir?.parentFile, "kotlin"))
}
} }
buildTypes { buildTypes {
@ -130,19 +128,17 @@ dependencies {
implementation(libs.kotlin.stdlib) implementation(libs.kotlin.stdlib)
implementation(libs.datetime) implementation(libs.datetime)
implementation(libs.coroutines.core) implementation(libs.bundles.coroutines)
implementation(libs.coroutines.android)
implementation(libs.coroutines.guava)
implementation(libs.libsu.core) implementation(libs.libsu.core)
implementation(libs.shizuku.api) implementation(libs.bundles.shizuku)
api(libs.shizuku.provider)
implementation(libs.jackson.core) implementation(libs.jackson.core)
implementation(libs.serialization) implementation(libs.serialization)
implementation(libs.ktor.core) implementation(libs.bundles.ktor)
implementation(libs.ktor.okhttp) implementation(libs.bundles.room)
ksp(libs.room.compiler)
implementation(libs.work.ktx) implementation(libs.work.ktx)
@ -155,8 +151,10 @@ dependencies {
testImplementation(platform(libs.junit.bom)) testImplementation(platform(libs.junit.bom))
testImplementation(libs.bundles.test.unit) testImplementation(libs.bundles.test.unit)
testRuntimeOnly(libs.junit.platform) testRuntimeOnly(libs.junit.platform)
androidTestImplementation(platform(libs.junit.bom)) androidTestImplementation(libs.hilt.test)
androidTestImplementation(libs.room.test)
androidTestImplementation(libs.bundles.test.android) androidTestImplementation(libs.bundles.test.android)
kspAndroidTest(libs.hilt.compiler)
// debugImplementation(libs.leakcanary) // debugImplementation(libs.leakcanary)
} }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,116 @@
package com.looker.droidify
import android.content.Context
import com.looker.droidify.data.local.dao.AppDao
import com.looker.droidify.data.local.dao.IndexDao
import com.looker.droidify.datastore.model.SortOrder
import com.looker.droidify.domain.model.Fingerprint
import com.looker.droidify.domain.model.Repo
import com.looker.droidify.domain.model.VersionInfo
import com.looker.droidify.model.Repository
import com.looker.droidify.sync.FakeDownloader
import com.looker.droidify.sync.common.JsonParser
import com.looker.droidify.sync.common.downloadIndex
import com.looker.droidify.sync.v2.model.IndexV2
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import javax.inject.Inject
import kotlin.test.Test
import kotlin.test.assertTrue
@HiltAndroidTest
class RoomTesting {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var indexDao: IndexDao
@Inject
lateinit var appDao: AppDao
@Inject
@ApplicationContext
lateinit var context: Context
private val defaults = Repository.defaultRepositories
private val izzyLegacy = defaults[4]
private val fdroidLegacy = defaults[0]
@Before
fun before() = runTest {
hiltRule.inject()
launch {
val izzy = izzyLegacy.toRepo(1)
val izzyFile = FakeDownloader.downloadIndex(context, izzy, "i2", "index-v2.json")
val izzyIndex =
JsonParser.decodeFromString<IndexV2>(izzyFile.readBytes().decodeToString())
indexDao.insertIndex(
fingerprint = izzy.fingerprint!!,
index = izzyIndex,
expectedRepoId = izzy.id,
)
}
// launch {
// val fdroid = fdroidLegacy.toRepo(2)
// val fdroidFile =
// FakeDownloader.downloadIndex(context, fdroid, "f2", "fdroid-index-v2.json")
// val fdroidIndex =
// JsonParser.decodeFromString<IndexV2>(fdroidFile.readBytes().decodeToString())
// indexDao.insertIndex(
// fingerprint = fdroid.fingerprint!!,
// index = fdroidIndex,
// expectedRepoId = fdroid.id,
// )
// }
}
@Test
fun sortOrderTest() = runTest {
val lastUpdatedQuery = appDao.query(sortOrder = SortOrder.UPDATED)
var previousUpdated = Long.MAX_VALUE
lastUpdatedQuery.forEach {
println("Previous: $previousUpdated, Current: ${it.lastUpdated}")
assertTrue(it.lastUpdated <= previousUpdated)
previousUpdated = it.lastUpdated
}
val addedQuery = appDao.query(sortOrder = SortOrder.ADDED)
var previousAdded = Long.MAX_VALUE
addedQuery.forEach {
println("Previous: $previousAdded, Current: ${it.added}")
assertTrue(it.added <= previousAdded)
previousAdded = it.added
}
}
@Test
fun categoryTest() = runTest {
val categoryQuery = appDao.query(
sortOrder = SortOrder.UPDATED,
categoriesToInclude = listOf("Games", "Food"),
)
val nonCategoryQuery = appDao.query(
sortOrder = SortOrder.UPDATED,
categoriesToExclude = listOf("Games", "Food"),
)
}
}
private fun Repository.toRepo(id: Int) = Repo(
id = id,
enabled = enabled,
address = address,
name = name,
description = description,
fingerprint = Fingerprint(fingerprint),
authentication = null,
versionInfo = VersionInfo(timestamp, entityTag),
mirrors = emptyList(),
)

View File

@ -0,0 +1,16 @@
package com.looker.droidify
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.testing.HiltTestApplication
class TestRunner : AndroidJUnitRunner() {
override fun newApplication(
cl: ClassLoader,
appName: String,
context: Context,
): Application {
return super.newApplication(cl, HiltTestApplication::class.java.getName(), context)
}
}

View File

@ -4,12 +4,18 @@ import android.content.Context
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import com.looker.droidify.database.Database
import com.looker.droidify.index.RepositoryUpdater.IndexType
import com.looker.droidify.model.Repository import com.looker.droidify.model.Repository
import com.looker.droidify.sync.FakeDownloader
import com.looker.droidify.sync.common.assets
import com.looker.droidify.sync.common.benchmark
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import java.io.File import java.io.File
import kotlin.math.sqrt
import kotlin.system.measureTimeMillis import kotlin.system.measureTimeMillis
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@ -21,7 +27,9 @@ class RepositoryUpdaterTest {
@Before @Before
fun setup() { fun setup() {
context = InstrumentationRegistry.getInstrumentation().context context = InstrumentationRegistry.getInstrumentation().targetContext
Database.init(context)
RepositoryUpdater.init(CoroutineScope(Dispatchers.Default), FakeDownloader)
repository = Repository( repository = Repository(
id = 15, id = 15,
address = "https://apt.izzysoft.de/fdroid/repo", address = "https://apt.izzysoft.de/fdroid/repo",
@ -41,13 +49,14 @@ class RepositoryUpdaterTest {
@Test @Test
fun processFile() { fun processFile() {
testRepetition(1) { val output = benchmark(1) {
val createFile = File.createTempFile("index", "entry") val createFile = File.createTempFile("index", "entry")
val mergerFile = File.createTempFile("index", "merger") val mergerFile = File.createTempFile("index", "merger")
val jarStream = context.resources.assets.open("index-v1.jar") val jarStream = context.resources.assets.open("index-v1.jar")
jarStream.copyTo(createFile.outputStream()) jarStream.copyTo(createFile.outputStream())
process(createFile, mergerFile) process(createFile, mergerFile)
} }
println(output)
} }
private fun process(file: File, merger: File) = measureTimeMillis { private fun process(file: File, merger: File) = measureTimeMillis {
@ -65,28 +74,4 @@ class RepositoryUpdaterTest {
}, },
) )
} }
private inline fun testRepetition(repetition: Int, block: () -> Long) {
val times = (1..repetition).map {
System.gc()
System.runFinalization()
block().toDouble()
}
val meanAndDeviation = times.culledMeanAndDeviation()
println(times)
println("${meanAndDeviation.first} ± ${meanAndDeviation.second}")
}
} }
private fun List<Double>.culledMeanAndDeviation(): Pair<Double, Double> = when {
isEmpty() -> Double.NaN to Double.NaN
size == 1 || size == 2 -> this.meanAndDeviation()
else -> sorted().subList(1, size - 1).meanAndDeviation()
}
private fun List<Double>.meanAndDeviation(): Pair<Double, Double> {
val mean = average()
return mean to sqrt(fold(0.0) { acc, value -> acc + (value - mean).squared() } / size)
}
private fun Double.squared() = this * this

View File

@ -44,7 +44,7 @@ class EntrySyncableTest {
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
@Before @Before
fun before() { fun before() {
context = InstrumentationRegistry.getInstrumentation().context context = InstrumentationRegistry.getInstrumentation().targetContext
dispatcher = StandardTestDispatcher() dispatcher = StandardTestDispatcher()
validator = IndexJarValidator(dispatcher) validator = IndexJarValidator(dispatcher)
parser = EntryParser(dispatcher, JsonParser, validator) parser = EntryParser(dispatcher, JsonParser, validator)

View File

@ -7,8 +7,8 @@ import com.looker.droidify.domain.model.Repo
import com.looker.droidify.sync.common.IndexJarValidator import com.looker.droidify.sync.common.IndexJarValidator
import com.looker.droidify.sync.common.Izzy import com.looker.droidify.sync.common.Izzy
import com.looker.droidify.sync.common.JsonParser import com.looker.droidify.sync.common.JsonParser
import com.looker.droidify.sync.common.downloadIndex
import com.looker.droidify.sync.common.benchmark import com.looker.droidify.sync.common.benchmark
import com.looker.droidify.sync.common.downloadIndex
import com.looker.droidify.sync.common.toV2 import com.looker.droidify.sync.common.toV2
import com.looker.droidify.sync.v1.V1Parser import com.looker.droidify.sync.v1.V1Parser
import com.looker.droidify.sync.v1.V1Syncable import com.looker.droidify.sync.v1.V1Syncable
@ -17,6 +17,7 @@ import com.looker.droidify.sync.v2.V2Parser
import com.looker.droidify.sync.v2.model.FileV2 import com.looker.droidify.sync.v2.model.FileV2
import com.looker.droidify.sync.v2.model.IndexV2 import com.looker.droidify.sync.v2.model.IndexV2
import com.looker.droidify.sync.v2.model.MetadataV2 import com.looker.droidify.sync.v2.model.MetadataV2
import com.looker.droidify.sync.v2.model.PackageV2
import com.looker.droidify.sync.v2.model.VersionV2 import com.looker.droidify.sync.v2.model.VersionV2
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
@ -28,6 +29,8 @@ import kotlin.test.Test
import kotlin.test.assertContentEquals import kotlin.test.assertContentEquals
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class V1SyncableTest { class V1SyncableTest {
@ -42,7 +45,7 @@ class V1SyncableTest {
@Before @Before
fun before() { fun before() {
context = InstrumentationRegistry.getInstrumentation().context context = InstrumentationRegistry.getInstrumentation().targetContext
dispatcher = StandardTestDispatcher() dispatcher = StandardTestDispatcher()
validator = IndexJarValidator(dispatcher) validator = IndexJarValidator(dispatcher)
parser = V1Parser(dispatcher, JsonParser, validator) parser = V1Parser(dispatcher, JsonParser, validator)
@ -102,9 +105,38 @@ class V1SyncableTest {
testIndexConversion("index-v1.jar", "index-v2-updated.json") testIndexConversion("index-v1.jar", "index-v2-updated.json")
} }
// @Test @Test
fun v1tov2FDroidRepo() = runTest(dispatcher) { fun targetPropertyTest() = runTest(dispatcher) {
testIndexConversion("fdroid-index-v1.jar", "fdroid-index-v2.json") val v2IzzyFile =
FakeDownloader.downloadIndex(context, repo, "izzy-v2", "index-v2-updated.json")
val v2FdroidFile =
FakeDownloader.downloadIndex(context, repo, "fdroid-v2", "fdroid-index-v2.json")
val (_, v2Izzy) = v2Parser.parse(v2IzzyFile, repo)
val (_, v2Fdroid) = v2Parser.parse(v2FdroidFile, repo)
val performTest: (PackageV2) -> Unit = { data ->
print("lib: ")
println(data.metadata.liberapay)
print("donate: ")
println(data.metadata.donate)
print("bit: ")
println(data.metadata.bitcoin)
print("flattr: ")
println(data.metadata.flattrID)
print("Open: ")
println(data.metadata.openCollective)
print("LiteCoin: ")
println(data.metadata.litecoin)
}
v2Izzy.packages.forEach { (packageName, data) ->
println("Testing on Izzy $packageName")
performTest(data)
}
v2Fdroid.packages.forEach { (packageName, data) ->
println("Testing on FDroid $packageName")
performTest(data)
}
} }
private suspend fun testIndexConversion( private suspend fun testIndexConversion(
@ -252,6 +284,8 @@ private fun assertVersion(
assertNotNull(foundVersion) assertNotNull(foundVersion)
assertEquals(expectedVersion.added, foundVersion.added) assertEquals(expectedVersion.added, foundVersion.added)
assertEquals(expectedVersion.file.sha256, foundVersion.file.sha256)
assertEquals(expectedVersion.file.size, foundVersion.file.size)
assertEquals(expectedVersion.file.name, foundVersion.file.name) assertEquals(expectedVersion.file.name, foundVersion.file.name)
assertEquals(expectedVersion.src?.name, foundVersion.src?.name) assertEquals(expectedVersion.src?.name, foundVersion.src?.name)
@ -261,7 +295,13 @@ private fun assertVersion(
assertEquals(expectedMan.versionCode, foundMan.versionCode) assertEquals(expectedMan.versionCode, foundMan.versionCode)
assertEquals(expectedMan.versionName, foundMan.versionName) assertEquals(expectedMan.versionName, foundMan.versionName)
assertEquals(expectedMan.maxSdkVersion, foundMan.maxSdkVersion) assertEquals(expectedMan.maxSdkVersion, foundMan.maxSdkVersion)
assertNotNull(expectedMan.usesSdk)
assertNotNull(foundMan.usesSdk)
assertEquals(expectedMan.usesSdk, foundMan.usesSdk) assertEquals(expectedMan.usesSdk, foundMan.usesSdk)
assertTrue(expectedMan.usesSdk.minSdkVersion >= 1)
assertTrue(expectedMan.usesSdk.targetSdkVersion >= 1)
assertTrue(foundMan.usesSdk.minSdkVersion >= 1)
assertTrue(foundMan.usesSdk.targetSdkVersion >= 1)
assertContentEquals( assertContentEquals(
expectedMan.features.sortedBy { it.name }, expectedMan.features.sortedBy { it.name },

View File

@ -8,11 +8,6 @@ internal inline fun benchmark(
extraMessage: String? = null, extraMessage: String? = null,
block: () -> Long, block: () -> Long,
): String { ): String {
if (extraMessage != null) {
println("=".repeat(50))
println(extraMessage)
println("=".repeat(50))
}
val times = DoubleArray(repetition) val times = DoubleArray(repetition)
repeat(repetition) { iteration -> repeat(repetition) { iteration ->
System.gc() System.gc()
@ -20,11 +15,19 @@ internal inline fun benchmark(
times[iteration] = block().toDouble() times[iteration] = block().toDouble()
} }
val meanAndDeviation = times.culledMeanAndDeviation() val meanAndDeviation = times.culledMeanAndDeviation()
return buildString { return buildString(200) {
append("=".repeat(50)) append("=".repeat(50))
append("\n") append("\n")
append(times.joinToString(" | ")) if (extraMessage != null) {
append("\n") append(extraMessage)
append("\n")
append("=".repeat(50))
append("\n")
}
if (times.size > 1) {
append(times.joinToString(" | "))
append("\n")
}
append("${meanAndDeviation.first} ms ± ${meanAndDeviation.second.toFloat()} ms") append("${meanAndDeviation.first} ms ± ${meanAndDeviation.second.toFloat()} ms")
append("\n") append("\n")
append("=".repeat(50)) append("=".repeat(50))

View File

@ -6,7 +6,7 @@ import com.looker.droidify.domain.model.Repo
import com.looker.droidify.domain.model.VersionInfo import com.looker.droidify.domain.model.VersionInfo
val Izzy = Repo( val Izzy = Repo(
id = 1L, id = 1,
enabled = true, enabled = true,
address = "https://apt.izzysoft.de/fdroid/repo", address = "https://apt.izzysoft.de/fdroid/repo",
name = "IzzyOnDroid F-Droid Repo", name = "IzzyOnDroid F-Droid Repo",
@ -15,6 +15,4 @@ val Izzy = Repo(
authentication = Authentication("", ""), authentication = Authentication("", ""),
versionInfo = VersionInfo(0L, null), versionInfo = VersionInfo(0L, null),
mirrors = emptyList(), mirrors = emptyList(),
antiFeatures = emptyList(),
categories = emptyList(),
) )

File diff suppressed because one or more lines are too long

View File

@ -80,17 +80,17 @@ class Droidify : Application(), SingletonImageLoader.Factory, Configuration.Prov
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
if (BuildConfig.DEBUG && SdkCheck.isOreo) strictThreadPolicy() // if (BuildConfig.DEBUG && SdkCheck.isOreo) strictThreadPolicy()
val databaseUpdated = Database.init(this) val databaseUpdated = Database.init(this)
ProductPreferences.init(this, appScope) ProductPreferences.init(this, appScope)
RepositoryUpdater.init(appScope, downloader) // RepositoryUpdater.init(appScope, downloader)
listenApplications() listenApplications()
checkLanguage() checkLanguage()
updatePreference() updatePreference()
appScope.launch { installer() } appScope.launch { installer() }
if (databaseUpdated) forceSyncAll() // if (databaseUpdated) forceSyncAll()
} }
override fun onTerminate() { override fun onTerminate() {
@ -107,7 +107,7 @@ class Droidify : Application(), SingletonImageLoader.Factory, Configuration.Prov
addAction(Intent.ACTION_PACKAGE_ADDED) addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_REMOVED) addAction(Intent.ACTION_PACKAGE_REMOVED)
addDataScheme("package") addDataScheme("package")
} },
) )
val installedItems = val installedItems =
packageManager.getInstalledPackagesCompat() packageManager.getInstalledPackagesCompat()
@ -200,7 +200,7 @@ class Droidify : Application(), SingletonImageLoader.Factory, Configuration.Prov
periodMillis = period, periodMillis = period,
networkType = syncConditions.toJobNetworkType(), networkType = syncConditions.toJobNetworkType(),
isCharging = syncConditions.pluggedIn, isCharging = syncConditions.pluggedIn,
isBatteryLow = syncConditions.batteryNotLow isBatteryLow = syncConditions.batteryNotLow,
) )
jobScheduler?.schedule(job) jobScheduler?.schedule(job)
} }
@ -212,10 +212,13 @@ class Droidify : Application(), SingletonImageLoader.Factory, Configuration.Prov
Database.RepositoryAdapter.put(it.copy(lastModified = "", entityTag = "")) Database.RepositoryAdapter.put(it.copy(lastModified = "", entityTag = ""))
} }
} }
Connection(SyncService::class.java, onBind = { connection, binder -> Connection(
binder.sync(SyncService.SyncRequest.FORCE) SyncService::class.java,
connection.unbind(this) onBind = { connection, binder ->
}).bind(this) binder.sync(SyncService.SyncRequest.FORCE)
connection.unbind(this)
},
).bind(this)
} }
class BootReceiver : BroadcastReceiver() { class BootReceiver : BroadcastReceiver() {
@ -256,12 +259,12 @@ fun strictThreadPolicy() {
.detectNetwork() .detectNetwork()
.detectUnbufferedIo() .detectUnbufferedIo()
.penaltyLog() .penaltyLog()
.build() .build(),
) )
StrictMode.setVmPolicy( StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder() StrictMode.VmPolicy.Builder()
.detectAll() .detectAll()
.penaltyLog() .penaltyLog()
.build() .build(),
) )
} }

View File

@ -0,0 +1,61 @@
@file:OptIn(ExperimentalEncodingApi::class)
package com.looker.droidify.data.encryption
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
private const val KEY_SIZE = 256
private const val IV_SIZE = 16
private const val ALGORITHM = "AES"
private const val TRANSFORMATION = "AES/CBC/PKCS5Padding"
@JvmInline
value class Key(val secretKey: ByteArray) {
val spec: SecretKeySpec
get() = SecretKeySpec(secretKey, ALGORITHM)
fun encrypt(input: String): Pair<Encrypted, ByteArray> {
val iv = generateIV()
val ivSpec = IvParameterSpec(iv)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, spec, ivSpec)
val encrypted = cipher.doFinal(input.toByteArray())
return Encrypted(Base64.encode(encrypted)) to iv
}
}
/**
* Before encrypting we convert it to a base64 string
* */
@JvmInline
value class Encrypted(val value: String) {
fun decrypt(key: Key, iv: ByteArray): String {
val iv = IvParameterSpec(iv)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, key.spec, iv)
val decrypted = cipher.doFinal(Base64.decode(value))
return String(decrypted)
}
}
fun generateSecretKey(): ByteArray {
return with(KeyGenerator.getInstance(ALGORITHM)) {
init(KEY_SIZE)
generateKey().encoded
}
}
private fun generateIV(): ByteArray {
val iv = ByteArray(IV_SIZE)
val secureRandom = SecureRandom()
secureRandom.nextBytes(iv)
return iv
}

View File

@ -0,0 +1,83 @@
package com.looker.droidify.data.local
import android.content.Context
import androidx.room.BuiltInTypeConverters
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.sqlite.db.SupportSQLiteDatabase
import com.looker.droidify.data.local.converters.Converters
import com.looker.droidify.data.local.converters.PermissionConverter
import com.looker.droidify.data.local.dao.AppDao
import com.looker.droidify.data.local.dao.AuthDao
import com.looker.droidify.data.local.dao.IndexDao
import com.looker.droidify.data.local.dao.RepoDao
import com.looker.droidify.data.local.model.AntiFeatureAppRelation
import com.looker.droidify.data.local.model.AntiFeatureEntity
import com.looker.droidify.data.local.model.AntiFeatureRepoRelation
import com.looker.droidify.data.local.model.AppEntity
import com.looker.droidify.data.local.model.AuthenticationEntity
import com.looker.droidify.data.local.model.AuthorEntity
import com.looker.droidify.data.local.model.CategoryAppRelation
import com.looker.droidify.data.local.model.CategoryEntity
import com.looker.droidify.data.local.model.CategoryRepoRelation
import com.looker.droidify.data.local.model.DonateEntity
import com.looker.droidify.data.local.model.GraphicEntity
import com.looker.droidify.data.local.model.InstalledEntity
import com.looker.droidify.data.local.model.LinksEntity
import com.looker.droidify.data.local.model.MirrorEntity
import com.looker.droidify.data.local.model.RepoEntity
import com.looker.droidify.data.local.model.ScreenshotEntity
import com.looker.droidify.data.local.model.VersionEntity
@Database(
entities = [
AntiFeatureEntity::class,
AntiFeatureAppRelation::class,
AntiFeatureRepoRelation::class,
AuthenticationEntity::class,
AuthorEntity::class,
AppEntity::class,
CategoryEntity::class,
CategoryAppRelation::class,
CategoryRepoRelation::class,
DonateEntity::class,
GraphicEntity::class,
InstalledEntity::class,
LinksEntity::class,
MirrorEntity::class,
RepoEntity::class,
ScreenshotEntity::class,
VersionEntity::class,
],
version = 1,
)
@TypeConverters(
PermissionConverter::class,
Converters::class,
builtInTypeConverters = BuiltInTypeConverters(),
)
abstract class DroidifyDatabase : RoomDatabase() {
abstract fun appDao(): AppDao
abstract fun repoDao(): RepoDao
abstract fun authDao(): AuthDao
abstract fun indexDao(): IndexDao
}
fun droidifyDatabase(context: Context): DroidifyDatabase = Room
.databaseBuilder(
context = context,
klass = DroidifyDatabase::class.java,
name = "droidify_room",
)
.addCallback(
object : RoomDatabase.Callback() {
override fun onOpen(db: SupportSQLiteDatabase) {
super.onOpen(db)
db.query("PRAGMA synchronous = OFF")
db.query("PRAGMA journal_mode = WAL")
}
},
)
.build()

View File

@ -0,0 +1,61 @@
package com.looker.droidify.data.local.converters
import androidx.room.TypeConverter
import com.looker.droidify.sync.common.JsonParser
import com.looker.droidify.sync.v2.model.FileV2
import com.looker.droidify.sync.v2.model.LocalizedIcon
import com.looker.droidify.sync.v2.model.LocalizedString
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
private val localizedStringSerializer =
MapSerializer(String.serializer(), String.serializer())
private val stringListSerializer = ListSerializer(String.serializer())
private val localizedIconSerializer =
MapSerializer(String.serializer(), FileV2.serializer())
private val mapOfLocalizedStringsSerializer =
MapSerializer(String.serializer(), localizedStringSerializer)
object Converters {
@TypeConverter
fun fromLocalizedString(value: LocalizedString): String {
return JsonParser.encodeToString(localizedStringSerializer, value)
}
@TypeConverter
fun toLocalizedString(value: String): LocalizedString {
return JsonParser.decodeFromString(localizedStringSerializer, value)
}
@TypeConverter
fun fromLocalizedIcon(value: LocalizedIcon?): String? {
return value?.let { JsonParser.encodeToString(localizedIconSerializer, it) }
}
@TypeConverter
fun toLocalizedIcon(value: String?): LocalizedIcon? {
return value?.let { JsonParser.decodeFromString(localizedIconSerializer, it) }
}
@TypeConverter
fun fromLocalizedList(value: Map<String, LocalizedString>): String {
return JsonParser.encodeToString(mapOfLocalizedStringsSerializer, value)
}
@TypeConverter
fun toLocalizedList(value: String): Map<String, LocalizedString> {
return JsonParser.decodeFromString(mapOfLocalizedStringsSerializer, value)
}
@TypeConverter
fun fromStringList(value: List<String>): String {
return JsonParser.encodeToString(stringListSerializer, value)
}
@TypeConverter
fun toStringList(value: String): List<String> {
return JsonParser.decodeFromString(stringListSerializer, value)
}
}

View File

@ -0,0 +1,21 @@
package com.looker.droidify.data.local.converters
import androidx.room.TypeConverter
import com.looker.droidify.sync.common.JsonParser
import com.looker.droidify.sync.v2.model.PermissionV2
import kotlinx.serialization.builtins.ListSerializer
private val permissionListSerializer = ListSerializer(PermissionV2.serializer())
object PermissionConverter {
@TypeConverter
fun fromPermissionV2List(value: List<PermissionV2>): String {
return JsonParser.encodeToString(permissionListSerializer, value)
}
@TypeConverter
fun toPermissionV2List(value: String): List<PermissionV2> {
return JsonParser.decodeFromString(permissionListSerializer, value)
}
}

View File

@ -0,0 +1,189 @@
package com.looker.droidify.data.local.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.Transaction
import androidx.sqlite.db.SimpleSQLiteQuery
import com.looker.droidify.data.local.model.AntiFeatureAppRelation
import com.looker.droidify.data.local.model.AppEntity
import com.looker.droidify.data.local.model.AppEntityRelations
import com.looker.droidify.data.local.model.CategoryAppRelation
import com.looker.droidify.data.local.model.VersionEntity
import com.looker.droidify.datastore.model.SortOrder
import com.looker.droidify.sync.v2.model.DefaultName
import com.looker.droidify.sync.v2.model.Tag
import kotlinx.coroutines.flow.Flow
@Dao
interface AppDao {
@RawQuery(
observedEntities = [
AppEntity::class,
VersionEntity::class,
CategoryAppRelation::class,
AntiFeatureAppRelation::class,
],
)
fun _rawStreamAppEntities(query: SimpleSQLiteQuery): Flow<List<AppEntity>>
@RawQuery
suspend fun _rawQueryAppEntities(query: SimpleSQLiteQuery): List<AppEntity>
fun stream(
sortOrder: SortOrder,
searchQuery: String? = null,
repoId: Int? = null,
categoriesToInclude: List<DefaultName>? = null,
categoriesToExclude: List<DefaultName>? = null,
antiFeaturesToInclude: List<Tag>? = null,
antiFeaturesToExclude: List<Tag>? = null,
): Flow<List<AppEntity>> = _rawStreamAppEntities(
searchQuery(
sortOrder = sortOrder,
searchQuery = searchQuery,
repoId = repoId,
categoriesToInclude = categoriesToInclude,
categoriesToExclude = categoriesToExclude,
antiFeaturesToInclude = antiFeaturesToInclude,
antiFeaturesToExclude = antiFeaturesToExclude,
),
)
suspend fun query(
sortOrder: SortOrder,
searchQuery: String? = null,
repoId: Int? = null,
categoriesToInclude: List<DefaultName>? = null,
categoriesToExclude: List<DefaultName>? = null,
antiFeaturesToInclude: List<Tag>? = null,
antiFeaturesToExclude: List<Tag>? = null,
): List<AppEntity> = _rawQueryAppEntities(
searchQuery(
sortOrder = sortOrder,
searchQuery = searchQuery,
repoId = repoId,
categoriesToInclude = categoriesToInclude,
categoriesToExclude = categoriesToExclude,
antiFeaturesToInclude = antiFeaturesToInclude,
antiFeaturesToExclude = antiFeaturesToExclude,
),
)
private fun searchQuery(
sortOrder: SortOrder,
searchQuery: String?,
repoId: Int?,
categoriesToInclude: List<DefaultName>?,
categoriesToExclude: List<DefaultName>?,
antiFeaturesToInclude: List<Tag>?,
antiFeaturesToExclude: List<Tag>?,
): SimpleSQLiteQuery {
val args = arrayListOf<Any?>()
val query = buildString(1024) {
append("SELECT DISTINCT app.* FROM app")
append(" LEFT JOIN version ON app.id = version.appId")
append(" LEFT JOIN category_app_relation ON app.id = category_app_relation.id")
append(" LEFT JOIN anti_features_app_relation ON app.id = anti_features_app_relation.appId")
append(" WHERE 1")
if (repoId != null) {
append(" AND app.repoId = ?")
args.add(repoId)
}
if (categoriesToInclude != null) {
append(" AND category_app_relation.defaultName IN (")
append(categoriesToInclude.joinToString(", ") { "?" })
append(")")
args.addAll(categoriesToInclude)
}
if (categoriesToExclude != null) {
append(" AND category_app_relation.defaultName NOT IN (")
append(categoriesToExclude.joinToString(", ") { "?" })
append(")")
args.addAll(categoriesToExclude)
}
if (antiFeaturesToInclude != null) {
append(" AND anti_features_app_relation.tag IN (")
append(antiFeaturesToInclude.joinToString(", ") { "?" })
append(")")
args.addAll(antiFeaturesToInclude)
}
if (antiFeaturesToExclude != null) {
append(" AND anti_features_app_relation.tag NOT IN (")
append(antiFeaturesToExclude.joinToString(", ") { "?" })
append(")")
args.addAll(antiFeaturesToExclude)
}
if (searchQuery != null) {
val searchPattern = "%${searchQuery}%"
append(
"""
AND (
app.name LIKE ?
OR app.summary LIKE ?
OR app.packageName LIKE ?
OR app.description LIKE ?
)""",
)
args.addAll(listOf(searchPattern, searchPattern, searchPattern, searchPattern))
}
append(" ORDER BY ")
// Weighting: name > summary > packageName > description
if (searchQuery != null) {
val searchPattern = "%${searchQuery}%"
append("(CASE WHEN app.name LIKE ? THEN 4 ELSE 0 END) + ")
append("(CASE WHEN app.summary LIKE ? THEN 3 ELSE 0 END) + ")
append("(CASE WHEN app.packageName LIKE ? THEN 2 ELSE 0 END) + ")
append("(CASE WHEN app.description LIKE ? THEN 1 ELSE 0 END) DESC, ")
args.addAll(listOf(searchPattern, searchPattern, searchPattern, searchPattern))
}
when (sortOrder) {
SortOrder.UPDATED -> append("app.lastUpdated DESC, ")
SortOrder.ADDED -> append("app.added DESC, ")
SortOrder.SIZE -> append("version.apk_size DESC, ")
SortOrder.NAME -> Unit
}
append("app.name COLLATE LOCALIZED ASC")
}
return SimpleSQLiteQuery(query, args.toTypedArray())
}
@Query(
"""
SELECT app.*
FROM app
LEFT JOIN installed
ON app.packageName = installed.packageName
LEFT JOIN version
ON version.appId = app.id
WHERE installed.packageName IS NOT NULL
ORDER BY
CASE WHEN version.versionCode > installed.versionCode THEN 1 ELSE 2 END,
app.lastUpdated DESC,
app.name COLLATE LOCALIZED ASC
""",
)
fun installedStream(): Flow<List<AppEntity>>
@Transaction
@Query("SELECT * FROM app WHERE packageName = :packageName")
fun queryAppEntity(packageName: String): Flow<List<AppEntityRelations>>
@Query("SELECT COUNT(*) FROM app")
suspend fun count(): Int
@Query("DELETE FROM app WHERE id = :id")
suspend fun delete(id: Int)
}

View File

@ -0,0 +1,16 @@
package com.looker.droidify.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.looker.droidify.data.local.model.AuthenticationEntity
@Dao
interface AuthDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(authentication: AuthenticationEntity)
@Query("SELECT * FROM authentication WHERE repoId = :repoId")
suspend fun getAuthentication(repoId: Int): AuthenticationEntity?
}

View File

@ -0,0 +1,181 @@
package com.looker.droidify.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import com.looker.droidify.data.local.model.AntiFeatureAppRelation
import com.looker.droidify.data.local.model.AntiFeatureEntity
import com.looker.droidify.data.local.model.AntiFeatureRepoRelation
import com.looker.droidify.data.local.model.AppEntity
import com.looker.droidify.data.local.model.AuthorEntity
import com.looker.droidify.data.local.model.CategoryAppRelation
import com.looker.droidify.data.local.model.CategoryEntity
import com.looker.droidify.data.local.model.CategoryRepoRelation
import com.looker.droidify.data.local.model.DonateEntity
import com.looker.droidify.data.local.model.GraphicEntity
import com.looker.droidify.data.local.model.LinksEntity
import com.looker.droidify.data.local.model.MirrorEntity
import com.looker.droidify.data.local.model.RepoEntity
import com.looker.droidify.data.local.model.ScreenshotEntity
import com.looker.droidify.data.local.model.VersionEntity
import com.looker.droidify.data.local.model.antiFeatureEntity
import com.looker.droidify.data.local.model.appEntity
import com.looker.droidify.data.local.model.authorEntity
import com.looker.droidify.data.local.model.categoryEntity
import com.looker.droidify.data.local.model.donateEntity
import com.looker.droidify.data.local.model.linkEntity
import com.looker.droidify.data.local.model.localizedGraphics
import com.looker.droidify.data.local.model.localizedScreenshots
import com.looker.droidify.data.local.model.mirrorEntity
import com.looker.droidify.data.local.model.repoEntity
import com.looker.droidify.data.local.model.versionEntities
import com.looker.droidify.domain.model.Fingerprint
import com.looker.droidify.sync.v2.model.IndexV2
@Dao
interface IndexDao {
@Transaction
suspend fun insertIndex(
fingerprint: Fingerprint,
index: IndexV2,
expectedRepoId: Int = 0,
) {
val repoId = upsertRepo(
index.repo.repoEntity(
id = expectedRepoId,
fingerprint = fingerprint,
),
)
val antiFeatures = index.repo.antiFeatures.flatMap { (tag, feature) ->
feature.antiFeatureEntity(tag)
}
val categories = index.repo.categories.flatMap { (defaultName, category) ->
category.categoryEntity(defaultName)
}
val antiFeatureRepoRelations = antiFeatures.map { AntiFeatureRepoRelation(repoId, it.tag) }
val categoryRepoRelations = categories.map { CategoryRepoRelation(repoId, it.defaultName) }
val mirrors = index.repo.mirrors.map { it.mirrorEntity(repoId) }
insertAntiFeatures(antiFeatures)
insertAntiFeatureRepoRelation(antiFeatureRepoRelations)
insertCategories(categories)
insertCategoryRepoRelation(categoryRepoRelations)
insertMirror(mirrors)
index.packages.forEach { (packageName, packages) ->
val metadata = packages.metadata
val author = metadata.authorEntity()
val authorId = upsertAuthor(author)
val appId = appIdByPackageName(repoId, packageName) ?: insertApp(
appEntity = metadata.appEntity(
packageName = packageName,
repoId = repoId,
authorId = authorId,
),
).toInt()
val versions = packages.versionEntities(appId)
insertVersions(versions.keys.toList())
insertAntiFeatureAppRelation(versions.values.flatten())
val appCategories = packages.metadata.categories.map { CategoryAppRelation(appId, it) }
insertCategoryAppRelation(appCategories)
metadata.linkEntity(appId)?.let { insertLink(it) }
metadata.screenshots?.localizedScreenshots(appId)?.let { insertScreenshots(it) }
metadata.localizedGraphics(appId)?.let { insertGraphics(it) }
metadata.donateEntity(appId)?.let { insertDonate(it) }
}
}
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertRepo(repoEntity: RepoEntity): Long
@Update(onConflict = OnConflictStrategy.REPLACE)
suspend fun updateRepo(repoEntity: RepoEntity): Int
@Transaction
suspend fun upsertRepo(repoEntity: RepoEntity): Int {
val id = insertRepo(repoEntity)
return if (id == -1L) {
updateRepo(repoEntity)
} else {
id.toInt()
}
}
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMirror(mirrors: List<MirrorEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAntiFeatures(antiFeatures: List<AntiFeatureEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertCategories(categories: List<CategoryEntity>)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertAntiFeatureRepoRelation(crossRef: List<AntiFeatureRepoRelation>)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertCategoryRepoRelation(crossRef: List<CategoryRepoRelation>)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertApp(appEntity: AppEntity): Long
@Query("SELECT id FROM app WHERE packageName = :packageName AND repoId = :repoId LIMIT 1")
suspend fun appIdByPackageName(repoId: Int, packageName: String): Int?
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertAuthor(authorEntity: AuthorEntity): Long
@Query(
"""
SELECT id FROM author
WHERE
(:email IS NULL AND email IS NULL OR email = :email) AND
(:name IS NULL AND name IS NULL OR name = :name COLLATE NOCASE) AND
(:website IS NULL AND website IS NULL OR website = :website COLLATE NOCASE)
LIMIT 1
""",
)
suspend fun authorId(
email: String?,
name: String?,
website: String?,
): Int?
@Transaction
suspend fun upsertAuthor(authorEntity: AuthorEntity): Int {
val id = insertAuthor(authorEntity)
return if (id == -1L) {
authorId(
email = authorEntity.email,
name = authorEntity.name,
website = authorEntity.website,
)!!.toInt()
} else {
id.toInt()
}
}
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertScreenshots(screenshotEntity: List<ScreenshotEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertLink(linksEntity: LinksEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertGraphics(graphicEntity: List<GraphicEntity>)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertDonate(donateEntity: List<DonateEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertVersions(versions: List<VersionEntity>)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertCategoryAppRelation(crossRef: List<CategoryAppRelation>)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertAntiFeatureAppRelation(crossRef: List<AntiFeatureAppRelation>)
}

View File

@ -0,0 +1,20 @@
package com.looker.droidify.data.local.dao
import androidx.room.Dao
import androidx.room.Query
import com.looker.droidify.data.local.model.RepoEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface RepoDao {
@Query("SELECT * FROM repository")
fun stream(): Flow<List<RepoEntity>>
@Query("SELECT * FROM repository WHERE id = :repoId")
fun repo(repoId: Int): Flow<RepoEntity>
@Query("DELETE FROM repository WHERE id = :id")
suspend fun delete(id: Int)
}

View File

@ -0,0 +1,71 @@
package com.looker.droidify.data.local.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import com.looker.droidify.sync.v2.model.AntiFeatureReason
import com.looker.droidify.sync.v2.model.AntiFeatureV2
import com.looker.droidify.sync.v2.model.Tag
@Entity(
tableName = "anti_feature",
primaryKeys = ["tag", "locale"],
)
data class AntiFeatureEntity(
val icon: String?,
val name: String,
val description: String?,
val locale: String,
val tag: Tag,
)
@Entity(
tableName = "anti_feature_repo_relation",
primaryKeys = ["id", "tag"],
foreignKeys = [
ForeignKey(
entity = RepoEntity::class,
childColumns = ["id"],
parentColumns = ["id"],
onDelete = ForeignKey.CASCADE,
),
],
)
data class AntiFeatureRepoRelation(
@ColumnInfo("id")
val repoId: Int,
val tag: Tag,
)
@Entity(
tableName = "anti_features_app_relation",
primaryKeys = ["tag", "appId", "versionCode"],
foreignKeys = [
ForeignKey(
entity = AppEntity::class,
childColumns = ["appId"],
parentColumns = ["id"],
onDelete = ForeignKey.CASCADE,
),
],
)
data class AntiFeatureAppRelation(
val tag: Tag,
val reason: AntiFeatureReason,
val appId: Int,
val versionCode: Long,
)
fun AntiFeatureV2.antiFeatureEntity(
tag: Tag,
): List<AntiFeatureEntity> {
return name.map { (locale, localizedName) ->
AntiFeatureEntity(
icon = icon[locale]?.name,
name = localizedName,
description = description[locale],
tag = tag,
locale = locale,
)
}
}

View File

@ -0,0 +1,110 @@
package com.looker.droidify.data.local.model
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.ForeignKey.Companion.CASCADE
import androidx.room.Index
import androidx.room.Junction
import androidx.room.PrimaryKey
import androidx.room.Relation
import com.looker.droidify.sync.v2.model.LocalizedIcon
import com.looker.droidify.sync.v2.model.LocalizedString
import com.looker.droidify.sync.v2.model.MetadataV2
@Entity(
tableName = "app",
indices = [
Index("authorId"),
Index("repoId"),
Index("packageName"),
Index("packageName", "repoId", unique = true),
],
foreignKeys = [
ForeignKey(
entity = RepoEntity::class,
childColumns = ["repoId"],
parentColumns = ["id"],
onDelete = CASCADE,
),
ForeignKey(
entity = AuthorEntity::class,
childColumns = ["authorId"],
parentColumns = ["id"],
onDelete = CASCADE,
),
],
)
data class AppEntity(
val added: Long,
val lastUpdated: Long,
val license: String?,
val name: LocalizedString,
val icon: LocalizedIcon?,
val preferredSigner: String?,
val summary: LocalizedString?,
val description: LocalizedString?,
val packageName: String,
val authorId: Int,
val repoId: Int,
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
)
data class AppEntityRelations(
@Embedded val app: AppEntity,
@Relation(
parentColumn = "authorId",
entityColumn = "id",
)
val author: AuthorEntity,
@Relation(
parentColumn = "id",
entityColumn = "appId",
)
val links: LinksEntity?,
@Relation(
parentColumn = "id",
entityColumn = "defaultName",
associateBy = Junction(CategoryAppRelation::class),
)
val categories: List<CategoryEntity>,
@Relation(
parentColumn = "id",
entityColumn = "appId",
)
val graphics: List<GraphicEntity>?,
@Relation(
parentColumn = "id",
entityColumn = "appId",
)
val screenshots: List<ScreenshotEntity>?,
@Relation(
parentColumn = "id",
entityColumn = "appId",
)
val versions: List<VersionEntity>?,
@Relation(
parentColumn = "packageName",
entityColumn = "packageName",
)
val installed: InstalledEntity?,
)
fun MetadataV2.appEntity(
packageName: String,
repoId: Int,
authorId: Int,
) = AppEntity(
added = added,
lastUpdated = lastUpdated,
license = license,
name = name,
icon = icon,
preferredSigner = preferredSigner,
summary = summary,
description = description,
packageName = packageName,
authorId = authorId,
repoId = repoId,
)

View File

@ -0,0 +1,26 @@
package com.looker.droidify.data.local.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.ForeignKey.Companion.CASCADE
import androidx.room.PrimaryKey
import com.looker.droidify.data.encryption.Encrypted
@Entity(
tableName = "authentication",
foreignKeys = [
ForeignKey(
entity = RepoEntity::class,
childColumns = ["repoId"],
parentColumns = ["id"],
onDelete = CASCADE,
),
],
)
data class AuthenticationEntity(
val password: Encrypted,
val username: String,
val initializationVector: String,
@PrimaryKey
val repoId: Int,
)

View File

@ -0,0 +1,33 @@
package com.looker.droidify.data.local.model
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import com.looker.droidify.domain.model.Author
import com.looker.droidify.sync.v2.model.MetadataV2
@Entity(
tableName = "author",
indices = [Index("email", "name", "website", unique = true)],
)
data class AuthorEntity(
val email: String?,
val name: String?,
val website: String?,
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
)
fun MetadataV2.authorEntity() = AuthorEntity(
email = authorEmail,
name = authorName,
website = authorWebSite,
)
fun AuthorEntity.toAuthor() = Author(
email = email,
name = name,
phone = null,
web = website,
id = id,
)

View File

@ -0,0 +1,71 @@
package com.looker.droidify.data.local.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import com.looker.droidify.sync.v2.model.CategoryV2
import com.looker.droidify.sync.v2.model.DefaultName
@Entity(
tableName = "category",
primaryKeys = ["defaultName", "locale"],
indices = [Index("defaultName")]
)
data class CategoryEntity(
val icon: String?,
val name: String,
val description: String?,
val locale: String,
val defaultName: DefaultName,
)
@Entity(
tableName = "category_repo_relation",
primaryKeys = ["id", "defaultName"],
foreignKeys = [
ForeignKey(
entity = RepoEntity::class,
childColumns = ["id"],
parentColumns = ["id"],
onDelete = ForeignKey.CASCADE,
),
],
)
data class CategoryRepoRelation(
@ColumnInfo("id")
val repoId: Int,
val defaultName: DefaultName,
)
@Entity(
tableName = "category_app_relation",
primaryKeys = ["id", "defaultName"],
foreignKeys = [
ForeignKey(
entity = AppEntity::class,
childColumns = ["id"],
parentColumns = ["id"],
onDelete = ForeignKey.CASCADE,
),
],
)
data class CategoryAppRelation(
@ColumnInfo("id")
val appId: Int,
val defaultName: DefaultName,
)
fun CategoryV2.categoryEntity(
defaultName: DefaultName,
): List<CategoryEntity> {
return name.map { (locale, localizedName) ->
CategoryEntity(
icon = icon[locale]?.name,
name = localizedName,
description = description[locale],
defaultName = defaultName,
locale = locale,
)
}
}

View File

@ -0,0 +1,100 @@
package com.looker.droidify.data.local.model
import androidx.annotation.IntDef
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.ForeignKey.Companion.CASCADE
import androidx.room.Index
import com.looker.droidify.domain.model.Donation
import com.looker.droidify.sync.v2.model.MetadataV2
@Entity(
tableName = "donate",
primaryKeys = ["type", "appId"],
indices = [Index("appId")],
foreignKeys = [
ForeignKey(
entity = AppEntity::class,
childColumns = ["appId"],
parentColumns = ["id"],
onDelete = CASCADE,
),
],
)
data class DonateEntity(
@DonationType
val type: Int,
val value: String,
val appId: Int,
)
fun MetadataV2.donateEntity(appId: Int): List<DonateEntity>? {
return buildList {
if (bitcoin != null) {
add(DonateEntity(BITCOIN_ADD, bitcoin, appId))
}
if (litecoin != null) {
add(DonateEntity(LITECOIN_ADD, litecoin, appId))
}
if (liberapay != null) {
add(DonateEntity(LIBERAPAY_ID, liberapay, appId))
}
if (openCollective != null) {
add(DonateEntity(OPEN_COLLECTIVE_ID, openCollective, appId))
}
if (flattrID != null) {
add(DonateEntity(FLATTR_ID, flattrID, appId))
}
if (!donate.isNullOrEmpty()) {
add(DonateEntity(REGULAR, donate.joinToString(STRING_LIST_SEPARATOR), appId))
}
}.ifEmpty { null }
}
fun List<DonateEntity>.toDonation(): Donation {
var bitcoinAddress: String? = null
var litecoinAddress: String? = null
var liberapayId: String? = null
var openCollectiveId: String? = null
var flattrId: String? = null
var regular: List<String>? = null
for (entity in this) {
when (entity.type) {
BITCOIN_ADD -> bitcoinAddress = entity.value
FLATTR_ID -> flattrId = entity.value
LIBERAPAY_ID -> liberapayId = entity.value
LITECOIN_ADD -> litecoinAddress = entity.value
OPEN_COLLECTIVE_ID -> openCollectiveId = entity.value
REGULAR -> regular = entity.value.split(STRING_LIST_SEPARATOR)
}
}
return Donation(
bitcoinAddress = bitcoinAddress,
litecoinAddress = litecoinAddress,
liberapayId = liberapayId,
openCollectiveId = openCollectiveId,
flattrId = flattrId,
regularUrl = regular,
)
}
private const val STRING_LIST_SEPARATOR = "&^%#@!"
@Retention(AnnotationRetention.BINARY)
@IntDef(
BITCOIN_ADD,
LITECOIN_ADD,
LIBERAPAY_ID,
OPEN_COLLECTIVE_ID,
FLATTR_ID,
REGULAR,
)
private annotation class DonationType
private const val BITCOIN_ADD = 0
private const val LITECOIN_ADD = 1
private const val LIBERAPAY_ID = 2
private const val OPEN_COLLECTIVE_ID = 3
private const val FLATTR_ID = 4
private const val REGULAR = 5

View File

@ -0,0 +1,83 @@
package com.looker.droidify.data.local.model
import androidx.annotation.IntDef
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.ForeignKey.Companion.CASCADE
import androidx.room.Index
import com.looker.droidify.domain.model.Graphics
import com.looker.droidify.sync.v2.model.MetadataV2
@Entity(
tableName = "graphic",
primaryKeys = ["type", "locale", "appId"],
indices = [Index("appId", "locale")],
foreignKeys = [
ForeignKey(
entity = AppEntity::class,
childColumns = ["appId"],
parentColumns = ["id"],
onDelete = CASCADE,
),
],
)
data class GraphicEntity(
@GraphicType
val type: Int,
val url: String,
val locale: String,
val appId: Int,
)
fun MetadataV2.localizedGraphics(appId: Int): List<GraphicEntity>? {
return buildList {
promoGraphic?.forEach { (locale, value) ->
add(GraphicEntity(PROMO_GRAPHIC, value.name, locale, appId))
}
featureGraphic?.forEach { (locale, value) ->
add(GraphicEntity(FEATURE_GRAPHIC, value.name, locale, appId))
}
tvBanner?.forEach { (locale, value) ->
add(GraphicEntity(TV_BANNER, value.name, locale, appId))
}
video?.forEach { (locale, value) ->
add(GraphicEntity(VIDEO, value, locale, appId))
}
}.ifEmpty { null }
}
fun List<GraphicEntity>.toGraphics(): Graphics {
var featureGraphic: String? = null
var promoGraphic: String? = null
var tvBanner: String? = null
var video: String? = null
for (entity in this) {
when (entity.type) {
FEATURE_GRAPHIC -> featureGraphic = entity.url
PROMO_GRAPHIC -> promoGraphic = entity.url
TV_BANNER -> tvBanner = entity.url
VIDEO -> video = entity.url
}
}
return Graphics(
featureGraphic = featureGraphic,
promoGraphic = promoGraphic,
tvBanner = tvBanner,
video = video,
)
}
@Retention(AnnotationRetention.BINARY)
@IntDef(
VIDEO,
TV_BANNER,
PROMO_GRAPHIC,
FEATURE_GRAPHIC,
)
annotation class GraphicType
private const val VIDEO = 0
private const val TV_BANNER = 1
private const val PROMO_GRAPHIC = 2
private const val FEATURE_GRAPHIC = 3

View File

@ -0,0 +1,13 @@
package com.looker.droidify.data.local.model
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity("installed")
data class InstalledEntity(
val versionCode: String,
val versionName: String,
val signature: String,
@PrimaryKey
val packageName: String,
)

View File

@ -0,0 +1,56 @@
package com.looker.droidify.data.local.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.ForeignKey.Companion.CASCADE
import androidx.room.PrimaryKey
import com.looker.droidify.domain.model.Links
import com.looker.droidify.sync.v2.model.MetadataV2
@Entity(
tableName = "link",
foreignKeys = [
ForeignKey(
entity = AppEntity::class,
childColumns = ["appId"],
parentColumns = ["id"],
onDelete = CASCADE,
),
],
)
data class LinksEntity(
val changelog: String?,
val issueTracker: String?,
val translation: String?,
val sourceCode: String?,
val webSite: String?,
@PrimaryKey
val appId: Int,
)
private fun MetadataV2.isLinkNull(): Boolean {
return changelog == null &&
issueTracker == null &&
translation == null &&
sourceCode == null &&
webSite == null
}
fun MetadataV2.linkEntity(appId: Int) = if (!isLinkNull()) {
LinksEntity(
appId = appId,
changelog = changelog,
issueTracker = issueTracker,
translation = translation,
sourceCode = sourceCode,
webSite = webSite,
)
} else null
fun LinksEntity.toLinks() = Links(
changelog = changelog,
issueTracker = issueTracker,
translation = translation,
sourceCode = sourceCode,
webSite = webSite,
)

View File

@ -0,0 +1,36 @@
package com.looker.droidify.data.local.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.ForeignKey.Companion.CASCADE
import androidx.room.Index
import androidx.room.PrimaryKey
import com.looker.droidify.sync.v2.model.MirrorV2
@Entity(
tableName = "mirror",
indices = [Index("repoId")],
foreignKeys = [
ForeignKey(
entity = RepoEntity::class,
childColumns = ["repoId"],
parentColumns = ["id"],
onDelete = CASCADE,
),
]
)
data class MirrorEntity(
val url: String,
val countryCode: String?,
val isPrimary: Boolean,
val repoId: Int,
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
)
fun MirrorV2.mirrorEntity(repoId: Int) = MirrorEntity(
url = url,
countryCode = countryCode,
isPrimary = isPrimary == true,
repoId = repoId,
)

View File

@ -0,0 +1,54 @@
package com.looker.droidify.data.local.model
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.looker.droidify.domain.model.Authentication
import com.looker.droidify.domain.model.Fingerprint
import com.looker.droidify.domain.model.Repo
import com.looker.droidify.domain.model.VersionInfo
import com.looker.droidify.sync.v2.model.LocalizedIcon
import com.looker.droidify.sync.v2.model.LocalizedString
import com.looker.droidify.sync.v2.model.RepoV2
import com.looker.droidify.sync.v2.model.localizedValue
@Entity(tableName = "repository")
data class RepoEntity(
val icon: LocalizedIcon?,
val address: String,
val name: LocalizedString,
val description: LocalizedString,
val fingerprint: Fingerprint,
val timestamp: Long,
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
)
fun RepoV2.repoEntity(
id: Int,
fingerprint: Fingerprint,
) = RepoEntity(
id = id,
icon = icon,
address = address,
name = name,
description = description,
timestamp = timestamp,
fingerprint = fingerprint,
)
fun RepoEntity.toRepo(
locale: String,
mirrors: List<String>,
enabled: Boolean,
authentication: Authentication? = null,
) = Repo(
name = name.localizedValue(locale) ?: "Unknown",
description = description.localizedValue(locale) ?: "Unknown",
fingerprint = fingerprint,
authentication = authentication,
enabled = enabled,
address = address,
versionInfo = VersionInfo(timestamp = timestamp, etag = null),
mirrors = mirrors,
id = id,
)

View File

@ -0,0 +1,99 @@
package com.looker.droidify.data.local.model
import androidx.annotation.IntDef
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.ForeignKey.Companion.CASCADE
import androidx.room.Index
import com.looker.droidify.domain.model.Screenshots
import com.looker.droidify.sync.v2.model.LocalizedFiles
import com.looker.droidify.sync.v2.model.ScreenshotsV2
@Entity(
tableName = "screenshot",
primaryKeys = ["path", "type", "locale", "appId"],
indices = [Index("appId", "locale")],
foreignKeys = [
ForeignKey(
entity = AppEntity::class,
childColumns = ["appId"],
parentColumns = ["id"],
onDelete = CASCADE,
),
],
)
data class ScreenshotEntity(
val path: String,
@ScreenshotType
val type: Int,
val locale: String,
val appId: Int,
)
fun ScreenshotsV2.localizedScreenshots(appId: Int): List<ScreenshotEntity> {
if (isNull) return emptyList()
val screenshots = mutableListOf<ScreenshotEntity>()
val screenshotIterator: (Int, LocalizedFiles?) -> Unit = { type, localizedFiles ->
localizedFiles?.forEach { (locale, files) ->
for ((path, _, _) in files) {
screenshots.add(
ScreenshotEntity(
locale = locale,
appId = appId,
type = type,
path = path,
)
)
}
}
}
screenshotIterator(PHONE, phone)
screenshotIterator(SEVEN_INCH, sevenInch)
screenshotIterator(TEN_INCH, tenInch)
screenshotIterator(WEAR, wear)
screenshotIterator(TV, tv)
return screenshots
}
fun List<ScreenshotEntity>.toScreenshots(): Screenshots {
val phone = mutableListOf<String>()
val sevenInch = mutableListOf<String>()
val tenInch = mutableListOf<String>()
val wear = mutableListOf<String>()
val tv = mutableListOf<String>()
for (index in this.indices) {
val entity = get(index)
when (entity.type) {
PHONE -> phone.add(entity.path)
SEVEN_INCH -> sevenInch.add(entity.path)
TEN_INCH -> tenInch.add(entity.path)
TV -> tv.add(entity.path)
WEAR -> wear.add(entity.path)
}
}
return Screenshots(
phone = phone,
sevenInch = sevenInch,
tenInch = tenInch,
wear = wear,
tv = tv,
)
}
@Retention(AnnotationRetention.BINARY)
@IntDef(
PHONE,
SEVEN_INCH,
TEN_INCH,
WEAR,
TV,
)
private annotation class ScreenshotType
private const val PHONE = 0
private const val SEVEN_INCH = 1
private const val TEN_INCH = 2
private const val WEAR = 3
private const val TV = 4

View File

@ -0,0 +1,74 @@
package com.looker.droidify.data.local.model
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.ForeignKey.Companion.CASCADE
import androidx.room.Index
import androidx.room.PrimaryKey
import com.looker.droidify.sync.v2.model.ApkFileV2
import com.looker.droidify.sync.v2.model.FileV2
import com.looker.droidify.sync.v2.model.LocalizedString
import com.looker.droidify.sync.v2.model.PackageV2
import com.looker.droidify.sync.v2.model.PermissionV2
@Entity(
tableName = "version",
indices = [Index("appId")],
foreignKeys = [
ForeignKey(
entity = AppEntity::class,
childColumns = ["appId"],
parentColumns = ["id"],
onDelete = CASCADE,
),
],
)
data class VersionEntity(
val added: Long,
val whatsNew: LocalizedString,
val versionName: String,
val versionCode: Long,
val maxSdkVersion: Int?,
val minSdkVersion: Int,
val targetSdkVersion: Int,
@Embedded("apk_")
val apk: ApkFileV2,
@Embedded("src_")
val src: FileV2?,
val features: List<String>,
val nativeCode: List<String>,
val permissions: List<PermissionV2>,
val permissionsSdk23: List<PermissionV2>,
val appId: Int,
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
)
fun PackageV2.versionEntities(appId: Int): Map<VersionEntity, List<AntiFeatureAppRelation>> {
return versions.map { (_, version) ->
VersionEntity(
added = version.added,
whatsNew = version.whatsNew,
versionName = version.manifest.versionName,
versionCode = version.manifest.versionCode,
maxSdkVersion = version.manifest.maxSdkVersion,
minSdkVersion = version.manifest.usesSdk?.minSdkVersion ?: -1,
targetSdkVersion = version.manifest.usesSdk?.targetSdkVersion ?: -1,
apk = version.file,
src = version.src,
features = version.manifest.features.map { it.name },
nativeCode = version.manifest.nativecode,
permissions = version.manifest.usesPermission,
permissionsSdk23 = version.manifest.usesPermissionSdk23,
appId = appId,
) to version.antiFeatures.map { (tag, reason) ->
AntiFeatureAppRelation(
tag = tag,
reason = reason,
appId = appId,
versionCode = version.manifest.versionCode,
)
}
}.toMap()
}

View File

@ -607,6 +607,19 @@ object Database {
.map { getUpdates(skipSignatureCheck) } .map { getUpdates(skipSignatureCheck) }
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
fun getAll(): List<Product> {
return db.query(
Schema.Product.name,
columns = arrayOf(
Schema.Product.ROW_REPOSITORY_ID,
Schema.Product.ROW_DESCRIPTION,
Schema.Product.ROW_DATA,
),
selection = null,
signal = null,
).use { it.asSequence().map(::transform).toList() }
}
fun get(packageName: String, signal: CancellationSignal?): List<Product> { fun get(packageName: String, signal: CancellationSignal?): List<Product> {
return db.query( return db.query(
Schema.Product.name, Schema.Product.name,
@ -719,7 +732,7 @@ object Database {
when (order) { when (order) {
SortOrder.UPDATED -> builder += "product.${Schema.Product.ROW_UPDATED} DESC," SortOrder.UPDATED -> builder += "product.${Schema.Product.ROW_UPDATED} DESC,"
SortOrder.ADDED -> builder += "product.${Schema.Product.ROW_ADDED} DESC," SortOrder.ADDED -> builder += "product.${Schema.Product.ROW_ADDED} DESC,"
SortOrder.NAME -> Unit else -> Unit
}::class }::class
builder += "product.${Schema.Product.ROW_NAME} COLLATE LOCALIZED ASC" builder += "product.${Schema.Product.ROW_NAME} COLLATE LOCALIZED ASC"

View File

@ -92,7 +92,7 @@ fun Context?.sortOrderName(sortOrder: SortOrder) = this?.let {
SortOrder.UPDATED -> getString(stringRes.recently_updated) SortOrder.UPDATED -> getString(stringRes.recently_updated)
SortOrder.ADDED -> getString(stringRes.whats_new) SortOrder.ADDED -> getString(stringRes.whats_new)
SortOrder.NAME -> getString(stringRes.name) SortOrder.NAME -> getString(stringRes.name)
// SortOrder.SIZE -> getString(stringRes.size) SortOrder.SIZE -> getString(stringRes.size)
} }
} ?: "" } ?: ""

View File

@ -4,5 +4,8 @@ package com.looker.droidify.datastore.model
enum class SortOrder { enum class SortOrder {
UPDATED, UPDATED,
ADDED, ADDED,
NAME NAME,
SIZE,
} }
fun supportedSortOrders(): List<SortOrder> = listOf(SortOrder.UPDATED, SortOrder.ADDED, SortOrder.NAME)

View File

@ -0,0 +1,51 @@
package com.looker.droidify.di
import android.content.Context
import com.looker.droidify.data.local.DroidifyDatabase
import com.looker.droidify.data.local.dao.AppDao
import com.looker.droidify.data.local.dao.AuthDao
import com.looker.droidify.data.local.dao.IndexDao
import com.looker.droidify.data.local.dao.RepoDao
import com.looker.droidify.data.local.droidifyDatabase
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 {
@Singleton
@Provides
fun provideDatabase(
@ApplicationContext
context: Context,
): DroidifyDatabase = droidifyDatabase(context)
@Singleton
@Provides
fun provideAppDao(
db: DroidifyDatabase,
): AppDao = db.appDao()
@Singleton
@Provides
fun provideRepoDao(
db: DroidifyDatabase,
): RepoDao = db.repoDao()
@Singleton
@Provides
fun provideAuthDao(
db: DroidifyDatabase,
): AuthDao = db.authDao()
@Singleton
@Provides
fun provideIndexDao(
db: DroidifyDatabase,
): IndexDao = db.indexDao()
}

View File

@ -0,0 +1,23 @@
package com.looker.droidify.di
import android.content.Context
import com.looker.droidify.sync.LocalSyncable
import com.looker.droidify.sync.Syncable
import com.looker.droidify.sync.v2.model.IndexV2
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 SyncableModule {
@Singleton
@Provides
fun provideSyncable(
@ApplicationContext context: Context,
): Syncable<IndexV2> = LocalSyncable(context)
}

View File

@ -6,7 +6,7 @@ data class App(
val categories: List<String>, val categories: List<String>,
val links: Links, val links: Links,
val metadata: Metadata, val metadata: Metadata,
val author: Author, val author: Author?,
val screenshots: Screenshots, val screenshots: Screenshots,
val graphics: Graphics, val graphics: Graphics,
val donation: Donation, val donation: Donation,
@ -15,34 +15,35 @@ data class App(
) )
data class Author( data class Author(
val id: Long, val id: Int,
val name: String, val name: String?,
val email: String, val email: String?,
val web: String val phone: String?,
val web: String?,
) )
data class Donation( data class Donation(
val regularUrl: String? = null, val regularUrl: List<String>? = null,
val bitcoinAddress: String? = null, val bitcoinAddress: String? = null,
val flattrId: String? = null, val flattrId: String? = null,
val liteCoinAddress: String? = null, val litecoinAddress: String? = null,
val openCollectiveId: String? = null, val openCollectiveId: String? = null,
val librePayId: String? = null, val liberapayId: String? = null,
) )
data class Graphics( data class Graphics(
val featureGraphic: String = "", val featureGraphic: String? = null,
val promoGraphic: String = "", val promoGraphic: String? = null,
val tvBanner: String = "", val tvBanner: String? = null,
val video: String = "" val video: String? = null,
) )
data class Links( data class Links(
val changelog: String = "", val changelog: String? = null,
val issueTracker: String = "", val issueTracker: String? = null,
val sourceCode: String = "", val sourceCode: String? = null,
val translation: String = "", val translation: String? = null,
val webSite: String = "" val webSite: String? = null,
) )
data class Metadata( data class Metadata(

View File

@ -1,25 +1,23 @@
package com.looker.droidify.domain.model package com.looker.droidify.domain.model
data class Repo( data class Repo(
val id: Long, val id: Int,
val enabled: Boolean, val enabled: Boolean,
val address: String, val address: String,
val name: String, val name: String,
val description: String, val description: String,
val fingerprint: Fingerprint?, val fingerprint: Fingerprint?,
val authentication: Authentication, val authentication: Authentication?,
val versionInfo: VersionInfo, val versionInfo: VersionInfo,
val mirrors: List<String>, val mirrors: List<String>,
val antiFeatures: List<AntiFeature>,
val categories: List<Category>
) { ) {
val shouldAuthenticate = val shouldAuthenticate = authentication != null
authentication.username.isNotEmpty() && authentication.password.isNotEmpty()
fun update(fingerprint: Fingerprint, timestamp: Long? = null, etag: String? = null): Repo { fun update(fingerprint: Fingerprint, timestamp: Long? = null, etag: String? = null): Repo {
return copy( return copy(
fingerprint = fingerprint, fingerprint = fingerprint,
versionInfo = timestamp?.let { VersionInfo(timestamp = it, etag = etag) } ?: versionInfo versionInfo = timestamp?.let { VersionInfo(timestamp = it, etag = etag) }
?: versionInfo,
) )
} }
} }
@ -28,22 +26,22 @@ data class AntiFeature(
val id: Long, val id: Long,
val name: String, val name: String,
val icon: String = "", val icon: String = "",
val description: String = "" val description: String = "",
) )
data class Category( data class Category(
val id: Long, val id: Long,
val name: String, val name: String,
val icon: String = "", val icon: String = "",
val description: String = "" val description: String = "",
) )
data class Authentication( data class Authentication(
val username: String, val username: String,
val password: String val password: String,
) )
data class VersionInfo( data class VersionInfo(
val timestamp: Long, val timestamp: Long,
val etag: String? val etag: String?,
) )

View File

@ -82,7 +82,9 @@ internal class KtorDownloader(
if (networkResponse !is NetworkResponse.Success) { if (networkResponse !is NetworkResponse.Success) {
return@execute networkResponse return@execute networkResponse
} }
response.bodyAsChannel().copyTo(target.outputStream()) target.outputStream().use { output ->
response.bodyAsChannel().copyTo(output)
}
validator?.validate(target) validator?.validate(target)
networkResponse networkResponse
} }

View File

@ -0,0 +1,26 @@
package com.looker.droidify.sync
import android.content.Context
import com.looker.droidify.domain.model.Fingerprint
import com.looker.droidify.domain.model.Repo
import com.looker.droidify.sync.common.JsonParser
import com.looker.droidify.sync.v2.V2Parser
import com.looker.droidify.sync.v2.model.IndexV2
import com.looker.droidify.utility.common.cache.Cache
import kotlinx.coroutines.Dispatchers
class LocalSyncable(
private val context: Context,
) : Syncable<IndexV2> {
override val parser: Parser<IndexV2>
get() = V2Parser(Dispatchers.IO, JsonParser)
override suspend fun sync(repo: Repo): Pair<Fingerprint, IndexV2?> {
val file = Cache.getTemporaryFile(context).apply {
outputStream().use {
it.write(context.assets.open("izzy_index_v2.json").readBytes())
}
}
return parser.parse(file, repo)
}
}

View File

@ -2,19 +2,14 @@ package com.looker.droidify.sync
import com.looker.droidify.domain.model.Fingerprint import com.looker.droidify.domain.model.Fingerprint
import com.looker.droidify.domain.model.Repo import com.looker.droidify.domain.model.Repo
import com.looker.droidify.sync.v2.model.IndexV2
/**
* Expected Architecture: [https://excalidraw.com/#json=JqpGunWTJONjq-ecDNiPg,j9t0X4coeNvIG7B33GTq6A]
*
* Current Issue: When downloading entry.jar we need to re-call the synchronizer,
* which this arch doesn't allow.
*/
interface Syncable<T> { interface Syncable<T> {
val parser: Parser<T> val parser: Parser<T>
suspend fun sync( suspend fun sync(
repo: Repo, repo: Repo,
): Pair<Fingerprint, com.looker.droidify.sync.v2.model.IndexV2?> ): Pair<Fingerprint, IndexV2?>
} }

View File

@ -8,6 +8,7 @@ import com.looker.droidify.sync.v1.model.RepoV1
import com.looker.droidify.sync.v1.model.maxSdk import com.looker.droidify.sync.v1.model.maxSdk
import com.looker.droidify.sync.v1.model.name import com.looker.droidify.sync.v1.model.name
import com.looker.droidify.sync.v2.model.AntiFeatureV2 import com.looker.droidify.sync.v2.model.AntiFeatureV2
import com.looker.droidify.sync.v2.model.ApkFileV2
import com.looker.droidify.sync.v2.model.CategoryV2 import com.looker.droidify.sync.v2.model.CategoryV2
import com.looker.droidify.sync.v2.model.FeatureV2 import com.looker.droidify.sync.v2.model.FeatureV2
import com.looker.droidify.sync.v2.model.FileV2 import com.looker.droidify.sync.v2.model.FileV2
@ -94,7 +95,7 @@ private fun AppV1.toV2(preferredSigner: String?): MetadataV2 = MetadataV2(
added = added ?: 0L, added = added ?: 0L,
lastUpdated = lastUpdated ?: 0L, lastUpdated = lastUpdated ?: 0L,
icon = localized?.localizedIcon(packageName, icon) { it.icon }, icon = localized?.localizedIcon(packageName, icon) { it.icon },
name = localized?.localizedString(name) { it.name }, name = localized?.localizedString(name) { it.name } ?: emptyMap(),
description = localized?.localizedString(description) { it.description }, description = localized?.localizedString(description) { it.description },
summary = localized?.localizedString(summary) { it.summary }, summary = localized?.localizedString(summary) { it.summary },
authorEmail = authorEmail, authorEmail = authorEmail,
@ -157,7 +158,7 @@ private fun PackageV1.toVersionV2(
packageAntiFeatures: List<String>, packageAntiFeatures: List<String>,
): VersionV2 = VersionV2( ): VersionV2 = VersionV2(
added = added ?: 0L, added = added ?: 0L,
file = FileV2( file = ApkFileV2(
name = "/$apkName", name = "/$apkName",
sha256 = hash, sha256 = hash,
size = size, size = size,

View File

@ -1,9 +1,9 @@
package com.looker.droidify.sync.common package com.looker.droidify.sync.common
import android.content.Context import android.content.Context
import com.looker.droidify.utility.common.cache.Cache
import com.looker.droidify.domain.model.Repo import com.looker.droidify.domain.model.Repo
import com.looker.droidify.network.Downloader import com.looker.droidify.network.Downloader
import com.looker.droidify.utility.common.cache.Cache
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
@ -16,23 +16,25 @@ suspend fun Downloader.downloadIndex(
url: String, url: String,
diff: Boolean = false, diff: Boolean = false,
): File = withContext(Dispatchers.IO) { ): File = withContext(Dispatchers.IO) {
val tempFile = Cache.getIndexFile(context, "repo_${repo.id}_$fileName") val indexFile = Cache.getIndexFile(context, "repo_${repo.id}_$fileName")
downloadToFile( downloadToFile(
url = url, url = url,
target = tempFile, target = indexFile,
headers = { headers = {
if (repo.shouldAuthenticate) { if (repo.shouldAuthenticate) {
authentication( with(requireNotNull(repo.authentication)) {
repo.authentication.username, authentication(
repo.authentication.password username = username,
) password = password,
)
}
} }
if (repo.versionInfo.timestamp > 0L && !diff) { if (repo.versionInfo.timestamp > 0L && !diff) {
ifModifiedSince(Date(repo.versionInfo.timestamp)) ifModifiedSince(Date(repo.versionInfo.timestamp))
} }
} },
) )
tempFile indexFile
} }
const val INDEX_V1_NAME = "index-v1.jar" const val INDEX_V1_NAME = "index-v1.jar"

View File

@ -1,12 +1,11 @@
package com.looker.droidify.sync.v2 package com.looker.droidify.sync.v2
import android.content.Context import android.content.Context
import com.looker.droidify.utility.common.cache.Cache
import com.looker.droidify.domain.model.Fingerprint import com.looker.droidify.domain.model.Fingerprint
import com.looker.droidify.domain.model.Repo import com.looker.droidify.domain.model.Repo
import com.looker.droidify.network.Downloader
import com.looker.droidify.sync.Parser import com.looker.droidify.sync.Parser
import com.looker.droidify.sync.Syncable import com.looker.droidify.sync.Syncable
import com.looker.droidify.network.Downloader
import com.looker.droidify.sync.common.ENTRY_V2_NAME import com.looker.droidify.sync.common.ENTRY_V2_NAME
import com.looker.droidify.sync.common.INDEX_V2_NAME import com.looker.droidify.sync.common.INDEX_V2_NAME
import com.looker.droidify.sync.common.IndexJarValidator import com.looker.droidify.sync.common.IndexJarValidator
@ -15,7 +14,9 @@ import com.looker.droidify.sync.common.downloadIndex
import com.looker.droidify.sync.v2.model.Entry import com.looker.droidify.sync.v2.model.Entry
import com.looker.droidify.sync.v2.model.IndexV2 import com.looker.droidify.sync.v2.model.IndexV2
import com.looker.droidify.sync.v2.model.IndexV2Diff import com.looker.droidify.sync.v2.model.IndexV2Diff
import com.looker.droidify.utility.common.cache.Cache
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -51,7 +52,7 @@ class EntrySyncable(
context = context, context = context,
repo = repo, repo = repo,
url = repo.address.removeSuffix("/") + "/$ENTRY_V2_NAME", url = repo.address.removeSuffix("/") + "/$ENTRY_V2_NAME",
fileName = ENTRY_V2_NAME fileName = ENTRY_V2_NAME,
) )
val (fingerprint, entry) = parser.parse(jar, repo) val (fingerprint, entry) = parser.parse(jar, repo)
jar.delete() jar.delete()
@ -61,7 +62,6 @@ class EntrySyncable(
val indexPath = repo.address.removeSuffix("/") + index.name val indexPath = repo.address.removeSuffix("/") + index.name
val indexFile = Cache.getIndexFile(context, "repo_${repo.id}_$INDEX_V2_NAME") val indexFile = Cache.getIndexFile(context, "repo_${repo.id}_$INDEX_V2_NAME")
val indexV2 = if (index != entry.index && indexFile.exists()) { val indexV2 = if (index != entry.index && indexFile.exists()) {
// example https://apt.izzysoft.de/fdroid/repo/diff/1725372028000.json
val diffFile = downloader.downloadIndex( val diffFile = downloader.downloadIndex(
context = context, context = context,
repo = repo, repo = repo,
@ -69,16 +69,11 @@ class EntrySyncable(
fileName = "diff_${repo.versionInfo.timestamp}.json", fileName = "diff_${repo.versionInfo.timestamp}.json",
diff = true, diff = true,
) )
// TODO: Maybe parse in parallel val diff = async { diffParser.parse(diffFile, repo).second }
diffParser.parse(diffFile, repo).second.let { val oldIndex = async { indexParser.parse(indexFile, repo).second }
diff.await().patchInto(oldIndex.await()) { index ->
diffFile.delete() diffFile.delete()
it.patchInto( Json.encodeToStream(index, indexFile.outputStream())
indexParser.parse(
indexFile,
repo
).second) { index ->
Json.encodeToStream(index, indexFile.outputStream())
}
} }
} else { } else {
// example https://apt.izzysoft.de/fdroid/repo/index-v2.json // example https://apt.izzysoft.de/fdroid/repo/index-v2.json

View File

@ -12,3 +12,10 @@ data class FileV2(
val sha256: String? = null, val sha256: String? = null,
val size: Long? = null, val size: Long? = null,
) )
@Serializable
data class ApkFileV2(
val name: String,
val sha256: String,
val size: Long,
)

View File

@ -1,7 +1,44 @@
package com.looker.droidify.sync.v2.model package com.looker.droidify.sync.v2.model
import androidx.core.os.LocaleListCompat
typealias LocalizedString = Map<String, String> typealias LocalizedString = Map<String, String>
typealias NullableLocalizedString = Map<String, String?> typealias NullableLocalizedString = Map<String, String?>
typealias LocalizedIcon = Map<String, FileV2> typealias LocalizedIcon = Map<String, FileV2>
typealias LocalizedList = Map<String, List<String>> typealias LocalizedList = Map<String, List<String>>
typealias LocalizedFiles = Map<String, List<FileV2>> typealias LocalizedFiles = Map<String, List<FileV2>>
typealias DefaultName = String
typealias Tag = String
typealias AntiFeatureReason = LocalizedString
fun Map<String, Any>?.localesSize(): Int? = this?.keys?.size
fun Map<String, Any>?.locales(): List<String> = buildList {
if (!isNullOrEmpty()) {
for (locale in this@locales!!.keys) {
add(locale)
}
}
}
fun <T> Map<String, T>?.localizedValue(locale: String): T? {
if (isNullOrEmpty()) return null
val localeList = LocaleListCompat.forLanguageTags(locale)
val match = localeList.getFirstMatch(keys.toTypedArray()) ?: return null
return get(match.toLanguageTag()) ?: run {
val langCountryTag = "${match.language}-${match.country}"
getOrStartsWith(langCountryTag) ?: run {
val langTag = match.language
getOrStartsWith(langTag) ?: get("en-US") ?: get("en") ?: values.first()
}
}
}
private fun <T> Map<String, T>.getOrStartsWith(s: String): T? = get(s) ?: run {
entries.forEach { (key, value) ->
if (key.startsWith(s)) return value
}
return null
}

View File

@ -23,7 +23,7 @@ data class PackageV2Diff(
added = metadata?.added ?: 0L, added = metadata?.added ?: 0L,
lastUpdated = metadata?.lastUpdated ?: 0L, lastUpdated = metadata?.lastUpdated ?: 0L,
name = metadata?.name name = metadata?.name
?.mapNotNull { (key, value) -> value?.let { key to value } }?.toMap(), ?.mapNotNull { (key, value) -> value?.let { key to value } }?.toMap() ?: emptyMap(),
summary = metadata?.summary summary = metadata?.summary
?.mapNotNull { (key, value) -> value?.let { key to value } }?.toMap(), ?.mapNotNull { (key, value) -> value?.let { key to value } }?.toMap(),
description = metadata?.description description = metadata?.description
@ -116,7 +116,7 @@ data class PackageV2Diff(
@Serializable @Serializable
data class MetadataV2( data class MetadataV2(
val name: LocalizedString? = null, val name: LocalizedString,
val summary: LocalizedString? = null, val summary: LocalizedString? = null,
val description: LocalizedString? = null, val description: LocalizedString? = null,
val icon: LocalizedIcon? = null, val icon: LocalizedIcon? = null,
@ -129,7 +129,7 @@ data class MetadataV2(
val bitcoin: String? = null, val bitcoin: String? = null,
val categories: List<String> = emptyList(), val categories: List<String> = emptyList(),
val changelog: String? = null, val changelog: String? = null,
val donate: List<String> = emptyList(), val donate: List<String>? = null,
val featureGraphic: LocalizedIcon? = null, val featureGraphic: LocalizedIcon? = null,
val flattrID: String? = null, val flattrID: String? = null,
val issueTracker: String? = null, val issueTracker: String? = null,
@ -183,25 +183,25 @@ data class MetadataV2Diff(
@Serializable @Serializable
data class VersionV2( data class VersionV2(
val added: Long, val added: Long,
val file: FileV2, val file: ApkFileV2,
val src: FileV2? = null, val src: FileV2? = null,
val whatsNew: LocalizedString = emptyMap(), val whatsNew: LocalizedString = emptyMap(),
val manifest: ManifestV2, val manifest: ManifestV2,
val antiFeatures: Map<String, LocalizedString> = emptyMap(), val antiFeatures: Map<Tag, AntiFeatureReason> = emptyMap(),
) )
@Serializable @Serializable
data class VersionV2Diff( data class VersionV2Diff(
val added: Long? = null, val added: Long? = null,
val file: FileV2? = null, val file: ApkFileV2? = null,
val src: FileV2? = null, val src: FileV2? = null,
val whatsNew: LocalizedString? = null, val whatsNew: LocalizedString? = null,
val manifest: ManifestV2? = null, val manifest: ManifestV2? = null,
val antiFeatures: Map<String, LocalizedString>? = null, val antiFeatures: Map<Tag, AntiFeatureReason>? = null,
) { ) {
fun toVersion() = VersionV2( fun toVersion() = VersionV2(
added = added ?: 0, added = added ?: 0,
file = file ?: FileV2(""), file = file ?: ApkFileV2("", "", -1L),
src = src ?: FileV2(""), src = src ?: FileV2(""),
whatsNew = whatsNew ?: emptyMap(), whatsNew = whatsNew ?: emptyMap(),
manifest = manifest ?: ManifestV2( manifest = manifest ?: ManifestV2(

View File

@ -10,8 +10,8 @@ data class RepoV2(
val icon: LocalizedIcon? = null, val icon: LocalizedIcon? = null,
val name: LocalizedString = emptyMap(), val name: LocalizedString = emptyMap(),
val description: LocalizedString = emptyMap(), val description: LocalizedString = emptyMap(),
val antiFeatures: Map<String, AntiFeatureV2> = emptyMap(), val antiFeatures: Map<Tag, AntiFeatureV2> = emptyMap(),
val categories: Map<String, CategoryV2> = emptyMap(), val categories: Map<DefaultName, CategoryV2> = emptyMap(),
val mirrors: List<MirrorV2> = emptyList(), val mirrors: List<MirrorV2> = emptyList(),
val timestamp: Long, val timestamp: Long,
) )
@ -22,8 +22,8 @@ data class RepoV2Diff(
val icon: LocalizedIcon? = null, val icon: LocalizedIcon? = null,
val name: LocalizedString? = null, val name: LocalizedString? = null,
val description: LocalizedString? = null, val description: LocalizedString? = null,
val antiFeatures: Map<String, AntiFeatureV2?>? = null, val antiFeatures: Map<Tag, AntiFeatureV2?>? = null,
val categories: Map<String, CategoryV2?>? = null, val categories: Map<DefaultName, CategoryV2?>? = null,
val mirrors: List<MirrorV2>? = null, val mirrors: List<MirrorV2>? = null,
val timestamp: Long, val timestamp: Long,
) { ) {
@ -69,7 +69,7 @@ data class RepoV2Diff(
data class MirrorV2( data class MirrorV2(
val url: String, val url: String,
val isPrimary: Boolean? = null, val isPrimary: Boolean? = null,
val location: String? = null val countryCode: String? = null
) )
@Serializable @Serializable

View File

@ -31,6 +31,7 @@ import com.google.android.material.shape.ShapeAppearanceModel
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import com.looker.droidify.R import com.looker.droidify.R
import com.looker.droidify.databinding.TabsToolbarBinding import com.looker.droidify.databinding.TabsToolbarBinding
import com.looker.droidify.datastore.model.supportedSortOrders
import com.looker.droidify.datastore.extension.sortOrderName import com.looker.droidify.datastore.extension.sortOrderName
import com.looker.droidify.datastore.model.SortOrder import com.looker.droidify.datastore.model.SortOrder
import com.looker.droidify.model.ProductItem import com.looker.droidify.model.ProductItem
@ -212,7 +213,7 @@ class TabsFragment : ScreenFragment() {
.setIcon(toolbar.context.getMutatedIcon(R.drawable.ic_sort)) .setIcon(toolbar.context.getMutatedIcon(R.drawable.ic_sort))
.let { menu -> .let { menu ->
menu.item.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS) menu.item.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS)
val menuItems = SortOrder.entries.map { sortOrder -> val menuItems = supportedSortOrders().map { sortOrder ->
menu.add(context.sortOrderName(sortOrder)) menu.add(context.sortOrderName(sortOrder))
.setOnMenuItemClickListener { .setOnMenuItemClickListener {
viewModel.setSortOrder(sortOrder) viewModel.setSortOrder(sortOrder)
@ -224,9 +225,7 @@ class TabsFragment : ScreenFragment() {
} }
favouritesItem = add(1, 0, 0, stringRes.favourites) favouritesItem = add(1, 0, 0, stringRes.favourites)
.setIcon( .setIcon(toolbar.context.getMutatedIcon(R.drawable.ic_favourite_checked))
toolbar.context.getMutatedIcon(R.drawable.ic_favourite_checked)
)
.setOnMenuItemClickListener { .setOnMenuItemClickListener {
view.post { mainActivity.navigateFavourites() } view.post { mainActivity.navigateFavourites() }
true true

View File

@ -7,6 +7,7 @@ import com.looker.droidify.database.Database
import com.looker.droidify.datastore.SettingsRepository import com.looker.droidify.datastore.SettingsRepository
import com.looker.droidify.datastore.get import com.looker.droidify.datastore.get
import com.looker.droidify.datastore.model.SortOrder import com.looker.droidify.datastore.model.SortOrder
import com.looker.droidify.domain.model.Fingerprint
import com.looker.droidify.model.ProductItem import com.looker.droidify.model.ProductItem
import com.looker.droidify.ui.tabsFragment.TabsFragment.BackAction import com.looker.droidify.ui.tabsFragment.TabsFragment.BackAction
import com.looker.droidify.utility.common.extension.asStateFlow import com.looker.droidify.utility.common.extension.asStateFlow
@ -20,7 +21,9 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class TabsViewModel @Inject constructor( class TabsViewModel @Inject constructor(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val savedStateHandle: SavedStateHandle private val indexDao: IndexDao,
private val syncable: Syncable<IndexV2>,
private val savedStateHandle: SavedStateHandle,
) : ViewModel() { ) : ViewModel() {
val currentSection = val currentSection =
@ -37,7 +40,7 @@ class TabsViewModel @Inject constructor(
val sections = val sections =
combine( combine(
Database.CategoryAdapter.getAllStream(), Database.CategoryAdapter.getAllStream(),
Database.RepositoryAdapter.getEnabledStream() Database.RepositoryAdapter.getEnabledStream(),
) { categories, repos -> ) { categories, repos ->
val productCategories = categories val productCategories = categories
.asSequence() .asSequence()
@ -80,6 +83,30 @@ class TabsViewModel @Inject constructor(
} }
} }
private fun calcBackAction(
currentSection: ProductItem.Section,
isSearchActionItemExpanded: Boolean,
showSections: Boolean,
): BackAction {
return when {
currentSection != ProductItem.Section.All -> {
BackAction.ProductAll
}
isSearchActionItemExpanded -> {
BackAction.CollapseSearchView
}
showSections -> {
BackAction.HideSections
}
else -> {
BackAction.None
}
}
}
companion object { companion object {
private const val STATE_SECTION = "section" private const val STATE_SECTION = "section"
} }

View File

@ -1,5 +1,18 @@
package com.looker.droidify.utility.common.extension package com.looker.droidify.utility.common.extension
inline fun <K, E> Map<K, E>.windowed(windowSize: Int, block: (Map<K, E>) -> Unit) {
var index = 0
val windowedPackages: HashMap<K, E> = HashMap(windowSize)
forEach {
index++
windowedPackages.put(it.key, it.value)
if (windowedPackages.size == windowSize || index == size) {
block(windowedPackages)
windowedPackages.clear()
}
}
}
inline fun <K, E> Map<K, E>.updateAsMutable(block: MutableMap<K, E>.() -> Unit): Map<K, E> { inline fun <K, E> Map<K, E>.updateAsMutable(block: MutableMap<K, E>.() -> Unit): Map<K, E> {
return toMutableMap().apply(block) return toMutableMap().apply(block)
} }

View File

@ -0,0 +1,9 @@
package com.looker.droidify
import java.io.File
fun assets(name: String): File? {
val url = Thread.currentThread().contextClassLoader?.getResource(name) ?: return null
return File(url.file)
}

View File

@ -0,0 +1,41 @@
package com.looker.droidify.encryption
import com.looker.droidify.data.encryption.Key
import com.looker.droidify.data.encryption.generateSecretKey
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFails
import kotlin.test.assertNotEquals
class EncryptionTest {
private val secretKey = Key(generateSecretKey())
private val fakeKey = Key(generateSecretKey())
private val testString = "This is a test string"
@Test
fun `encrypt and decrypt`() {
val (encrypted, iv) = secretKey.encrypt(testString)
assertNotEquals(testString, encrypted.value, "Encrypted and original string are the same")
val decrypted = encrypted.decrypt(secretKey, iv)
assertEquals(testString, decrypted, "Decrypted string does not match original")
}
@Test
fun `encrypt and decrypt with fake key`() {
val (encrypted, iv) = secretKey.encrypt(testString)
assertNotEquals(testString, encrypted.value, "Encrypted and original string are the same")
assertFails { encrypted.decrypt(fakeKey, iv) }
}
@Test
fun `encrypt and decrypt with wrong iv`() {
val (encrypted, iv) = secretKey.encrypt(testString)
assertNotEquals(testString, encrypted.value, "Encrypted and original string are the same")
val fakeIv = iv.clone().apply { this[lastIndex] = "1".toByte() }
val output = encrypted.decrypt(secretKey, fakeIv)
assertNotEquals(testString, output, "Encrypted and original string are the same")
}
}

View File

@ -0,0 +1,47 @@
package com.looker.droidify.index
import com.looker.droidify.assets
import com.looker.droidify.sync.common.JsonParser
import com.looker.droidify.sync.v2.model.IndexV2
import com.looker.droidify.sync.v2.model.PackageV2
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlin.collections.component1
import kotlin.collections.component2
import kotlin.test.Test
class IndexValuesTest {
private val dispatcher = StandardTestDispatcher()
@Test
fun `test values in index v2`() = runTest(dispatcher) {
val izzy = assets("izzy_index_v2_updated.json")!!
val fdroid = assets("fdroid_index_v2.json")!!
val izzyIndex: IndexV2 = JsonParser.decodeFromString(izzy.readBytes().decodeToString())
val fdroidIndex: IndexV2 = JsonParser.decodeFromString(fdroid.readBytes().decodeToString())
var hits = 0
var total = 0
val nativeCode = mutableSetOf<String>()
val performTest: (PackageV2) -> Unit = { data ->
data.versions.forEach { t, u ->
hits++
nativeCode.addAll(u.manifest.nativecode)
}
total++
}
izzyIndex.packages.forEach { (packageName, data) ->
// println("Testing on Izzy $packageName")
performTest(data)
}
fdroidIndex.packages.forEach { (packageName, data) ->
// println("Testing on FDroid $packageName")
performTest(data)
}
println(nativeCode)
println("Hits: %d, %.2f%%".format(hits, hits * 100 / total.toFloat()))
}
}

View File

@ -9,6 +9,7 @@ import io.ktor.client.plugins.SocketTimeoutException
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import java.io.File import java.io.File
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertIs import kotlin.test.assertIs
@ -32,19 +33,19 @@ class KtorDownloaderTest {
private val downloader = KtorDownloader(engine, dispatcher) private val downloader = KtorDownloader(engine, dispatcher)
@org.junit.jupiter.api.Test @Test
fun `head call success`() = runTest(dispatcher) { fun `head call success`() = runTest(dispatcher) {
val response = downloader.headCall("https://success.com") val response = downloader.headCall("https://success.com")
assertIs<NetworkResponse.Success>(response) assertIs<NetworkResponse.Success>(response)
} }
@org.junit.jupiter.api.Test @Test
fun `head call if path not found`() = runTest(dispatcher) { fun `head call if path not found`() = runTest(dispatcher) {
val response = downloader.headCall("https://notfound.com") val response = downloader.headCall("https://notfound.com")
assertIs<NetworkResponse.Error.Http>(response) assertIs<NetworkResponse.Error.Http>(response)
} }
@org.junit.jupiter.api.Test @Test
fun `save text to file success`() = runTest(dispatcher) { fun `save text to file success`() = runTest(dispatcher) {
val file = File.createTempFile("test", "success") val file = File.createTempFile("test", "success")
val response = downloader.downloadToFile( val response = downloader.downloadToFile(
@ -55,7 +56,7 @@ class KtorDownloaderTest {
assertEquals("success", file.readText()) assertEquals("success", file.readText())
} }
@org.junit.jupiter.api.Test @Test
fun `save text to read-only file`() = runTest(dispatcher) { fun `save text to read-only file`() = runTest(dispatcher) {
val file = File.createTempFile("test", "success") val file = File.createTempFile("test", "success")
file.setReadOnly() file.setReadOnly()
@ -66,7 +67,7 @@ class KtorDownloaderTest {
assertIs<NetworkResponse.Error.IO>(response) assertIs<NetworkResponse.Error.IO>(response)
} }
@org.junit.jupiter.api.Test @Test
fun `save text to file with slow connection`() = runTest(dispatcher) { fun `save text to file with slow connection`() = runTest(dispatcher) {
val file = File.createTempFile("test", "success") val file = File.createTempFile("test", "success")
val response = downloader.downloadToFile( val response = downloader.downloadToFile(
@ -76,7 +77,7 @@ class KtorDownloaderTest {
assertIs<NetworkResponse.Error.ConnectionTimeout>(response) assertIs<NetworkResponse.Error.ConnectionTimeout>(response)
} }
@org.junit.jupiter.api.Test @Test
fun `save text to file with socket error`() = runTest(dispatcher) { fun `save text to file with socket error`() = runTest(dispatcher) {
val file = File.createTempFile("test", "success") val file = File.createTempFile("test", "success")
val response = downloader.downloadToFile( val response = downloader.downloadToFile(
@ -86,7 +87,7 @@ class KtorDownloaderTest {
assertIs<NetworkResponse.Error.SocketTimeout>(response) assertIs<NetworkResponse.Error.SocketTimeout>(response)
} }
@org.junit.jupiter.api.Test @Test
fun `save text to file if not modifier`() = runTest(dispatcher) { fun `save text to file if not modifier`() = runTest(dispatcher) {
val file = File.createTempFile("test", "success") val file = File.createTempFile("test", "success")
val response = downloader.downloadToFile( val response = downloader.downloadToFile(
@ -100,7 +101,7 @@ class KtorDownloaderTest {
assertEquals("", file.readText()) assertEquals("", file.readText())
} }
@org.junit.jupiter.api.Test @Test
fun `save text to file with wrong authentication`() = fun `save text to file with wrong authentication`() =
runTest(dispatcher) { runTest(dispatcher) {
val file = File.createTempFile("test", "success") val file = File.createTempFile("test", "success")

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,4 +5,6 @@ plugins {
alias(libs.plugins.hilt) apply false alias(libs.plugins.hilt) apply false
alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.kotlin.parcelize) apply false alias(libs.plugins.kotlin.parcelize) apply false
alias(libs.plugins.hilt) apply false
alias(libs.plugins.ksp) apply false
} }

View File

@ -13,8 +13,6 @@
org.gradle.daemon=true org.gradle.daemon=true
org.gradle.jvmargs=-Xmx6g -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g org.gradle.jvmargs=-Xmx6g -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g
android.useAndroidX=true android.useAndroidX=true
org.gradle.parallel=true
org.gradle.caching=true org.gradle.caching=true
org.gradle.configuration-cache=true org.gradle.configuration-cache=true
android.defaults.buildfeatures.resvalues=false
android.defaults.buildfeatures.shaders=false android.defaults.buildfeatures.shaders=false

View File

@ -46,17 +46,16 @@ lifecycle-runtime= { group = "androidx.lifecycle", name = "lifecycle-runtime", v
lifecycle-viewModel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "lifecycle" } lifecycle-viewModel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "lifecycle" }
recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recycler-view" } recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recycler-view" }
sqlite-ktx = { group = "androidx.sqlite", name = "sqlite-ktx", version.ref = "sqlite" } sqlite-ktx = { group = "androidx.sqlite", name = "sqlite-ktx", version.ref = "sqlite" }
test-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }
test-ext = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "test-ext" } test-ext = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "test-ext" }
test-rules = { group = "androidx.test", name = "rules", version.ref = "test-rules" } test-rules = { group = "androidx.test", name = "rules", version.ref = "test-rules" }
test-runner = { group = "androidx.test", name = "runner", version.ref = "test-runner" } test-runner = { group = "androidx.test", name = "runner", version.ref = "test-runner" }
test-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "ui-automator" }
work-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } work-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
work-testing = { group = "androidx.work", name = "work-testing", version.ref = "work" } work-testing = { group = "androidx.work", name = "work-testing", version.ref = "work" }
coil-core = { group = "io.coil-kt.coil3", name = "coil", version.ref = "coil" } coil-core = { group = "io.coil-kt.coil3", name = "coil", version.ref = "coil" }
coil-network = { group = "io.coil-kt.coil3", name = "coil-network-ktor3", version.ref = "coil" } coil-network = { group = "io.coil-kt.coil3", name = "coil-network-ktor3", version.ref = "coil" }
hilt-core = { group = "com.google.dagger", name = "hilt-core", version.ref = "hilt" } hilt-core = { group = "com.google.dagger", name = "hilt-core", version.ref = "hilt" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-test = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
hilt-ext-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltExt" } hilt-ext-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltExt" }
hilt-ext-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltExt" } hilt-ext-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltExt" }
@ -77,6 +76,7 @@ libsu-core = { group = "com.github.topjohnwu.libsu", name = "core", version.ref
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-test = { group = "androidx.room", name = "room-testing", version.ref = "room" }
shizuku-api = { group = "dev.rikka.shizuku", name = "api", version.ref = "shizuku" } shizuku-api = { group = "dev.rikka.shizuku", name = "api", version.ref = "shizuku" }
shizuku-provider = { group = "dev.rikka.shizuku", name = "provider", version.ref = "shizuku" } shizuku-provider = { group = "dev.rikka.shizuku", name = "provider", version.ref = "shizuku" }
image-viewer = { module = "com.github.stfalcon-studio:StfalconImageViewer", version.ref = "image-viewer" } image-viewer = { module = "com.github.stfalcon-studio:StfalconImageViewer", version.ref = "image-viewer" }
@ -93,6 +93,10 @@ kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", versi
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
[bundles] [bundles]
room = ["room-runtime", "room-ktx"]
shizuku = ["shizuku-provider", "shizuku-api"]
ktor = ["ktor-core", "ktor-okhttp"]
coroutines = ["coroutines-core", "coroutines-android", "coroutines-guava"]
coil = ["coil-core", "coil-network"] coil = ["coil-core", "coil-network"]
test-unit = ["junit-jupiter", "ktor-mock", "coroutines-test", "kotlin-test"] test-unit = ["junit-jupiter", "ktor-mock", "coroutines-test", "kotlin-test"]
test-android = ["test-runner", "test-rules", "test-ext", "test-espresso-core", "coroutines-test", "kotlin-test"] test-android = ["test-runner", "test-rules", "test-ext", "coroutines-test", "kotlin-test"]