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/data/.gitignore vendored Normal file
View File

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

View File

@@ -0,0 +1,23 @@
plugins {
alias(libs.plugins.looker.android.library)
alias(libs.plugins.looker.hilt.work)
alias(libs.plugins.looker.lint)
}
android {
namespace = "com.looker.core.data"
}
dependencies {
modules(
Modules.coreCommon,
Modules.coreDatabase,
Modules.coreDatastore,
Modules.coreDI,
Modules.coreDomain,
Modules.coreNetwork,
Modules.sync,
)
implementation(libs.kotlinx.coroutines.android)
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
</manifest>

View File

@@ -0,0 +1,26 @@
package com.looker.core.data.di
import com.looker.core.domain.AppRepository
import com.looker.core.domain.RepoRepository
import com.looker.core.data.repository.OfflineFirstAppRepository
import com.looker.core.data.repository.OfflineFirstRepoRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
interface DataModule {
@Binds
fun bindsAppRepository(
appRepository: OfflineFirstAppRepository
): AppRepository
@Binds
fun bindsRepoRepository(
repoRepository: OfflineFirstRepoRepository
): RepoRepository
}

View File

@@ -0,0 +1,132 @@
package com.looker.core.data.fdroid
import com.looker.core.database.model.AntiFeatureEntity
import com.looker.core.database.model.AppEntity
import com.looker.core.database.model.CategoryEntity
import com.looker.core.database.model.PackageEntity
import com.looker.core.database.model.PermissionEntity
import com.looker.core.database.model.RepoEntity
import com.looker.sync.fdroid.v2.model.PackageV2
import com.looker.sync.fdroid.v2.model.RepoV2
import com.looker.sync.fdroid.v2.model.VersionV2
fun PackageV2.toEntity(
packageName: String,
repoId: Long,
allowUnstable: Boolean = false
): AppEntity =
AppEntity(
repoId = repoId,
packageName = packageName,
categories = metadata.categories,
summary = metadata.summary ?: emptyMap(),
description = metadata.description ?: emptyMap(),
changelog = metadata.changelog ?: "",
translation = metadata.translation ?: "",
issueTracker = metadata.issueTracker ?: "",
sourceCode = metadata.sourceCode ?: "",
binaries = "",
name = metadata.name ?: emptyMap(),
authorName = metadata.authorName ?: "",
authorEmail = metadata.authorEmail ?: "",
authorWebSite = metadata.authorWebsite ?: "",
donate = metadata.donate.firstOrNull() ?: "",
liberapayID = metadata.liberapay ?: "",
liberapay = metadata.liberapay ?: "",
openCollective = metadata.openCollective ?: "",
bitcoin = metadata.bitcoin ?: "",
litecoin = metadata.litecoin ?: "",
flattrID = metadata.flattrID ?: "",
suggestedVersionCode = versions.values.firstOrNull()?.manifest?.versionCode ?: -1,
suggestedVersionName = versions.values.firstOrNull()?.manifest?.versionName ?: "",
license = metadata.license ?: "",
webSite = metadata.webSite ?: "",
added = metadata.added,
icon = metadata.icon?.mapValues { it.value.name } ?: emptyMap(),
lastUpdated = metadata.lastUpdated,
phoneScreenshots = metadata.screenshots?.phone?.mapValues { it.value.map { it.name } }
?: emptyMap(),
tenInchScreenshots = metadata.screenshots?.tenInch?.mapValues { it.value.map { it.name } }
?: emptyMap(),
sevenInchScreenshots = metadata.screenshots?.sevenInch
?.mapValues { it.value.map { it.name } } ?: emptyMap(),
tvScreenshots = metadata.screenshots?.tv?.mapValues { it.value.map { it.name } }
?: emptyMap(),
wearScreenshots = metadata.screenshots?.wear?.mapValues { it.value.map { it.name } }
?: emptyMap(),
featureGraphic = metadata.featureGraphic?.mapValues { it.value.name } ?: emptyMap(),
promoGraphic = metadata.promoGraphic?.mapValues { it.value.name } ?: emptyMap(),
tvBanner = metadata.tvBanner?.mapValues { it.value.name } ?: emptyMap(),
video = metadata.video ?: emptyMap(),
packages = versions.values.map(VersionV2::toPackage).checkUnstable(
allowUnstable,
versions.values.firstOrNull()?.manifest?.versionCode ?: -1
)
)
private fun List<PackageEntity>.checkUnstable(
allowUnstable: Boolean,
suggestedVersionCode: Long
): List<PackageEntity> = filter {
allowUnstable || (suggestedVersionCode > 0L && it.versionCode >= suggestedVersionCode)
}
fun VersionV2.toPackage(): PackageEntity = PackageEntity(
added = added,
hash = file.sha256!!,
features = manifest.features.map { it.name },
apkName = file.name,
hashType = "SHA-256",
minSdkVersion = manifest.minSdkVersion ?: -1,
maxSdkVersion = manifest.maxSdkVersion ?: -1,
signer = manifest.signer?.sha256?.firstOrNull() ?: "",
size = file.size ?: -1,
usesPermission = manifest.usesPermission.map {
PermissionEntity(name = it.name, maxSdk = it.maxSdkVersion)
} + manifest.usesPermissionSdk23.map {
PermissionEntity(name = it.name, maxSdk = it.maxSdkVersion, minSdk = 23)
},
versionCode = manifest.versionCode,
versionName = manifest.versionName,
srcName = src?.name ?: "",
nativeCode = manifest.nativecode,
antiFeatures = antiFeatures.keys.toList(),
targetSdkVersion = manifest.usesSdk?.targetSdkVersion ?: -1,
sig = signer?.sha256?.firstOrNull() ?: "",
whatsNew = whatsNew
)
fun RepoV2.toEntity(
id: Long,
fingerprint: String,
etag: String,
username: String,
password: String,
enabled: Boolean = true
) = RepoEntity(
id = id,
enabled = enabled,
fingerprint = fingerprint,
mirrors = mirrors.map { it.url },
address = address,
name = name,
description = description,
timestamp = timestamp,
etag = etag,
username = username,
password = password,
antiFeatures = antiFeatures.mapValues {
AntiFeatureEntity(
name = it.value.name,
icon = it.value.icon.mapValues { it.value.name },
description = it.value.description
)
},
categories = categories.mapValues {
CategoryEntity(
name = it.value.name,
icon = it.value.icon.mapValues { it.value.name },
description = it.value.description
)
}
)

View File

@@ -0,0 +1,48 @@
package com.looker.core.data.fdroid.sync.workers
import android.content.Context
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import kotlin.reflect.KClass
@EntryPoint
@InstallIn(SingletonComponent::class)
interface HiltWorkerFactoryEntryPoint {
fun hiltWorkerFactory(): HiltWorkerFactory
}
private const val WORKER_CLASS_NAME = "RouterWorkerDelegateClassName"
internal fun KClass<out CoroutineWorker>.delegatedData() =
Data.Builder()
.putString(WORKER_CLASS_NAME, qualifiedName)
.build()
internal class DelegatingWorker(
appContext: Context,
workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {
private val workerClassName =
workerParams.inputData.getString(WORKER_CLASS_NAME) ?: ""
private val delegateWorker =
EntryPointAccessors.fromApplication<HiltWorkerFactoryEntryPoint>(appContext)
.hiltWorkerFactory()
.createWorker(appContext, workerClassName, workerParams)
as? CoroutineWorker
?: throw IllegalArgumentException("Unable to find appropriate worker")
override suspend fun getForegroundInfo(): ForegroundInfo =
delegateWorker.getForegroundInfo()
override suspend fun doWork(): Result =
delegateWorker.doWork()
}

View File

@@ -0,0 +1,42 @@
package com.looker.core.data.fdroid.sync.workers
import android.app.Notification
import android.content.Context
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.work.ForegroundInfo
import com.looker.core.common.createNotificationChannel
import com.looker.core.common.R as CommonR
private const val SyncNotificationID = 12
private const val SyncNotificationChannelID = "SyncNotificationChannelID"
fun Context.syncForegroundInfo() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ForegroundInfo(
SyncNotificationID,
syncWorkNotification(),
FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
} else {
ForegroundInfo(
SyncNotificationID,
syncWorkNotification(),
)
}
private fun Context.syncWorkNotification(): Notification {
createNotificationChannel(
id = SyncNotificationChannelID,
name = getString(CommonR.string.sync_repositories),
description = getString(CommonR.string.sync_repositories),
)
return NotificationCompat.Builder(
this,
SyncNotificationChannelID
)
.setSmallIcon(CommonR.drawable.ic_sync)
.setContentTitle(getString(CommonR.string.syncing))
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}

View File

@@ -0,0 +1,81 @@
package com.looker.core.data.fdroid.sync.workers
import android.content.Context
import android.util.Log
import androidx.hilt.work.HiltWorker
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.ForegroundInfo
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.looker.core.domain.RepoRepository
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted private val appContext: Context,
@Assisted workParams: WorkerParameters,
private val repoRepository: RepoRepository
) : CoroutineWorker(appContext, workParams) {
override suspend fun getForegroundInfo(): ForegroundInfo =
appContext.syncForegroundInfo()
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
Log.i(SYNC_WORK, "Start Sync")
setForegroundAsync(appContext.syncForegroundInfo())
val isSuccess = try {
repoRepository.syncAll()
} catch (e: Exception) {
e.printStackTrace()
return@withContext Result.failure()
}
if (isSuccess) Result.success() else Result.failure()
}
companion object {
private const val SYNC_WORK = "sync_work"
fun cancelSyncWork(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(SYNC_WORK)
}
fun scheduleSyncWork(context: Context, constraints: Constraints) {
WorkManager.getInstance(context).apply {
val work = PeriodicWorkRequestBuilder<DelegatingWorker>(12L, TimeUnit.HOURS)
.setConstraints(constraints)
.setInputData(SyncWorker::class.delegatedData())
.build()
enqueueUniquePeriodicWork(SYNC_WORK, ExistingPeriodicWorkPolicy.REPLACE, work)
}
}
fun startSyncWork(context: Context) {
WorkManager.getInstance(context).apply {
val netRequired = Constraints(
requiredNetworkType = NetworkType.CONNECTED
)
val work = OneTimeWorkRequestBuilder<DelegatingWorker>()
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.setConstraints(netRequired)
.setInputData(SyncWorker::class.delegatedData())
.build()
beginUniqueWork(
SYNC_WORK,
ExistingWorkPolicy.REPLACE,
work
).enqueue()
}
}
}
}

View File

@@ -0,0 +1,85 @@
package com.looker.core.data.repository
import com.looker.core.domain.model.PackageName
import com.looker.core.domain.AppRepository
import com.looker.core.database.dao.AppDao
import com.looker.core.database.dao.InstalledDao
import com.looker.core.database.model.AppEntity
import com.looker.core.database.model.InstalledEntity
import com.looker.core.database.model.PackageEntity
import com.looker.core.database.model.toExternal
import com.looker.core.datastore.SettingsRepository
import com.looker.core.datastore.get
import com.looker.core.domain.model.App
import com.looker.core.domain.model.Author
import com.looker.core.domain.model.Package
import javax.inject.Inject
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
class OfflineFirstAppRepository @Inject constructor(
installedDao: InstalledDao,
private val appDao: AppDao,
private val settingsRepository: SettingsRepository
) : AppRepository {
private val localePreference = settingsRepository.get { language }
private val installedFlow = installedDao.getInstalledStream()
override fun getApps(): Flow<List<App>> =
appDao.getAppStream().localizedAppList(localePreference, installedFlow)
override fun getApp(packageName: PackageName): Flow<List<App>> =
appDao.getApp(packageName.name).localizedAppList(localePreference, installedFlow)
override fun getAppFromAuthor(author: Author): Flow<List<App>> =
appDao.getAppsFromAuthor(author.name).localizedAppList(localePreference, installedFlow)
override fun getPackages(packageName: PackageName): Flow<List<Package>> =
appDao.getPackages(packageName.name)
.localizedPackages(packageName, localePreference, installedFlow)
override suspend fun addToFavourite(packageName: PackageName): Boolean = coroutineScope {
val isFavourite =
async {
settingsRepository
.getInitial()
.favouriteApps
.any { it == packageName.name }
}
launch {
settingsRepository.toggleFavourites(packageName.name)
}
!isFavourite.await()
}
}
private fun Flow<List<AppEntity>>.localizedAppList(
preference: Flow<String>,
installedFlow: Flow<List<InstalledEntity>>
): Flow<List<App>> =
combine(this, preference, installedFlow) { appsList, locale, installedList ->
appsList.toExternal(locale) {
it.findInstalled(installedList)
}
}
private fun Flow<List<PackageEntity>>.localizedPackages(
packageName: PackageName,
preference: Flow<String>,
installedFlow: Flow<List<InstalledEntity>>
): Flow<List<Package>> =
combine(this, preference, installedFlow) { packagesList, locale, installedList ->
packagesList.toExternal(locale) {
InstalledEntity(packageName.name, it.versionCode, it.sig) in installedList
}
}
private fun AppEntity.findInstalled(list: List<InstalledEntity>): PackageEntity? =
packages.find {
InstalledEntity(packageName, it.versionCode, it.sig) in list
}

View File

@@ -0,0 +1,108 @@
package com.looker.core.data.repository
import android.content.Context
import com.looker.core.common.extension.exceptCancellation
import com.looker.core.data.fdroid.toEntity
import com.looker.core.database.dao.AppDao
import com.looker.core.database.dao.RepoDao
import com.looker.core.database.model.toExternal
import com.looker.core.database.model.update
import com.looker.core.datastore.SettingsRepository
import com.looker.core.di.ApplicationScope
import com.looker.core.di.DefaultDispatcher
import com.looker.core.domain.RepoRepository
import com.looker.core.domain.model.Repo
import com.looker.network.Downloader
import com.looker.sync.fdroid.v2.EntrySyncable
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
import javax.inject.Inject
class OfflineFirstRepoRepository @Inject constructor(
@ApplicationContext context: Context,
private val appDao: AppDao,
private val repoDao: RepoDao,
private val settingsRepository: SettingsRepository,
downloader: Downloader,
@DefaultDispatcher private val dispatcher: CoroutineDispatcher,
@ApplicationScope private val scope: CoroutineScope
) : RepoRepository {
private val preference = runBlocking {
settingsRepository.getInitial()
}
private val locale = preference.language
override suspend fun getRepo(id: Long): Repo = withContext(dispatcher) {
repoDao.getRepoById(id).toExternal(locale)
}
override fun getRepos(): Flow<List<Repo>> =
repoDao.getRepoStream().map { it.toExternal(locale) }
override suspend fun updateRepo(repo: Repo) {
scope.launch {
val entity = repoDao.getRepoById(repo.id)
repoDao.upsertRepo(entity.update(repo))
}
}
override suspend fun enableRepository(repo: Repo, enable: Boolean) {
scope.launch {
val entity = repoDao.getRepoById(repo.id)
repoDao.upsertRepo(entity.copy(enabled = enable))
if (enable) sync(repo)
}
}
private val syncable = EntrySyncable(context, downloader, dispatcher)
override suspend fun sync(repo: Repo): Boolean = coroutineScope {
try {
val (fingerprint, indexV2) = syncable.sync(repo)
if (indexV2 == null) return@coroutineScope true
val updatedRepo = indexV2.repo.toEntity(
id = repo.id,
fingerprint = fingerprint.value,
etag = "",
username = repo.authentication.username,
password = repo.authentication.password,
)
val apps = indexV2.packages
.map { (packageName, packageV2) ->
packageV2.toEntity(
packageName = packageName,
repoId = repo.id,
allowUnstable = preference.unstableUpdate,
)
}
repoDao.upsertRepo(updatedRepo)
appDao.upsertApps(apps)
true
} catch (e: Exception) {
e.exceptCancellation()
false
}
}
override suspend fun syncAll(): Boolean = supervisorScope {
val repos = repoDao
.getRepoStream()
.first()
.filter { it.enabled }
repos.forEach {
sync(it.toExternal("en-US"))
}
true
}
}

View File

@@ -0,0 +1,55 @@
package com.looker.core.data.utils
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import com.looker.core.common.extension.connectivityManager
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate
class ConnectivityManagerNetworkMonitor
@Inject constructor(
@ApplicationContext context: Context
) : NetworkMonitor {
override val isOnline: Flow<Boolean> = callbackFlow {
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
channel.trySend(true)
}
override fun onLost(network: Network) {
channel.trySend(false)
}
}
val connectivityManager = context.connectivityManager
connectivityManager?.registerNetworkCallback(
NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build(),
callback
)
channel.trySend(connectivityManager.isCurrentlyConnected())
awaitClose {
connectivityManager?.unregisterNetworkCallback(callback)
}
}.conflate()
private fun ConnectivityManager?.isCurrentlyConnected() = when (this) {
null -> false
else ->
activeNetwork
?.let(::getNetworkCapabilities)
?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
?: false
}
}

View File

@@ -0,0 +1,7 @@
package com.looker.core.data.utils
import kotlinx.coroutines.flow.Flow
interface NetworkMonitor {
val isOnline: Flow<Boolean>
}

View File

@@ -0,0 +1,7 @@
package com.looker.core.data.utils
import kotlinx.coroutines.flow.Flow
interface SyncStatusMonitor {
val isSyncing: Flow<Boolean>
}