From 3fd8f275f062429a98985b77ae655525d4d6c3f2 Mon Sep 17 00:00:00 2001 From: Semper-Viventem Date: Fri, 21 Nov 2025 20:33:43 -0800 Subject: [PATCH 1/4] Update deps, optimize devices db galls --- app/build.gradle.kts | 4 +- .../java/f/cking/software/data/DataMappers.kt | 2 +- .../data/database/dao/AppleContactDao.kt | 3 ++ .../software/data/database/dao/DeviceDao.kt | 4 ++ .../software/data/repo/DevicesRepository.kt | 49 ++++++++++--------- .../interactor/ClearGarbageInteractor.kt | 1 + .../ui/devicelist/DeviceListViewModel.kt | 20 ++++---- gradle/libs.versions.toml | 10 ++-- 8 files changed, 51 insertions(+), 42 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index be125364..cd5a642d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,8 +26,8 @@ android { minSdk = 29 targetSdk = 36 - versionCode = 1708536378 - versionName = "0.31.1-beta" + versionCode = 1708536379 + versionName = "0.31.2-beta" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/java/f/cking/software/data/DataMappers.kt b/app/src/main/java/f/cking/software/data/DataMappers.kt index 7fb4eea7..590afed1 100644 --- a/app/src/main/java/f/cking/software/data/DataMappers.kt +++ b/app/src/main/java/f/cking/software/data/DataMappers.kt @@ -38,7 +38,7 @@ fun LocationEntity.toDomain(): LocationModel { return LocationModel(lat, lng, time) } -fun DeviceEntity.toDomain(appleAirDrop: AppleAirDrop?): DeviceData { +fun DeviceEntity.toDomain(appleAirDrop: AppleAirDrop? = null): DeviceData { return DeviceData( address = address, name = name, diff --git a/app/src/main/java/f/cking/software/data/database/dao/AppleContactDao.kt b/app/src/main/java/f/cking/software/data/database/dao/AppleContactDao.kt index bc39f661..b48ffdeb 100644 --- a/app/src/main/java/f/cking/software/data/database/dao/AppleContactDao.kt +++ b/app/src/main/java/f/cking/software/data/database/dao/AppleContactDao.kt @@ -29,4 +29,7 @@ interface AppleContactDao { @Insert(onConflict = OnConflictStrategy.REPLACE) fun insertAll(contacts: List) + + @Query("DELETE FROM apple_contacts WHERE associated_address IN (:addresses)") + fun deleteAllByAddresses(addresses: List) } \ No newline at end of file diff --git a/app/src/main/java/f/cking/software/data/database/dao/DeviceDao.kt b/app/src/main/java/f/cking/software/data/database/dao/DeviceDao.kt index e803758d..a90d4a6b 100644 --- a/app/src/main/java/f/cking/software/data/database/dao/DeviceDao.kt +++ b/app/src/main/java/f/cking/software/data/database/dao/DeviceDao.kt @@ -5,6 +5,7 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import f.cking.software.data.database.entity.DeviceEntity +import kotlinx.coroutines.flow.Flow @Dao interface DeviceDao { @@ -12,6 +13,9 @@ interface DeviceDao { @Query("SELECT * FROM device") fun getAll(): List + @Query("SELECT * FROM device") + fun observeAll(): Flow> + @Query("SELECT * FROM device ORDER BY last_detect_time_ms DESC LIMIT :limit OFFSET :offset") fun getPaginated(offset: Int, limit: Int): List diff --git a/app/src/main/java/f/cking/software/data/repo/DevicesRepository.kt b/app/src/main/java/f/cking/software/data/repo/DevicesRepository.kt index 0cb70cf3..070baad8 100644 --- a/app/src/main/java/f/cking/software/data/repo/DevicesRepository.kt +++ b/app/src/main/java/f/cking/software/data/repo/DevicesRepository.kt @@ -11,8 +11,10 @@ import f.cking.software.domain.toDomain import f.cking.software.splitToBatches import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -23,7 +25,8 @@ class DevicesRepository( private val deviceDao: DeviceDao = appDatabase.deviceDao() private val appleContactsDao = appDatabase.appleContactDao() private val lastBatch = MutableStateFlow(emptyList()) - private val allDevices = MutableStateFlow(emptyList()) + private val allDevices = deviceDao.observeAll() + .map { it.toDomainWithAirDrop() } suspend fun getDevices(): List { return withContext(Dispatchers.IO) { @@ -54,12 +57,8 @@ class DevicesRepository( lastBatch.value = emptyList() } - suspend fun observeAllDevices(): StateFlow> { - return allDevices.apply { - if (allDevices.value.isEmpty()) { - notifyListeners() - } - } + fun observeAllDevices(): Flow> { + return allDevices } suspend fun observeLastBatch(): StateFlow> { @@ -102,6 +101,17 @@ class DevicesRepository( } } + suspend fun clearUnAssociatedAirdrops() { + withContext(Dispatchers.IO) { + val allDevices = deviceDao.getAll().mapTo(mutableSetOf()) { it.address } + val allAidrops = appleContactsDao.getAll().map { it.associatedAddress } + + val unassotiatedAirdrops = allAidrops.filter { !allDevices.contains(it) } + + appleContactsDao.deleteAllByAddresses(unassotiatedAirdrops) + } + } + suspend fun getAllByAddresses(addresses: List): List { return withContext(Dispatchers.IO) { addresses.splitToBatches(DatabaseUtils.getMaxSQLVariablesNumber()).flatMap { @@ -149,14 +159,10 @@ class DevicesRepository( private suspend fun notifyListeners() { coroutineScope { - launch { + launch(Dispatchers.Default) { val data = getLastBatch() lastBatch.emit(data) } - launch { - val data = getDevices() - allDevices.emit(data) - } } } @@ -168,20 +174,15 @@ class DevicesRepository( } private suspend fun List.toDomainWithAirDrop(): List { - return withContext(Dispatchers.IO) { - - val allRelatedContacts = - splitToBatches(DatabaseUtils.getMaxSQLVariablesNumber()).flatMap { batch -> - appleContactsDao.getByAddresses(batch.map { it.address }) - } + return withContext(Dispatchers.Default) { + val allRelatedContacts = withContext(Dispatchers.IO) { + appleContactsDao.getAll().groupBy { it.associatedAddress } + } map { device -> - val airdrop = allRelatedContacts.asSequence() - .filter { it.associatedAddress == device.address } - .map { it.toDomain() } - .toList() - .takeIf { it.isNotEmpty() } - ?.let { AppleAirDrop(it) } + val airdrop = allRelatedContacts[device.address]?.let { + AppleAirDrop(it.map { it.toDomain() }) + } device.toDomain(airdrop) } diff --git a/app/src/main/java/f/cking/software/domain/interactor/ClearGarbageInteractor.kt b/app/src/main/java/f/cking/software/domain/interactor/ClearGarbageInteractor.kt index 23ce69a9..fa0156f0 100644 --- a/app/src/main/java/f/cking/software/domain/interactor/ClearGarbageInteractor.kt +++ b/app/src/main/java/f/cking/software/domain/interactor/ClearGarbageInteractor.kt @@ -23,6 +23,7 @@ class ClearGarbageInteractor( .toList() devicesRepository.deleteAllByAddress(devices) + devicesRepository.clearUnAssociatedAirdrops() locationRepository.removeDeviceLocationsByAddresses(devices) devices.count() } diff --git a/app/src/main/java/f/cking/software/ui/devicelist/DeviceListViewModel.kt b/app/src/main/java/f/cking/software/ui/devicelist/DeviceListViewModel.kt index 24e8f8c0..6d19585a 100644 --- a/app/src/main/java/f/cking/software/ui/devicelist/DeviceListViewModel.kt +++ b/app/src/main/java/f/cking/software/ui/devicelist/DeviceListViewModel.kt @@ -27,7 +27,6 @@ import f.cking.software.mapParallel import f.cking.software.service.BgScanService import f.cking.software.splitToBatches import f.cking.software.ui.ScreenNavigationCommands -import f.cking.software.ui.devicelist.DeviceListViewModel.ActiveScannerExpandedState.entries import f.cking.software.utils.navigation.Router import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -200,6 +199,7 @@ class DeviceListViewModel( @OptIn(ExperimentalCoroutinesApi::class) private fun observeAllDevices(): Job { + isLoading = true return viewModelScope.launch { combine( appliedFilter, @@ -207,17 +207,17 @@ class DeviceListViewModel( devicesRepository.observeAllDevices(), ) { filters, query, devices -> Triple(filters, query, devices) } .flatMapLatest { (filters, query, devices) -> - flow { - val result = withContext(Dispatchers.Default) { - isLoading = true - devices - .withFilters(filters, query) - .sortedWith(GENERAL_COMPARATOR) - .apply { showEnjoyTheAppIfNeeded() } + flow { + val result = withContext(Dispatchers.Default) { + isLoading = true + devices + .withFilters(filters, query) + .sortedWith(GENERAL_COMPARATOR) + .apply { showEnjoyTheAppIfNeeded() } + } + emit(result) } - emit(result) } - } .onStart { isLoading = true } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 60208ec2..7335b983 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,15 +1,15 @@ [versions] # You also need to bump the version of ksp, anvil, compose -kotlin-general = "2.2.20" +kotlin-general = "2.2.21" kotlinx = "1.10.2" ksp = "2.2.20-2.0.3" anvil = "2.4.2" -android-gradle = "8.13.0" +android-gradle = "8.13.1" protobuf = "3.21.7" protobuf-gradle = "0.9.1" # Protobuf JVM library https://github.com/flipperdevices/flipperzero-protobuf-jvm protobuf-jvm = "0.12.0-0.3.0" -compose = "1.9.2" +compose = "1.9.5" compose-icons = "1.7.8" compose-wear = "1.5.2" compose-compiler = "1.5.6" @@ -23,7 +23,7 @@ wear = "1.2.0" wear-gms = "18.0.0" wear-interaction-phone = "1.1.0-alpha03" wear-interaction-remote = "1.0.0" -room = "2.8.1" +room = "2.8.4" dagger = "2.44" timber = "5.0.1" timber-treessence = "1.0.5" @@ -49,7 +49,7 @@ datastore = "1.1.2" # https://github.com/vsch/flexmark-java/issues/442 flexmark = "0.42.14" markdown = "0.1.5" -ktor = "3.3.0" +ktor = "3.3.2" apache-compress = "1.21" apache-codec = "1.15" countly = "22.06.0" From cf6517ecd8b2828f261c373b2caec2f6e9f44baf Mon Sep 17 00:00:00 2001 From: Semper-Viventem Date: Fri, 21 Nov 2025 21:07:55 -0800 Subject: [PATCH 2/4] Optimize journal performance --- .../software/data/database/dao/JournalDao.kt | 4 ++ .../data/database/dao/RadarProfileDao.kt | 7 +++ .../software/data/repo/JournalRepository.kt | 27 ++++----- .../data/repo/RadarProfilesRepository.kt | 33 ++++++----- .../software/ui/journal/JournalViewModel.kt | 57 ++++++++++++++----- 5 files changed, 80 insertions(+), 48 deletions(-) diff --git a/app/src/main/java/f/cking/software/data/database/dao/JournalDao.kt b/app/src/main/java/f/cking/software/data/database/dao/JournalDao.kt index 81883e90..bdbedb73 100644 --- a/app/src/main/java/f/cking/software/data/database/dao/JournalDao.kt +++ b/app/src/main/java/f/cking/software/data/database/dao/JournalDao.kt @@ -5,6 +5,7 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import f.cking.software.data.database.entity.JournalEntryEntity +import kotlinx.coroutines.flow.Flow @Dao interface JournalDao { @@ -12,6 +13,9 @@ interface JournalDao { @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(journalEntryEntity: JournalEntryEntity) + @Query("SELECT * FROM journal") + fun observe(): Flow> + @Query("SELECT * FROM journal") fun getAll(): List diff --git a/app/src/main/java/f/cking/software/data/database/dao/RadarProfileDao.kt b/app/src/main/java/f/cking/software/data/database/dao/RadarProfileDao.kt index b324adb6..2485432b 100644 --- a/app/src/main/java/f/cking/software/data/database/dao/RadarProfileDao.kt +++ b/app/src/main/java/f/cking/software/data/database/dao/RadarProfileDao.kt @@ -7,6 +7,7 @@ import androidx.room.Query import f.cking.software.data.database.entity.LocationEntity import f.cking.software.data.database.entity.ProfileDetectEntity import f.cking.software.data.database.entity.RadarProfileEntity +import kotlinx.coroutines.flow.Flow @Dao interface RadarProfileDao { @@ -14,9 +15,15 @@ interface RadarProfileDao { @Query("SELECT * FROM radar_profile") fun getAll(): List + @Query("SELECT * FROM radar_profile") + fun observe(): Flow> + @Query("SELECT * FROM radar_profile WHERE id = :id") fun getById(id: Int): RadarProfileEntity? + @Query("SELECT * FROM radar_profile WHERE id IN (:ids)") + fun getAllById(ids: List): List + @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(radarProfile: RadarProfileEntity) diff --git a/app/src/main/java/f/cking/software/data/repo/JournalRepository.kt b/app/src/main/java/f/cking/software/data/repo/JournalRepository.kt index 1d4216c3..3c2e24b9 100644 --- a/app/src/main/java/f/cking/software/data/repo/JournalRepository.kt +++ b/app/src/main/java/f/cking/software/data/repo/JournalRepository.kt @@ -6,28 +6,26 @@ import f.cking.software.domain.toData import f.cking.software.domain.toDomain import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext -class JournalRepository( - private val database: AppDatabase, -) { +class JournalRepository(database: AppDatabase) { - val journalDao = database.journalDao() - private val journal = MutableStateFlow(emptyList()) - - suspend fun observe(): Flow> { - return journal.apply { - if (journal.value.isEmpty()) { - notifyListeners() + private val journalDao = database.journalDao() + private val journal = journalDao.observe() + .map { + withContext(Dispatchers.Default) { + it.map { it.toDomain() } } } + + fun observe(): Flow> { + return journal } suspend fun newEntry(journalEntry: JournalEntry) { withContext(Dispatchers.IO) { journalDao.insert(journalEntry.toData()) - notifyListeners() } } @@ -42,9 +40,4 @@ class JournalRepository( journalDao.getById(id)?.toDomain() } } - - private suspend fun notifyListeners() { - val data = getAllEntries() - journal.emit(data) - } } \ No newline at end of file diff --git a/app/src/main/java/f/cking/software/data/repo/RadarProfilesRepository.kt b/app/src/main/java/f/cking/software/data/repo/RadarProfilesRepository.kt index c5d38dcf..192a5dcf 100644 --- a/app/src/main/java/f/cking/software/data/repo/RadarProfilesRepository.kt +++ b/app/src/main/java/f/cking/software/data/repo/RadarProfilesRepository.kt @@ -7,8 +7,8 @@ import f.cking.software.domain.model.RadarProfile import f.cking.software.domain.toData import f.cking.software.domain.toDomain import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext class RadarProfilesRepository( @@ -16,15 +16,15 @@ class RadarProfilesRepository( ) { val dao = database.radarProfileDao() - private val allProfiles = MutableStateFlow(emptyList()) - - suspend fun observeAllProfiles(): StateFlow> { - return withContext(Dispatchers.IO) { - if (allProfiles.value.isEmpty()) { - notifyListeners() + private val allProfiles = dao.observe() + .map { + withContext(Dispatchers.Default) { + it.map { it.toDomain() } } - allProfiles } + + suspend fun observeAllProfiles(): Flow> { + return allProfiles } suspend fun getAllProfiles(): List { @@ -33,23 +33,27 @@ class RadarProfilesRepository( } } - suspend fun getById(id: Int): RadarProfile?{ + suspend fun getById(id: Int): RadarProfile? { return withContext(Dispatchers.IO) { dao.getById(id)?.toDomain() } } + suspend fun getAllByIds(ids: List): List { + return withContext(Dispatchers.IO) { + dao.getAllById(ids).map { it.toDomain() } + } + } + suspend fun saveProfile(profile: RadarProfile) { withContext(Dispatchers.IO) { dao.insert(profile.toData()) - notifyListeners() } } suspend fun deleteProfile(profileId: Int) { withContext(Dispatchers.IO) { dao.delete(profileId) - notifyListeners() } } @@ -70,9 +74,4 @@ class RadarProfilesRepository( dao.getProfileDetectLocations(profileId).map { it.toDomain() } } } - - private suspend fun notifyListeners() { - val data = getAllProfiles() - allProfiles.emit(data) - } } \ No newline at end of file diff --git a/app/src/main/java/f/cking/software/ui/journal/JournalViewModel.kt b/app/src/main/java/f/cking/software/ui/journal/JournalViewModel.kt index efa829d7..7984acec 100644 --- a/app/src/main/java/f/cking/software/ui/journal/JournalViewModel.kt +++ b/app/src/main/java/f/cking/software/ui/journal/JournalViewModel.kt @@ -15,11 +15,16 @@ import f.cking.software.data.repo.DevicesRepository import f.cking.software.data.repo.JournalRepository import f.cking.software.data.repo.RadarProfilesRepository import f.cking.software.dateTimeStringFormat +import f.cking.software.domain.model.DeviceData import f.cking.software.domain.model.JournalEntry +import f.cking.software.domain.model.RadarProfile import f.cking.software.ui.ScreenNavigationCommands import f.cking.software.utils.navigation.Router +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlin.math.min class JournalViewModel( @@ -53,18 +58,41 @@ class JournalViewModel( viewModelScope.launch { journalRepository.observe() .onStart { loading = true } - .collect { update -> + .map { loading = true - journal = update.sortedBy { it.timestamp }.reversed().map { map(it) } + mapJournalHistory(it) + } + .collect { update -> + journal = update loading = false } } } - private suspend fun map(from: JournalEntry): JournalEntryUiModel { + private suspend fun mapJournalHistory(history: List): List { + return withContext(Dispatchers.Default) { + val associatedAddresses = + history.flatMapTo(mutableSetOf()) { (it.report as? JournalEntry.Report.ProfileReport)?.deviceAddresses ?: emptyList() } + val associatedDevices = devicesRepository.getAllByAddresses(associatedAddresses.toList()).associateBy { it.address } + + val profileIds = history.mapNotNull { (it.report as? JournalEntry.Report.ProfileReport)?.profileId }.toSet() + val associatedProfiles = profileRepository.getAllByIds(profileIds.toList()).associateBy { it.id } + + history.asSequence() + .sortedByDescending { it.timestamp } + .map { map(it, associatedDevices, associatedProfiles) } + .toList() + } + } + + private fun map( + from: JournalEntry, + associatedDevices: Map, + associatedProfiles: Map, + ): JournalEntryUiModel { return when (from.report) { is JournalEntry.Report.Error -> mapReportError(from, from.report) - is JournalEntry.Report.ProfileReport -> mapReportProfile(from, from.report) + is JournalEntry.Report.ProfileReport -> mapReportProfile(from, from.report, associatedDevices, associatedProfiles) } } @@ -90,32 +118,33 @@ class JournalViewModel( ) } - private suspend fun mapReportProfile( + private fun mapReportProfile( journalEntry: JournalEntry, report: JournalEntry.Report.ProfileReport, + associatedDevices: Map, + associatedProfiles: Map, ): JournalEntryUiModel { + val profileName = associatedProfiles[report.profileId]?.name ?: context.getString(R.string.unknown_capital_case) return JournalEntryUiModel( dateTime = journalEntry.timestamp.formattedDate(), color = { MaterialTheme.colorScheme.surface }, colorForeground = { MaterialTheme.colorScheme.onSurface }, - title = context.getString(R.string.journal_profile_detected, getProfileName(report.profileId)), + title = context.getString(R.string.journal_profile_detected, profileName), subtitle = null, subtitleCollapsed = null, journalEntry = journalEntry, - items = mapListItems(report.deviceAddresses), + items = mapListItems(report.deviceAddresses, associatedDevices), ) } private fun Long.formattedDate() = dateTimeStringFormat("dd MMM yyyy, HH:mm") - private suspend fun getProfileName(id: Int): String { - return profileRepository.getById(id)?.name ?: context.getString(R.string.unknown_capital_case) - } - - private suspend fun mapListItems(addresses: List): List { - val matchedDevices = devicesRepository.getAllByAddresses(addresses) + private fun mapListItems( + addresses: List, + associatedDevices: Map, + ): List { return addresses.map { address -> - val device = matchedDevices.firstOrNull { it.address == address } + val device = associatedDevices[address] JournalEntryUiModel.ListItemUiModel( displayName = device?.buildDisplayName() ?: context.getString(R.string.journal_profile_removed, address), payload = device?.address, From cc1c2b338ba54501e158b805ee1f812cc3f1b875 Mon Sep 17 00:00:00 2001 From: Semper-Viventem Date: Fri, 21 Nov 2025 21:31:40 -0800 Subject: [PATCH 3/4] Add map disclaimer --- .../ui/devicedetails/DeviceDetailsScreen.kt | 23 +++++++++++++++++++ app/src/main/res/values/strings.xml | 2 ++ 2 files changed, 25 insertions(+) diff --git a/app/src/main/java/f/cking/software/ui/devicedetails/DeviceDetailsScreen.kt b/app/src/main/java/f/cking/software/ui/devicedetails/DeviceDetailsScreen.kt index 92673ae8..6dfca64f 100644 --- a/app/src/main/java/f/cking/software/ui/devicedetails/DeviceDetailsScreen.kt +++ b/app/src/main/java/f/cking/software/ui/devicedetails/DeviceDetailsScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll @@ -27,6 +28,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ColorScheme @@ -92,6 +94,7 @@ import f.cking.software.utils.graphic.SignalData import f.cking.software.utils.graphic.SystemNavbarSpacer import f.cking.software.utils.graphic.TagChip import f.cking.software.utils.graphic.ThemedDialog +import f.cking.software.utils.graphic.infoDialog import kotlinx.coroutines.isActive import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf @@ -756,6 +759,26 @@ object DeviceDetailsScreen { color = MaterialTheme.colorScheme.onSurface ) } + + val dialog = infoDialog( + title = stringResource(R.string.device_map_disclaimer_title), + content = stringResource(R.string.device_map_disclaimer_content) + ) + + IconButton( + modifier = Modifier.align(Alignment.BottomEnd), + onClick = { + dialog.show() + }, + ) { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = stringResource(R.string.device_map_disclaimer_title), + modifier = Modifier.size(24.dp) + .background(Color.Black.copy(alpha = 0.1f), shape = CircleShape), + tint = Color.DarkGray, + ) + } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 66df9a1c..c9121bad 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -284,6 +284,8 @@ Disconnect Read Metadata + Map disclaimer + The map does not display the device’s actual location; it only records your smartphone’s coordinates at the moment of scanning. The location marker reflects where the device was detected, not where it truly is. The map is not designed to locate devices, but to show where they were previously observed and how their movement relates to your own. Device list From a33b2f77d088d2823e56108e0138a3cb8547e02b Mon Sep 17 00:00:00 2001 From: Semper-Viventem Date: Fri, 21 Nov 2025 22:12:40 -0800 Subject: [PATCH 4/4] Add database information --- .../software/data/database/AppDatabase.kt | 13 +++++-- .../software/data/database/dao/LocationDao.kt | 4 +++ .../data/helpers/BleFiltersProvider.kt | 2 +- .../software/data/repo/DevicesRepository.kt | 34 ++++++++++++------- .../interactor/ClearGarbageInteractor.kt | 2 +- .../interactor/GetAllDevicesInteractor.kt | 4 +-- .../interactor/GetDatabaseInfoInteractor.kt | 26 ++++++++++++++ .../domain/interactor/InteractorsModule.kt | 1 + .../domain/model/DatabaseInformation.kt | 7 ++++ .../main/java/f/cking/software/ui/UiModule.kt | 4 +-- .../ui/selectdevice/SelectDeviceViewModel.kt | 2 +- .../software/ui/settings/SettingsScreen.kt | 16 +++++++-- .../software/ui/settings/SettingsViewModel.kt | 5 +++ app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values/strings.xml | 6 +++- 15 files changed, 103 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/f/cking/software/domain/interactor/GetDatabaseInfoInteractor.kt create mode 100644 app/src/main/java/f/cking/software/domain/model/DatabaseInformation.kt diff --git a/app/src/main/java/f/cking/software/data/database/AppDatabase.kt b/app/src/main/java/f/cking/software/data/database/AppDatabase.kt index a0b28898..fa687fdf 100644 --- a/app/src/main/java/f/cking/software/data/database/AppDatabase.kt +++ b/app/src/main/java/f/cking/software/data/database/AppDatabase.kt @@ -103,6 +103,13 @@ abstract class AppDatabase : RoomDatabase() { } } + suspend fun getDatabaseSize(context: Context): Long { + return withContext(Dispatchers.IO) { + val dbFile = File(context.getDatabasePath(openHelper.databaseName).toString()) + dbFile.length() + } + } + private fun testDatabase(name: String, context: Context) { val testDb = build(context, name) testDb.openHelper.writableDatabase.isDatabaseIntegrityOk @@ -232,10 +239,12 @@ abstract class AppDatabase : RoomDatabase() { ) """.trimIndent() ) - it.execSQL(""" + it.execSQL( + """ CREATE INDEX IF NOT EXISTS index_profile_detect_profile_id_trigger_time ON profile_detect(profile_id, trigger_time) - """.trimIndent()) + """.trimIndent() + ) } private fun migration( diff --git a/app/src/main/java/f/cking/software/data/database/dao/LocationDao.kt b/app/src/main/java/f/cking/software/data/database/dao/LocationDao.kt index 85864ce9..db43aa92 100644 --- a/app/src/main/java/f/cking/software/data/database/dao/LocationDao.kt +++ b/app/src/main/java/f/cking/software/data/database/dao/LocationDao.kt @@ -6,6 +6,7 @@ import androidx.room.OnConflictStrategy import androidx.room.Query import f.cking.software.data.database.entity.DeviceToLocationEntity import f.cking.software.data.database.entity.LocationEntity +import kotlinx.coroutines.flow.Flow @Dao interface LocationDao { @@ -22,6 +23,9 @@ interface LocationDao { """) fun getAllLocationsByDeviceAddress(address: String, fromTime: Long = 0, toTime: Long = Long.MAX_VALUE): List + @Query("SELECT * FROM location") + fun observeAllLocations(): Flow> + @Insert(onConflict = OnConflictStrategy.REPLACE) fun saveLocation(locationEntity: LocationEntity) diff --git a/app/src/main/java/f/cking/software/data/helpers/BleFiltersProvider.kt b/app/src/main/java/f/cking/software/data/helpers/BleFiltersProvider.kt index ad4d65b4..fb997b21 100644 --- a/app/src/main/java/f/cking/software/data/helpers/BleFiltersProvider.kt +++ b/app/src/main/java/f/cking/software/data/helpers/BleFiltersProvider.kt @@ -47,7 +47,7 @@ class BleFiltersProvider( suspend fun getKnownDevicesFilters(): List { return withContext(Dispatchers.Default) { - val allKnownDevices = getAllDevicesInteractor.execute() + val allKnownDevices = getAllDevicesInteractor.execute(withAirdropInfo = false) val lastSeenDevices = allKnownDevices .sortedByDescending { it.lastDetectTimeMs } diff --git a/app/src/main/java/f/cking/software/data/repo/DevicesRepository.kt b/app/src/main/java/f/cking/software/data/repo/DevicesRepository.kt index 070baad8..6df1c44a 100644 --- a/app/src/main/java/f/cking/software/data/repo/DevicesRepository.kt +++ b/app/src/main/java/f/cking/software/data/repo/DevicesRepository.kt @@ -26,17 +26,17 @@ class DevicesRepository( private val appleContactsDao = appDatabase.appleContactDao() private val lastBatch = MutableStateFlow(emptyList()) private val allDevices = deviceDao.observeAll() - .map { it.toDomainWithAirDrop() } + .map { it.toDomain(withAirdropInfo = true) } - suspend fun getDevices(): List { + suspend fun getDevices(withAirdropInfo: Boolean = false): List { return withContext(Dispatchers.IO) { - deviceDao.getAll().toDomainWithAirDrop() + deviceDao.getAll().toDomain(withAirdropInfo) } } suspend fun getPaginated(offset: Int, limit: Int): List { return withContext(Dispatchers.IO) { - deviceDao.getPaginated(offset, limit).toDomainWithAirDrop() + deviceDao.getPaginated(offset, limit).toDomain(withAirdropInfo = true) } } @@ -48,7 +48,7 @@ class DevicesRepository( emptyList() } else { val scanTime = lastDevice.lastDetectTimeMs - deviceDao.getByLastDetectTime(scanTime).toDomainWithAirDrop() + deviceDao.getByLastDetectTime(scanTime).toDomain(withAirdropInfo = true) } } } @@ -64,7 +64,7 @@ class DevicesRepository( suspend fun observeLastBatch(): StateFlow> { return lastBatch.apply { if (lastBatch.value.isEmpty()) { - notifyListeners() + notifyLastBatchListener() } } } @@ -73,14 +73,14 @@ class DevicesRepository( withContext(Dispatchers.IO) { saveDevices(devices) saveContacts(devices) - notifyListeners() + notifyLastBatchListener() } } suspend fun saveDevice(data: DeviceData) { withContext(Dispatchers.IO) { deviceDao.insert(data.toData()) - notifyListeners() + notifyLastBatchListener() } } @@ -88,7 +88,7 @@ class DevicesRepository( withContext(Dispatchers.IO) { val new = device.copy(lastFollowingDetectionTimeMs = detectionTime) deviceDao.insert(new.toData()) - notifyListeners() + notifyLastBatchListener() } } @@ -97,7 +97,7 @@ class DevicesRepository( addresses.splitToBatches(DatabaseUtils.getMaxSQLVariablesNumber()).forEach { addressesBatch -> deviceDao.deleteAllByAddress(addressesBatch) } - notifyListeners() + notifyLastBatchListener() } } @@ -115,7 +115,7 @@ class DevicesRepository( suspend fun getAllByAddresses(addresses: List): List { return withContext(Dispatchers.IO) { addresses.splitToBatches(DatabaseUtils.getMaxSQLVariablesNumber()).flatMap { - deviceDao.findAllByAddresses(addresses).toDomainWithAirDrop() + deviceDao.findAllByAddresses(addresses).toDomain(withAirdropInfo = true) } } } @@ -157,7 +157,7 @@ class DevicesRepository( } } - private suspend fun notifyListeners() { + private suspend fun notifyLastBatchListener() { coroutineScope { launch(Dispatchers.Default) { val data = getLastBatch() @@ -173,6 +173,16 @@ class DevicesRepository( } } + private suspend fun List.toDomain(withAirdropInfo: Boolean): List { + return withContext(Dispatchers.Default) { + if (withAirdropInfo) { + toDomainWithAirDrop() + } else { + map { it.toDomain() } + } + } + } + private suspend fun List.toDomainWithAirDrop(): List { return withContext(Dispatchers.Default) { val allRelatedContacts = withContext(Dispatchers.IO) { diff --git a/app/src/main/java/f/cking/software/domain/interactor/ClearGarbageInteractor.kt b/app/src/main/java/f/cking/software/domain/interactor/ClearGarbageInteractor.kt index fa0156f0..ec9df0f3 100644 --- a/app/src/main/java/f/cking/software/domain/interactor/ClearGarbageInteractor.kt +++ b/app/src/main/java/f/cking/software/domain/interactor/ClearGarbageInteractor.kt @@ -16,7 +16,7 @@ class ClearGarbageInteractor( suspend fun execute(): Int { return withContext(Dispatchers.Default) { - val devices = devicesRepository.getDevices() + val devices = devicesRepository.getDevices(withAirdropInfo = false) .asSequence() .filter { isGarbage(it) } .map { it.address } diff --git a/app/src/main/java/f/cking/software/domain/interactor/GetAllDevicesInteractor.kt b/app/src/main/java/f/cking/software/domain/interactor/GetAllDevicesInteractor.kt index 673a223f..31e19499 100644 --- a/app/src/main/java/f/cking/software/domain/interactor/GetAllDevicesInteractor.kt +++ b/app/src/main/java/f/cking/software/domain/interactor/GetAllDevicesInteractor.kt @@ -7,7 +7,7 @@ class GetAllDevicesInteractor( private val devicesRepository: DevicesRepository, ) { - suspend fun execute(): List { - return devicesRepository.getDevices() + suspend fun execute(withAirdropInfo: Boolean = false): List { + return devicesRepository.getDevices(withAirdropInfo) } } \ No newline at end of file diff --git a/app/src/main/java/f/cking/software/domain/interactor/GetDatabaseInfoInteractor.kt b/app/src/main/java/f/cking/software/domain/interactor/GetDatabaseInfoInteractor.kt new file mode 100644 index 00000000..afc5c14f --- /dev/null +++ b/app/src/main/java/f/cking/software/domain/interactor/GetDatabaseInfoInteractor.kt @@ -0,0 +1,26 @@ +package f.cking.software.domain.interactor + +import f.cking.software.TheApp +import f.cking.software.data.database.AppDatabase +import f.cking.software.domain.model.DatabaseInformation +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map + +class GetDatabaseInfoInteractor( + private val database: AppDatabase, + private val application: TheApp, +) { + fun execute(): Flow { + return combine( + database.deviceDao().observeAll().map { it.size }, + database.locationDao().observeAllLocations().map { it.size } + ) { deviceCount, locationCount -> + DatabaseInformation( + sizeBytes = database.getDatabaseSize(application), + totalDevices = deviceCount, + totalGeotags = locationCount + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/f/cking/software/domain/interactor/InteractorsModule.kt b/app/src/main/java/f/cking/software/domain/interactor/InteractorsModule.kt index c7394e49..528cbd09 100644 --- a/app/src/main/java/f/cking/software/domain/interactor/InteractorsModule.kt +++ b/app/src/main/java/f/cking/software/domain/interactor/InteractorsModule.kt @@ -36,5 +36,6 @@ object InteractorsModule { factory { CheckBatchForRadarMatchesInteractor(get(), get(), get(), get(), get()) } factory { SaveOrMergeBatchInteractor(get(), get(), get(), get(), get(), get(), get()) } factory { FetchDeviceServiceInfo(get(), get()) } + factory { GetDatabaseInfoInteractor(get(), get()) } } } \ No newline at end of file diff --git a/app/src/main/java/f/cking/software/domain/model/DatabaseInformation.kt b/app/src/main/java/f/cking/software/domain/model/DatabaseInformation.kt new file mode 100644 index 00000000..006a065d --- /dev/null +++ b/app/src/main/java/f/cking/software/domain/model/DatabaseInformation.kt @@ -0,0 +1,7 @@ +package f.cking.software.domain.model + +data class DatabaseInformation( + val sizeBytes: Long, + val totalDevices: Int, + val totalGeotags: Int, +) \ No newline at end of file diff --git a/app/src/main/java/f/cking/software/ui/UiModule.kt b/app/src/main/java/f/cking/software/ui/UiModule.kt index a00cc7f1..b4721a86 100644 --- a/app/src/main/java/f/cking/software/ui/UiModule.kt +++ b/app/src/main/java/f/cking/software/ui/UiModule.kt @@ -13,7 +13,7 @@ import f.cking.software.ui.selectmanufacturer.SelectManufacturerViewModel import f.cking.software.ui.settings.SettingsViewModel import f.cking.software.utils.navigation.Router import f.cking.software.utils.navigation.RouterImpl -import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.core.module.dsl.viewModel import org.koin.dsl.module object UiModule { @@ -22,7 +22,7 @@ object UiModule { single { get() } viewModel { MainViewModel(get(), get(), get(), get(), get(), get(), get()) } viewModel { DeviceListViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get()) } - viewModel { SettingsViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } + viewModel { SettingsViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { ProfilesListViewModel(get(), get()) } viewModel { ProfileDetailsViewModel(profileId = it[0], template = it[1], get(), get(), get(), get(), get()) } viewModel { SelectManufacturerViewModel(get()) } diff --git a/app/src/main/java/f/cking/software/ui/selectdevice/SelectDeviceViewModel.kt b/app/src/main/java/f/cking/software/ui/selectdevice/SelectDeviceViewModel.kt index eb9e3c37..f78a39f9 100644 --- a/app/src/main/java/f/cking/software/ui/selectdevice/SelectDeviceViewModel.kt +++ b/app/src/main/java/f/cking/software/ui/selectdevice/SelectDeviceViewModel.kt @@ -38,7 +38,7 @@ class SelectDeviceViewModel( private fun refreshDevices() { viewModelScope.launch { loading = true - devices = devicesRepository.getDevices().asSequence() + devices = devicesRepository.getDevices(withAirdropInfo = false).asSequence() .filter { device -> searchStr.takeIf { it.isNotBlank() }?.let { searchStr -> (device.resolvedName?.contains(searchStr, true) ?: false) diff --git a/app/src/main/java/f/cking/software/ui/settings/SettingsScreen.kt b/app/src/main/java/f/cking/software/ui/settings/SettingsScreen.kt index df49352c..eae0155b 100644 --- a/app/src/main/java/f/cking/software/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/f/cking/software/ui/settings/SettingsScreen.kt @@ -1,5 +1,6 @@ package f.cking.software.ui.settings +import android.text.format.Formatter import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -21,6 +22,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle @@ -100,9 +102,19 @@ object SettingsScreen { @Composable private fun DatabaseBlock(viewModel: SettingsViewModel) { RoundedBox { - Text(text = stringResource(id = R.string.database_block_title), fontWeight = FontWeight.SemiBold) - Spacer(modifier = Modifier.height(4.dp)) + Text(text = stringResource(R.string.database_information), fontWeight = FontWeight.SemiBold) + Spacer(modifier = Modifier.height(8.dp)) + + val databaseInfo = viewModel.databaseInfo + if (databaseInfo != null) { + Text(text = stringResource(R.string.database_size, Formatter.formatFileSize(LocalContext.current, databaseInfo.sizeBytes))) + Text(text = stringResource(R.string.database_devices_count, databaseInfo.totalDevices.toString())) + Text(text = stringResource(R.string.database_locations_count, databaseInfo.totalGeotags.toString())) + Spacer(modifier = Modifier.height(12.dp)) + } + Text(text = stringResource(R.string.database_actions), fontWeight = FontWeight.SemiBold) + Spacer(modifier = Modifier.height(8.dp)) BackupDB(viewModel = viewModel) Spacer(modifier = Modifier.height(8.dp)) RestoreDB(viewModel = viewModel) diff --git a/app/src/main/java/f/cking/software/ui/settings/SettingsViewModel.kt b/app/src/main/java/f/cking/software/ui/settings/SettingsViewModel.kt index 607fc91b..f3a3c263 100644 --- a/app/src/main/java/f/cking/software/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/f/cking/software/ui/settings/SettingsViewModel.kt @@ -11,6 +11,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import f.cking.software.BuildConfig import f.cking.software.R +import f.cking.software.collectAsState import f.cking.software.data.helpers.IntentHelper import f.cking.software.data.helpers.LocationProvider import f.cking.software.data.helpers.PermissionHelper @@ -19,6 +20,7 @@ import f.cking.software.data.repo.SettingsRepository import f.cking.software.domain.interactor.BackupDatabaseInteractor import f.cking.software.domain.interactor.ClearGarbageInteractor import f.cking.software.domain.interactor.CreateBackupFileInteractor +import f.cking.software.domain.interactor.GetDatabaseInfoInteractor import f.cking.software.domain.interactor.RestoreDatabaseInteractor import f.cking.software.domain.interactor.SaveReportInteractor import f.cking.software.domain.interactor.SelectBackupFileInteractor @@ -43,6 +45,7 @@ class SettingsViewModel( private val intentHelper: IntentHelper, private val permissionHelper: PermissionHelper, private val router: Router, + private val getDatabaseInfoInteractor: GetDatabaseInfoInteractor, ) : ViewModel() { var garbageRemovingInProgress: Boolean by mutableStateOf(false) @@ -55,6 +58,8 @@ class SettingsViewModel( var silentModeEnabled: Boolean by mutableStateOf(settingsRepository.getSilentMode()) var deepAnalysisEnabled: Boolean by mutableStateOf(settingsRepository.getEnableDeepAnalysis()) + val databaseInfo by getDatabaseInfoInteractor.execute().collectAsState(viewModelScope, null) + init { observeLocationData() observeSilentMode() diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index c488f3da..251e97bb 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -81,7 +81,7 @@ Секретный кот Оффлайн режим Выключить любую сетевую коммуникацию от приложения. Может быть полезно чтобы экономить трафик или оставаться незаметным в сети. - Управление базой данных + Управление базой данных Настройки Оффлайн режим выключен. Вы можете включить его в настройках. Поиск производителя diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c9121bad..b6d12152 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -99,7 +99,11 @@ A secret cat photo Offline mode Turn off any network communications in the app. It might be helpful to save traffic. - Database actions + Database actions + Database information + Size: %s + Devices records: %s + Location records: %s App settings Offline mode is turned off. You can change it in the settings. Deep analysis