This commit is contained in:
Felitendo
2025-05-20 15:22:07 +02:00
parent c24d95627e
commit 8a6d5d19db
384 changed files with 7065 additions and 4430 deletions

1
core/database/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,17 @@
plugins {
alias(libs.plugins.looker.android.library)
alias(libs.plugins.looker.room)
alias(libs.plugins.looker.hilt)
alias(libs.plugins.looker.serialization)
}
android {
namespace = "com.looker.core.database"
}
dependencies {
modules(Modules.coreCommon, Modules.coreDomain)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.androidx.core.ktx)
}

View File

@@ -0,0 +1,381 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "b01a8fae755b8b96d36459e885dea04b",
"entities": [
{
"tableName": "apps",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `repoId` INTEGER NOT NULL, `categories` BLOB NOT NULL, `summary` TEXT NOT NULL, `description` TEXT NOT NULL, `changelog` TEXT NOT NULL, `translation` TEXT NOT NULL, `issueTracker` TEXT NOT NULL, `sourceCode` TEXT NOT NULL, `binaries` TEXT NOT NULL, `name` TEXT NOT NULL, `authorName` TEXT NOT NULL, `authorEmail` TEXT NOT NULL, `authorWebSite` TEXT NOT NULL, `donate` TEXT NOT NULL, `liberapayID` TEXT NOT NULL, `liberapay` TEXT NOT NULL, `openCollective` TEXT NOT NULL, `bitcoin` TEXT NOT NULL, `litecoin` TEXT NOT NULL, `flattrID` TEXT NOT NULL, `suggestedVersionName` TEXT NOT NULL, `suggestedVersionCode` INTEGER NOT NULL, `license` TEXT NOT NULL, `webSite` TEXT NOT NULL, `added` INTEGER NOT NULL, `icon` TEXT NOT NULL, `phoneScreenshots` TEXT NOT NULL, `sevenInchScreenshots` TEXT NOT NULL, `tenInchScreenshots` TEXT NOT NULL, `wearScreenshots` TEXT NOT NULL, `tvScreenshots` TEXT NOT NULL, `featureGraphic` TEXT NOT NULL, `promoGraphic` TEXT NOT NULL, `tvBanner` TEXT NOT NULL, `video` TEXT NOT NULL, `lastUpdated` INTEGER NOT NULL, `packages` TEXT NOT NULL, PRIMARY KEY(`repoId`, `packageName`))",
"fields": [
{
"fieldPath": "packageName",
"columnName": "packageName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "repoId",
"columnName": "repoId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "categories",
"columnName": "categories",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "summary",
"columnName": "summary",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "changelog",
"columnName": "changelog",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "translation",
"columnName": "translation",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "issueTracker",
"columnName": "issueTracker",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sourceCode",
"columnName": "sourceCode",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "binaries",
"columnName": "binaries",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "authorName",
"columnName": "authorName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "authorEmail",
"columnName": "authorEmail",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "authorWebSite",
"columnName": "authorWebSite",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "donate",
"columnName": "donate",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "liberapayID",
"columnName": "liberapayID",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "liberapay",
"columnName": "liberapay",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "openCollective",
"columnName": "openCollective",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bitcoin",
"columnName": "bitcoin",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "litecoin",
"columnName": "litecoin",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "flattrID",
"columnName": "flattrID",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "suggestedVersionName",
"columnName": "suggestedVersionName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "suggestedVersionCode",
"columnName": "suggestedVersionCode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "license",
"columnName": "license",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "webSite",
"columnName": "webSite",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "added",
"columnName": "added",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "phoneScreenshots",
"columnName": "phoneScreenshots",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sevenInchScreenshots",
"columnName": "sevenInchScreenshots",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tenInchScreenshots",
"columnName": "tenInchScreenshots",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "wearScreenshots",
"columnName": "wearScreenshots",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tvScreenshots",
"columnName": "tvScreenshots",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "featureGraphic",
"columnName": "featureGraphic",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "promoGraphic",
"columnName": "promoGraphic",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tvBanner",
"columnName": "tvBanner",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "video",
"columnName": "video",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastUpdated",
"columnName": "lastUpdated",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "packages",
"columnName": "packages",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"repoId",
"packageName"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "repos",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `enabled` INTEGER NOT NULL, `fingerprint` TEXT NOT NULL, `etag` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `address` TEXT NOT NULL, `mirrors` BLOB NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `antiFeatures` TEXT NOT NULL, `categories` TEXT NOT NULL, `timestamp` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "enabled",
"columnName": "enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "fingerprint",
"columnName": "fingerprint",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "etag",
"columnName": "etag",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "password",
"columnName": "password",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "address",
"columnName": "address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mirrors",
"columnName": "mirrors",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "antiFeatures",
"columnName": "antiFeatures",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "categories",
"columnName": "categories",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "InstalledEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `versionCode` INTEGER NOT NULL, `signature` TEXT NOT NULL, PRIMARY KEY(`packageName`))",
"fields": [
{
"fieldPath": "packageName",
"columnName": "packageName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "versionCode",
"columnName": "versionCode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "signature",
"columnName": "signature",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"packageName"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b01a8fae755b8b96d36459e885dea04b')"
]
}
}

Binary file not shown.

View File

@@ -0,0 +1,94 @@
package com.looker.core.database
import androidx.room.TypeConverter
import com.looker.core.database.model.AntiFeatureEntity
import com.looker.core.database.model.CategoryEntity
import com.looker.core.database.model.LocalizedList
import com.looker.core.database.model.LocalizedString
import com.looker.core.database.model.PackageEntity
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
internal const val STRING_DELIMITER = "!@#$%^&*"
private val stringListSerializer = ListSerializer(String.serializer())
private val localizedStringSerializer = MapSerializer(String.serializer(), String.serializer())
private val localizedListSerializer = MapSerializer(String.serializer(), stringListSerializer)
private val antiFeatureSerializer =
MapSerializer(String.serializer(), AntiFeatureEntity.serializer())
private val categorySerializer = MapSerializer(String.serializer(), CategoryEntity.serializer())
private val packageListSerializer = ListSerializer(PackageEntity.serializer())
class CollectionConverter {
@TypeConverter
fun listToString(list: List<String>): ByteArray =
list.joinToString(STRING_DELIMITER).toByteArray()
@TypeConverter
fun stringToList(byteArray: ByteArray): List<String> = String(byteArray).split(STRING_DELIMITER)
}
class LocalizedConverter {
@TypeConverter
fun localizedStringToJson(localizedEntity: LocalizedString): String =
json.encodeToString(localizedStringSerializer, localizedEntity)
@TypeConverter
fun jsonToLocalizedString(jsonObject: String): LocalizedString =
json.decodeFromString(localizedStringSerializer, jsonObject)
@TypeConverter
fun localizedListToJson(localizedEntity: LocalizedList): String =
json.encodeToString(localizedListSerializer, localizedEntity)
@TypeConverter
fun jsonToLocalizedList(jsonObject: String): LocalizedList =
json.decodeFromString(localizedListSerializer, jsonObject)
}
class PackageEntityConverter {
@TypeConverter
fun entityToString(packageEntity: PackageEntity): String =
json.encodeToString(packageEntity)
@TypeConverter
fun stringToPackage(jsonString: String): PackageEntity =
json.decodeFromString(jsonString)
@TypeConverter
fun entityListToString(packageEntity: List<PackageEntity>): String =
json.encodeToString(packageListSerializer, packageEntity)
@TypeConverter
fun stringToPackageList(jsonString: String): List<PackageEntity> =
json.decodeFromString(packageListSerializer, jsonString)
}
class RepoConverter {
@TypeConverter
fun antiFeaturesToString(map: Map<String, AntiFeatureEntity>): String =
json.encodeToString(antiFeatureSerializer, map)
@TypeConverter
fun stringToAntiFeatures(string: String): Map<String, AntiFeatureEntity> =
json.decodeFromString(antiFeatureSerializer, string)
@TypeConverter
fun categoryToString(map: Map<String, CategoryEntity>): String =
json.encodeToString(categorySerializer, map)
@TypeConverter
fun stringToCategory(string: String): Map<String, CategoryEntity> =
json.decodeFromString(categorySerializer, string)
}

View File

@@ -0,0 +1,34 @@
package com.looker.core.database
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.looker.core.database.dao.AppDao
import com.looker.core.database.dao.InstalledDao
import com.looker.core.database.dao.RepoDao
import com.looker.core.database.model.AppEntity
import com.looker.core.database.model.InstalledEntity
import com.looker.core.database.model.RepoEntity
@Database(
version = 1,
entities = [
AppEntity::class,
RepoEntity::class,
InstalledEntity::class
]
)
@TypeConverters(
CollectionConverter::class,
LocalizedConverter::class,
PackageEntityConverter::class,
RepoConverter::class
)
abstract class DroidifyDatabase : RoomDatabase() {
abstract fun appDao(): AppDao
abstract fun repoDao(): RepoDao
abstract fun installedDao(): InstalledDao
}

View File

@@ -0,0 +1,50 @@
package com.looker.core.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Upsert
import com.looker.core.database.model.AppEntity
import com.looker.core.database.model.PackageEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface AppDao {
@Query(value = "SELECT * FROM apps")
fun getAppStream(): Flow<List<AppEntity>>
@Query(
value = """
SELECT * FROM apps
WHERE authorName = :authorName
"""
)
fun getAppsFromAuthor(authorName: String): Flow<List<AppEntity>>
@Query(value = "SELECT * FROM apps WHERE packageName = :packageName")
fun getApp(packageName: String): Flow<List<AppEntity>>
@Query(
value = """
SELECT packages FROM apps
WHERE packageName = :packageName
"""
)
fun getPackages(packageName: String): Flow<List<PackageEntity>>
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertOrIgnore(apps: List<AppEntity>)
@Upsert
suspend fun upsertApps(apps: List<AppEntity>)
@Query(
value = """
DELETE FROM apps
WHERE repoId = :repoId
"""
)
suspend fun deleteApps(repoId: Long)
}

View File

@@ -0,0 +1,18 @@
package com.looker.core.database.dao
import androidx.room.*
import com.looker.core.database.model.InstalledEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface InstalledDao {
@Query("SELECT * FROM installedentity")
fun getInstalledStream(): Flow<List<InstalledEntity>>
@Upsert
suspend fun upsertInstalled(installedEntity: InstalledEntity)
@Delete
suspend fun deleteInstalled(installedEntity: InstalledEntity)
}

View File

@@ -0,0 +1,28 @@
package com.looker.core.database.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import com.looker.core.database.model.RepoEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface RepoDao {
@Query(value = "SELECT * FROM repos")
fun getRepoStream(): Flow<List<RepoEntity>>
@Query(value = "SELECT * FROM repos WHERE id = :id")
suspend fun getRepoById(id: Long): RepoEntity
@Upsert
suspend fun upsertRepo(repoEntity: RepoEntity)
@Query(
value = """
DELETE FROM repos
WHERE id = :id
"""
)
suspend fun deleteRepo(id: Long)
}

View File

@@ -0,0 +1,34 @@
package com.looker.core.database.di
import com.looker.core.database.DroidifyDatabase
import com.looker.core.database.dao.AppDao
import com.looker.core.database.dao.InstalledDao
import com.looker.core.database.dao.RepoDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DaoModule {
@Provides
@Singleton
fun provideRepoDao(
database: DroidifyDatabase
): RepoDao = database.repoDao()
@Provides
@Singleton
fun provideAppDao(
database: DroidifyDatabase
): AppDao = database.appDao()
@Provides
@Singleton
fun provideInstalledDao(
database: DroidifyDatabase
): InstalledDao = database.installedDao()
}

View File

@@ -0,0 +1,26 @@
package com.looker.core.database.di
import android.content.Context
import androidx.room.Room
import com.looker.core.database.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 {
@Provides
@Singleton
fun provideDroidifyDatabase(
@ApplicationContext context: Context
): DroidifyDatabase = Room.databaseBuilder(
context,
DroidifyDatabase::class.java,
"droidify-database"
).createFromAsset("repo.db").build()
}

View File

@@ -0,0 +1,132 @@
package com.looker.core.database.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import com.looker.core.common.nullIfEmpty
import com.looker.core.domain.model.toPackageName
import com.looker.core.database.utils.localizedValue
import com.looker.core.domain.model.App
import com.looker.core.domain.model.Author
import com.looker.core.domain.model.Donation
import com.looker.core.domain.model.Graphics
import com.looker.core.domain.model.Links
import com.looker.core.domain.model.Metadata
import com.looker.core.domain.model.Screenshots
internal typealias LocalizedString = Map<String, String>
internal typealias LocalizedList = Map<String, List<String>>
@Entity(tableName = "apps", primaryKeys = ["repoId", "packageName"])
data class AppEntity(
@ColumnInfo(name = "packageName")
val packageName: String,
@ColumnInfo(name = "repoId")
val repoId: Long,
val categories: List<String>,
val summary: LocalizedString,
val description: LocalizedString,
val changelog: String,
val translation: String,
val issueTracker: String,
val sourceCode: String,
val binaries: String,
val name: LocalizedString,
val authorName: String,
val authorEmail: String,
val authorWebSite: String,
val donate: String,
val liberapayID: String,
val liberapay: String,
val openCollective: String,
val bitcoin: String,
val litecoin: String,
val flattrID: String,
val suggestedVersionName: String,
val suggestedVersionCode: Long,
val license: String,
val webSite: String,
val added: Long,
val icon: LocalizedString,
val phoneScreenshots: LocalizedList,
val sevenInchScreenshots: LocalizedList,
val tenInchScreenshots: LocalizedList,
val wearScreenshots: LocalizedList,
val tvScreenshots: LocalizedList,
val featureGraphic: LocalizedString,
val promoGraphic: LocalizedString,
val tvBanner: LocalizedString,
val video: LocalizedString,
val lastUpdated: Long,
val packages: List<PackageEntity>
)
fun AppEntity.toExternal(locale: String, installed: PackageEntity? = null): App = App(
repoId = repoId,
categories = categories,
links = links(),
metadata = metadata(locale),
screenshots = screenshots(locale),
graphics = graphics(locale),
author = author(),
donation = donations(),
packages = packages.toExternal(locale) { it == installed }
)
fun List<AppEntity>.toExternal(
locale: String,
isInstalled: (AppEntity) -> PackageEntity?
): List<App> = map {
it.toExternal(locale, isInstalled(it))
}
private fun AppEntity.author(): Author = Author(
name = authorName,
email = authorEmail,
web = authorWebSite
)
private fun AppEntity.donations(): Donation = Donation(
regularUrl = donate.nullIfEmpty(),
bitcoinAddress = bitcoin.nullIfEmpty(),
flattrId = flattrID.nullIfEmpty(),
liteCoinAddress = litecoin.nullIfEmpty(),
openCollectiveId = openCollective.nullIfEmpty(),
librePayId = liberapayID.nullIfEmpty(),
librePayAddress = liberapay.nullIfEmpty()
)
private fun AppEntity.graphics(locale: String): Graphics = Graphics(
featureGraphic = featureGraphic.localizedValue(locale) ?: "",
promoGraphic = promoGraphic.localizedValue(locale) ?: "",
tvBanner = tvBanner.localizedValue(locale) ?: "",
video = video.localizedValue(locale) ?: ""
)
private fun AppEntity.links(): Links = Links(
changelog = changelog,
issueTracker = issueTracker,
sourceCode = sourceCode,
translation = translation,
webSite = webSite
)
private fun AppEntity.metadata(locale: String): Metadata = Metadata(
name = name.localizedValue(locale) ?: "",
packageName = packageName.toPackageName(),
added = added,
description = description.localizedValue(locale) ?: "",
icon = icon.localizedValue(locale) ?: "",
lastUpdated = lastUpdated,
license = license,
suggestedVersionCode = suggestedVersionCode,
suggestedVersionName = suggestedVersionName,
summary = summary.localizedValue(locale) ?: ""
)
private fun AppEntity.screenshots(locale: String): Screenshots = Screenshots(
phone = phoneScreenshots.localizedValue(locale) ?: emptyList(),
sevenInch = sevenInchScreenshots.localizedValue(locale) ?: emptyList(),
tenInch = tenInchScreenshots.localizedValue(locale) ?: emptyList(),
tv = tvScreenshots.localizedValue(locale) ?: emptyList(),
wear = wearScreenshots.localizedValue(locale) ?: emptyList()
)

View File

@@ -0,0 +1,12 @@
package com.looker.core.database.model
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity
data class InstalledEntity(
@PrimaryKey
val packageName: String,
val versionCode: Long,
val signature: String
)

View File

@@ -0,0 +1,70 @@
package com.looker.core.database.model
import com.looker.core.database.utils.localizedValue
import com.looker.core.domain.model.ApkFile
import com.looker.core.domain.model.Manifest
import com.looker.core.domain.model.Package
import com.looker.core.domain.model.Permission
import com.looker.core.domain.model.Platforms
import com.looker.core.domain.model.SDKs
import kotlinx.serialization.Serializable
@Serializable
data class PackageEntity(
val added: Long,
val apkName: String,
val hash: String,
val hashType: String,
val minSdkVersion: Int,
val maxSdkVersion: Int,
val targetSdkVersion: Int,
val sig: String,
val signer: String,
val size: Long,
val srcName: String,
val usesPermission: List<PermissionEntity>,
val versionCode: Long,
val versionName: String,
val nativeCode: List<String>,
val features: List<String>,
val antiFeatures: List<String>,
val whatsNew: LocalizedString
)
@Serializable
data class PermissionEntity(
val name: String,
val minSdk: Int? = null,
val maxSdk: Int? = null
)
fun PackageEntity.toExternal(locale: String, installed: Boolean): Package = Package(
installed = installed,
added = added,
apk = ApkFile(
name = apkName,
hash = hash,
size = size
),
manifest = Manifest(
versionCode = versionCode,
versionName = versionName,
usesSDKs = SDKs(minSdkVersion, targetSdkVersion),
signer = setOf(signer),
permissions = usesPermission.map(PermissionEntity::toExternalModel)
),
platforms = Platforms(nativeCode),
features = features,
antiFeatures = antiFeatures,
whatsNew = whatsNew.localizedValue(locale) ?: ""
)
fun List<PackageEntity>.toExternal(
locale: String,
installed: (PackageEntity) -> Boolean
): List<Package> = map { it.toExternal(locale, installed(it)) }
fun PermissionEntity.toExternalModel(): Permission = Permission(
name = name,
sdKs = SDKs(min = minSdk ?: -1, max = maxSdk ?: -1)
)

View File

@@ -0,0 +1,90 @@
package com.looker.core.database.model
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.looker.core.database.utils.localizedValue
import com.looker.core.domain.model.AntiFeature
import com.looker.core.domain.model.Authentication
import com.looker.core.domain.model.Category
import com.looker.core.domain.model.Fingerprint
import com.looker.core.domain.model.Repo
import com.looker.core.domain.model.VersionInfo
import kotlinx.serialization.Serializable
@Entity(tableName = "repos")
data class RepoEntity(
@PrimaryKey(autoGenerate = true)
val id: Long? = null,
val enabled: Boolean,
val fingerprint: String,
val etag: String,
val username: String,
val password: String,
val address: String,
val mirrors: List<String>,
val name: LocalizedString,
val description: LocalizedString,
val antiFeatures: Map<String, AntiFeatureEntity>,
val categories: Map<String, CategoryEntity>,
val timestamp: Long
)
fun RepoEntity.update(repo: Repo) = copy(
username = repo.authentication.username,
password = repo.authentication.password,
timestamp = repo.versionInfo.timestamp,
enabled = repo.enabled,
mirrors = repo.mirrors,
fingerprint = repo.fingerprint?.value ?: ""
)
fun RepoEntity.toExternal(locale: String): Repo = Repo(
id = id!!,
enabled = enabled,
address = address,
name = name.localizedValue(locale) ?: "",
description = description.localizedValue(locale) ?: "",
fingerprint = if (fingerprint.isBlank()) null else Fingerprint(fingerprint),
authentication = Authentication(username, password),
versionInfo = VersionInfo(timestamp = timestamp, etag = etag),
mirrors = mirrors,
categories = categories.values.toCategoryList(locale),
antiFeatures = antiFeatures.values.toAntiFeatureList(locale)
)
fun List<RepoEntity>.toExternal(locale: String): List<Repo> =
map { it.toExternal(locale) }
@Serializable
data class CategoryEntity(
val icon: LocalizedString,
val name: LocalizedString,
val description: LocalizedString
)
private fun CategoryEntity.toCategory(locale: String) =
Category(
name = name.localizedValue(locale) ?: "",
icon = icon.localizedValue(locale) ?: "",
description = description.localizedValue(locale) ?: ""
)
fun Collection<CategoryEntity>.toCategoryList(locale: String): List<Category> =
map { it.toCategory(locale) }
@Serializable
data class AntiFeatureEntity(
val icon: LocalizedString,
val name: LocalizedString,
val description: LocalizedString
)
private fun AntiFeatureEntity.toAntiFeature(locale: String) =
AntiFeature(
name = name.localizedValue(locale) ?: "",
icon = icon.localizedValue(locale) ?: "",
description = description.localizedValue(locale) ?: ""
)
fun Collection<AntiFeatureEntity>.toAntiFeatureList(locale: String): List<AntiFeature> =
map { it.toAntiFeature(locale) }

View File

@@ -0,0 +1,53 @@
package com.looker.core.database.utils
import androidx.core.os.LocaleListCompat
import com.looker.core.common.stripBetween
import java.util.Locale
internal fun localeListCompat(tag: String): LocaleListCompat =
LocaleListCompat.forLanguageTags(tag)
/**
* Find the Localized value from [Map<String,T>] using [locale]
*
* Returns null if none matches or map or [locale] is empty
*/
fun <T> Map<String, T>?.localizedValue(locale: String): T? {
val localeList = localeListCompat(locale)
if (isNullOrEmpty() || localeList.isEmpty) return null
val suitableLocale = localeList.suitableLocale(keys)
return get(suitableLocale)
?: get("en_US")
?: get("en-US")
?: get("en")
?: values.firstOrNull()
}
/**
* Retrieve the most suitable Locale from the [keys] using [LocaleListCompat]
*
* Returns null if none found
*/
internal fun LocaleListCompat.suitableLocale(keys: Set<String>): String? = (0..<size())
.asSequence()
.mapNotNull { get(it).suitableTag(keys) }
.firstOrNull()
/**
* Get the suitable tag for [Locale] from [keys]
*
* Returns null if [keys] are empty or [Locale] in null
*/
internal fun Locale?.suitableTag(keys: Set<String>): String? {
if (keys.isEmpty()) return null
val currentLocale = this ?: return null
val tag = currentLocale.toLanguageTag()
val soloTag = currentLocale.language
val strippedTag = tag.stripBetween("-")
return if (tag in keys) tag
else if (strippedTag in keys) strippedTag
else if (soloTag in keys) soloTag
// try children of the language
else keys.find { it.startsWith(soloTag) }
}

View File

@@ -0,0 +1,195 @@
package com.looker.core.database
import androidx.core.os.LocaleListCompat
import androidx.core.os.LocaleListCompat.getEmptyLocaleList
import com.looker.core.database.utils.localeListCompat
import com.looker.core.database.utils.localizedValue
import com.looker.core.database.utils.suitableLocale
import com.looker.core.database.utils.suitableTag
import org.junit.Test
import java.util.Locale
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
*
* This code is copyrighted to (F-Droid.org), I merely rewrote it.
* Tests based on F-Droid's BestLocaleTest [https://gitlab.com/fdroid/fdroidclient/-/blob/680a1154cf3806390c2e4a9e95a7c6d6107b470f/libs/index/src/androidAndroidTest/kotlin/org/fdroid/BestLocaleTest.kt]
*
* https://developer.android.com/guide/topics/resources/multilingual-support#resource-resolution-examples
*/
class LocalizationTest {
@Test
fun `Get correct localeList`() {
assertEquals(
LocaleListCompat.create(Locale.ENGLISH, Locale.US),
localeListCompat("en,en-US")
)
}
@Test
fun `Return empty locale on none match`() {
assertNull(emptyMap<String, String>().localizedValue("en-US,de-DE"))
assertNull(getMap("en-US", "de-DE").localizedValue(""))
}
@Test
fun `Fallback to english`() {
assertEquals(
"en",
getMap("de-AT", "de-DE", "en").localizedValue("fr-FR")
)
assertEquals(
"en-US",
getMap("en", "en-US").localizedValue("zh-Hant-TW,zh-Hans-CN")
)
}
@Test
fun `Use the first selected locale, en_US`() {
assertEquals(
"en-US",
getMap("de-AT", "de-DE", "en-US").localizedValue("en-US,de-DE")
)
}
@Test
fun `Use the first en translation`() {
assertEquals(
"en-US",
getMap("de-AT", "de-DE", "en-US").localizedValue("en-SE,de-DE")
)
}
@Test
fun `Use the first full match against a non-default locale`() {
assertEquals(
"de-AT",
getMap(
"de-AT",
"de-DE",
"en-GB",
"en-US"
).localizedValue("de-AT,de-DE")
)
assertEquals(
"de",
getMap("de-AT", "de", "en-GB", "en-US").localizedValue("de-CH,en-US")
)
}
@Test
fun `Stripped locale tag`() {
assertEquals(
"zh-TW",
getMap(
"en-US",
"zh-CN",
"zh-HK",
"zh-TW"
).localizedValue("zh-Hant-TW,zh-Hans-CN")
)
}
@Test
fun `Google specified test`() {
assertEquals(
"fr-FR",
getMap("en-US", "de-DE", "es-ES", "fr-FR", "it-IT")
.localizedValue("fr-CH")
)
assertEquals(
"it-IT",
getMap("en-US", "de-DE", "es-ES", "it-IT")
.localizedValue("fr-CH,it-CH")
)
}
@Test
fun `Check null for suitable locale from list`() {
assertNull(localeListCompat("en-US").suitableLocale(keys("de-DE", "es-ES")))
assertNull(localeListCompat("en-US").suitableLocale(keys()))
assertNull(getEmptyLocaleList().suitableLocale(keys("de-DE", "es-ES")))
}
@Test
fun `Find suitable locale from wrong list`() {
assertNull(localeListCompat("en-US").suitableLocale(keys("de-DE", "es-ES")))
}
@Test
fun `Find suitable locale from list without modification`() {
assertEquals(
"en-US",
localeListCompat("en-US").suitableLocale(keys("en", "en-US", "en-UK"))
)
}
@Test
fun `Find suitable locale from list only with language`() {
assertEquals(
"en",
localeListCompat("en-US").suitableLocale(keys("de-DE", "fr-FR", "en-UK", "en"))
)
}
@Test
fun `Find stripped locale from the list`() {
assertEquals(
"zh-TW",
localeListCompat("zh-Hant-TW").suitableLocale(
keys(
"en",
"de-DE",
"fr-FR",
"zh-TW",
"zh"
)
)
)
}
@Test
fun `Check null for suitable locale`() {
val locale: Locale? = null
assertNull(locale.suitableTag(keys("en-US", "de-DE", "es-ES", "it-IT")))
assertNull(Locale.ENGLISH.suitableTag(keys()))
}
@Test
fun `Find suitable locale from wrong keys`() {
assertNull(Locale.ENGLISH.suitableTag(keys("de-DE", "es-ES")))
}
@Test
fun `Get suitable locale without modification`() {
assertEquals("en-US", Locale("en", "US").suitableTag(keys("en", "en-US", "en-UK")))
}
@Test
fun `Get suitable locale with only language`() {
assertEquals("en", Locale("en", "US").suitableTag(keys("en", "de-DE", "fr-FR")))
}
@Test
fun `Get suitable locale with stripped parts`() {
assertEquals(
"zh-TW",
localeListCompat("zh-Hant-TW")[0].suitableTag(
keys(
"en",
"de-DE",
"fr-FR",
"zh-TW",
"zh"
)
)
)
}
private fun keys(vararg tag: String): Set<String> = tag.toSet()
private fun getMap(vararg locales: String): Map<String, String> = locales.associateWith { it }
}