diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 03c8c3de..09c6b19f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -7,6 +7,11 @@ assignees: Semper-Viventem --- + + **Describe the bug** A clear and concise description of what the bug is. @@ -23,16 +28,10 @@ A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. -**Desktop (please complete the following information):** - - OS: [e.g. iOS] - - Browser [e.g. chrome, safari] - - Version [e.g. 22] - **Smartphone (please complete the following information):** - - Device: [e.g. iPhone6] - - OS: [e.g. iOS8.1] - - Browser [e.g. stock browser, safari] - - Version [e.g. 22] + - Device: [e.g. Pixel 9] + - OS: [e.g. Android 16] + - App version [e.g. v0.31.0-beta] (find in the settings screen) **Additional context** Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/crash-report.md b/.github/ISSUE_TEMPLATE/crash-report.md index 0cd955a8..f714d2fc 100644 --- a/.github/ISSUE_TEMPLATE/crash-report.md +++ b/.github/ISSUE_TEMPLATE/crash-report.md @@ -7,17 +7,25 @@ assignees: Semper-Viventem --- + + **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' -4. App crashes +4. Crash **Smartphone (please complete the following information):** - - Device: [e.g. iPhone6] - - OS: [e.g. iOS8.1] - - App version [e.g. 22] + - Device: [e.g. Pixel 9] + - OS: [e.g. Android 16] + - App version [e.g. v0.31.0-beta] (find in the settings screen) + +**Additional context** +Add any other context about the problem here. **Crash log** -Please attach the crash log if you have one, (You can see the log in debug builds) +Grab crash log using ADB or check errors information in the Journal page diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 2c606557..669673ca 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -7,6 +7,11 @@ assignees: Semper-Viventem --- + + **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 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/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/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/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/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/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/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 0cb70cf3..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 @@ -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,17 +25,18 @@ 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.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) } } @@ -45,7 +48,7 @@ class DevicesRepository( emptyList() } else { val scanTime = lastDevice.lastDetectTimeMs - deviceDao.getByLastDetectTime(scanTime).toDomainWithAirDrop() + deviceDao.getByLastDetectTime(scanTime).toDomain(withAirdropInfo = true) } } } @@ -54,18 +57,14 @@ 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> { return lastBatch.apply { if (lastBatch.value.isEmpty()) { - notifyListeners() + notifyLastBatchListener() } } } @@ -74,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() } } @@ -89,7 +88,7 @@ class DevicesRepository( withContext(Dispatchers.IO) { val new = device.copy(lastFollowingDetectionTimeMs = detectionTime) deviceDao.insert(new.toData()) - notifyListeners() + notifyLastBatchListener() } } @@ -98,14 +97,25 @@ class DevicesRepository( addresses.splitToBatches(DatabaseUtils.getMaxSQLVariablesNumber()).forEach { addressesBatch -> deviceDao.deleteAllByAddress(addressesBatch) } - notifyListeners() + notifyLastBatchListener() + } + } + + 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 { - deviceDao.findAllByAddresses(addresses).toDomainWithAirDrop() + deviceDao.findAllByAddresses(addresses).toDomain(withAirdropInfo = true) } } } @@ -147,16 +157,12 @@ class DevicesRepository( } } - private suspend fun notifyListeners() { + private suspend fun notifyLastBatchListener() { coroutineScope { - launch { + launch(Dispatchers.Default) { val data = getLastBatch() lastBatch.emit(data) } - launch { - val data = getDevices() - allDevices.emit(data) - } } } @@ -167,21 +173,26 @@ class DevicesRepository( } } - private suspend fun List.toDomainWithAirDrop(): List { - return withContext(Dispatchers.IO) { + private suspend fun List.toDomain(withAirdropInfo: Boolean): List { + return withContext(Dispatchers.Default) { + if (withAirdropInfo) { + toDomainWithAirDrop() + } else { + map { it.toDomain() } + } + } + } - val allRelatedContacts = - splitToBatches(DatabaseUtils.getMaxSQLVariablesNumber()).flatMap { batch -> - appleContactsDao.getByAddresses(batch.map { it.address }) - } + private suspend fun List.toDomainWithAirDrop(): List { + 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/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/domain/interactor/ClearGarbageInteractor.kt b/app/src/main/java/f/cking/software/domain/interactor/ClearGarbageInteractor.kt index 23ce69a9..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,13 +16,14 @@ 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 } .toList() devicesRepository.deleteAllByAddress(devices) + devicesRepository.clearUnAssociatedAirdrops() locationRepository.removeDeviceLocationsByAddresses(devices) devices.count() } 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/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/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/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, 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 66df9a1c..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 @@ -284,6 +288,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 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" diff --git a/metadata/de/full_description.txt b/metadata/de/full_description.txt index 15840b58..876d9668 100644 --- a/metadata/de/full_description.txt +++ b/metadata/de/full_description.txt @@ -11,6 +11,7 @@ Glücklicherweise implementieren viele moderne Geräte Datenschutzfunktionen in Ziel dieser App ist es, Ihnen Wissen und Kontrolle über die BLE-Geräte in Ihrer Umgebung zu geben. Zu verstehen, welche Geräte nachverfolgbare Informationen senden und welche datenschutzbewusst sind, ermöglicht es Ihnen, fundierte Entscheidungen darüber zu treffen, welche Geräte Sie täglich verwenden, tragen oder mit denen Sie interagieren. Im Allgemeinen kann die App: + * Bluetooth-Geräte in der Umgebung scannen, analysieren und verfolgen; * Flexible Filter für das Radar erstellen; * Tiefergehende Analyse der gescannten BLE-Geräte durchführen, einschließlich Daten aus verfügbaren GATT-Services; diff --git a/metadata/de/short_description.txt b/metadata/de/short_description.txt index 67ff084a..06e1f3ac 100644 --- a/metadata/de/short_description.txt +++ b/metadata/de/short_description.txt @@ -1 +1 @@ -Ein Tool, um BLE-Geräte in Ihrer Umgebung zu überwachen, zu analysieren und zu finden. \ No newline at end of file +BLE-Geräte finden, überwachen, und analysieren. \ No newline at end of file diff --git a/metadata/en-US/full_description.txt b/metadata/en-US/full_description.txt index f376512f..85dc07da 100644 --- a/metadata/en-US/full_description.txt +++ b/metadata/en-US/full_description.txt @@ -11,6 +11,7 @@ Beyond analysis, BLE Radar can help protect you in real-time. The app can alert By making this app, the goal is to empower you with knowledge and control over the BLE devices in your environment. Understanding which devices are broadcasting trackable information and which are privacy-conscious allows you to make informed decisions about what you use, wear, and interact with daily. In general, the app is capable: + * Scan, analyze and track Bluetooth devices around; * Create flexible filters for the radar; * Deep analysis of the scanned BLE devices, getting data from the available GATT services; diff --git a/metadata/ru/full_description.txt b/metadata/ru/full_description.txt index 4a194005..cd8a5280 100644 --- a/metadata/ru/full_description.txt +++ b/metadata/ru/full_description.txt @@ -11,6 +11,7 @@ Bluetooth Low Energy (BLE) — это широко используемый бе Цель приложения — дать вам знания и контроль над BLE-устройствами в вашей среде. Понимание того, какие устройства передают отслеживаемую информацию, а какие заботятся о приватности, позволяет принимать обоснованные решения о том, что вы используете, носите и с чем взаимодействуете ежедневно. В общем, приложение позволяет: + * Сканировать, анализировать и отслеживать Bluetooth-устройства вокруг; * Создавать гибкие фильтры для радара; * Глубоко анализировать сканированные BLE-устройства, получая данные из доступных GATT-сервисов;