From 13721cdab9a1f1b3730382779e97a09bb978a5c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=C2=A0Koby=C5=82ko?= Date: Sun, 7 Jan 2024 21:07:11 +0100 Subject: [PATCH] Implement internal periodic notifications --- .../com.twofasapp.storage.AppDatabase/13.json | 296 ++++++++++++++++++ .../java/com/twofasapp/storage/AppDatabase.kt | 3 +- .../com/twofasapp/ui/main/StartActivity.kt | 4 +- .../twofasapp/workmanager/OnAppStartWork.kt | 165 ++++++++++ .../src/main/res/values-de-rDE/strings.xml | 6 +- .../src/main/res/values-es-rES/strings.xml | 6 +- .../src/main/res/values-fr-rFR/strings.xml | 6 +- .../locale/src/main/res/values-in/strings.xml | 6 +- .../src/main/res/values-it-rIT/strings.xml | 6 +- .../src/main/res/values-nl-rNL/strings.xml | 6 +- .../src/main/res/values-pl-rPL/strings.xml | 6 +- .../src/main/res/values-pt-rBR/strings.xml | 6 +- .../src/main/res/values-pt-rPT/strings.xml | 6 +- .../src/main/res/values-tr-rTR/strings.xml | 6 +- .../src/main/res/values-uk-rUA/strings.xml | 6 +- core/locale/src/main/res/values/strings.xml | 6 +- .../notifications/NotificationsRepository.kt | 7 + .../NotificationsRepositoryImpl.kt | 37 +++ .../data/notifications/domain/Notification.kt | 1 + .../domain/PeriodicNotificationType.kt | 9 + .../notifications/local/NotificationsDao.kt | 12 +- .../local/NotificationsLocalSource.kt | 32 ++ .../local/model/NotificationEntity.kt | 2 + .../mappper/NotificationsMapper.kt | 6 +- .../feature/home/navigation/HomeNavigation.kt | 14 +- .../ui/notifications/NotificationsScreen.kt | 27 +- 26 files changed, 665 insertions(+), 22 deletions(-) create mode 100644 app/schemas/com.twofasapp.storage.AppDatabase/13.json create mode 100644 app/src/main/java/com/twofasapp/workmanager/OnAppStartWork.kt create mode 100644 data/notifications/src/main/java/com/twofasapp/data/notifications/domain/PeriodicNotificationType.kt diff --git a/app/schemas/com.twofasapp.storage.AppDatabase/13.json b/app/schemas/com.twofasapp.storage.AppDatabase/13.json new file mode 100644 index 00000000..3cb5eefe --- /dev/null +++ b/app/schemas/com.twofasapp.storage.AppDatabase/13.json @@ -0,0 +1,296 @@ +{ + "formatVersion": 1, + "database": { + "version": 13, + "identityHash": "00659315e21cc36273e2fcd4ae35ce93", + "entities": [ + { + "tableName": "local_services", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `secret` TEXT NOT NULL, `serviceTypeId` TEXT, `iconCollectionId` TEXT, `source` TEXT, `otpLink` TEXT, `otpLabel` TEXT, `otpAccount` TEXT, `otpIssuer` TEXT, `otpDigits` INTEGER, `otpPeriod` INTEGER, `otpAlgorithm` TEXT, `backupSyncStatus` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `badgeColor` TEXT, `selectedImageType` TEXT, `labelText` TEXT, `labelBackgroundColor` TEXT, `groupId` TEXT, `isDeleted` INTEGER, `authType` TEXT, `hotpCounter` INTEGER, `hotpCounterTimestamp` INTEGER, `revealTimestamp` INTEGER, `assignedDomains` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secret", + "columnName": "secret", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serviceTypeId", + "columnName": "serviceTypeId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "iconCollectionId", + "columnName": "iconCollectionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "otpLink", + "columnName": "otpLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "otpLabel", + "columnName": "otpLabel", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "otpAccount", + "columnName": "otpAccount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "otpIssuer", + "columnName": "otpIssuer", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "otpDigits", + "columnName": "otpDigits", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "otpPeriod", + "columnName": "otpPeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "otpAlgorithm", + "columnName": "otpAlgorithm", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "backupSyncStatus", + "columnName": "backupSyncStatus", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "badgeColor", + "columnName": "badgeColor", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "selectedImageType", + "columnName": "selectedImageType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "labelText", + "columnName": "labelText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "labelBackgroundColor", + "columnName": "labelBackgroundColor", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "authType", + "columnName": "authType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hotpCounter", + "columnName": "hotpCounter", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hotpCounterTimestamp", + "columnName": "hotpCounterTimestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "revealTimestamp", + "columnName": "revealTimestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedDomains", + "columnName": "assignedDomains", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "paired_browsers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `extensionPublicKey` TEXT NOT NULL, `pairedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extensionPublicKey", + "columnName": "extensionPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pairedAt", + "columnName": "pairedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "notifications", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `category` TEXT NOT NULL, `link` TEXT NOT NULL, `message` TEXT NOT NULL, `publishTime` INTEGER NOT NULL, `push` INTEGER NOT NULL, `platform` TEXT NOT NULL, `isRead` INTEGER NOT NULL, `periodicType` TEXT, `internalRoute` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publishTime", + "columnName": "publishTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "push", + "columnName": "push", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "platform", + "columnName": "platform", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isRead", + "columnName": "isRead", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "periodicType", + "columnName": "periodicType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "internalRoute", + "columnName": "internalRoute", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '00659315e21cc36273e2fcd4ae35ce93')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/twofasapp/storage/AppDatabase.kt b/app/src/main/java/com/twofasapp/storage/AppDatabase.kt index 177acb9a..bfd23ad2 100644 --- a/app/src/main/java/com/twofasapp/storage/AppDatabase.kt +++ b/app/src/main/java/com/twofasapp/storage/AppDatabase.kt @@ -31,12 +31,13 @@ import java.text.Normalizer autoMigrations = [ AutoMigration(from = 7, to = 8), AutoMigration(from = 8, to = 9), + AutoMigration(from = 12, to = 13), ] ) @TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { companion object { - const val DB_VERSION = 12 + const val DB_VERSION = 13 } abstract fun serviceDao(): ServiceDao diff --git a/app/src/main/java/com/twofasapp/ui/main/StartActivity.kt b/app/src/main/java/com/twofasapp/ui/main/StartActivity.kt index ca7daab6..11a09e1c 100644 --- a/app/src/main/java/com/twofasapp/ui/main/StartActivity.kt +++ b/app/src/main/java/com/twofasapp/ui/main/StartActivity.kt @@ -10,8 +10,9 @@ import com.twofasapp.base.lifecycle.AuthAware import com.twofasapp.base.lifecycle.AuthLifecycle import com.twofasapp.data.session.SettingsRepository import com.twofasapp.designsystem.AppThemeState -import com.twofasapp.workmanager.SyncTimeWorkDispatcher +import com.twofasapp.workmanager.OnAppStartWork import com.twofasapp.workmanager.OnAppUpdatedWorkDispatcher +import com.twofasapp.workmanager.SyncTimeWorkDispatcher import org.koin.android.ext.android.get import org.koin.android.ext.android.inject import org.koin.core.parameter.parametersOf @@ -32,6 +33,7 @@ class StartActivity : AppCompatActivity(), AuthAware { onAppUpdatedWorkDispatcher.dispatch() syncTimeWorkDispatcher.dispatch() + OnAppStartWork.dispatch(this) if (savedInstanceState == null) { authTracker.onSplashScreen() diff --git a/app/src/main/java/com/twofasapp/workmanager/OnAppStartWork.kt b/app/src/main/java/com/twofasapp/workmanager/OnAppStartWork.kt new file mode 100644 index 00000000..12259c9f --- /dev/null +++ b/app/src/main/java/com/twofasapp/workmanager/OnAppStartWork.kt @@ -0,0 +1,165 @@ +package com.twofasapp.workmanager + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequest +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.twofasapp.android.navigation.Screen +import com.twofasapp.data.browserext.BrowserExtRepository +import com.twofasapp.data.notifications.NotificationsRepository +import com.twofasapp.data.notifications.domain.Notification +import com.twofasapp.data.notifications.domain.PeriodicNotificationType +import com.twofasapp.data.services.BackupRepository +import com.twofasapp.data.services.ServicesRepository +import com.twofasapp.data.session.SessionRepository +import kotlinx.coroutines.flow.first +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import timber.log.Timber +import java.time.Duration +import java.time.Instant +import java.util.UUID + +class OnAppStartWork( + private val context: Context, + params: WorkerParameters, +) : CoroutineWorker(context, params), KoinComponent { + + companion object { + fun dispatch(context: Context) { + val request: OneTimeWorkRequest = OneTimeWorkRequestBuilder() + .build() + + Timber.d("Append new OnAppStartWork") + WorkManager.getInstance(context) + .enqueueUniqueWork("OnAppStartWork", ExistingWorkPolicy.APPEND_OR_REPLACE, request) + } + } + + + private val sessionRepository: SessionRepository by inject() + private val notificationsRepository: NotificationsRepository by inject() + private val backupRepository: BackupRepository by inject() + private val servicesRepository: ServicesRepository by inject() + private val browserExtRepository: BrowserExtRepository by inject() + + override suspend fun doWork(): Result { + checkPeriodicNotifications() + return Result.success() + } + + private suspend fun checkPeriodicNotifications() { + if (sessionRepository.isOnboardingDisplayed().not()) { + Timber.d("This is first app launch -> do nothing") + // This is first app launch -> do nothing + return + } + + val n = notificationsRepository.getPeriodicNotificationCounter() + val timestampMillis = notificationsRepository.getPeriodicNotificationTimestamp() + val timestamp = Instant.ofEpochMilli(timestampMillis) + val now = Instant.now() + + Timber.d("n = $n") + + if (timestampMillis != 0L && now.isBefore(timestamp.plusSeconds(Duration.ofDays(30).toSeconds()))) { + Timber.d("Less than 30 days") + // Finish when last notification was triggered less than 30 days ago + return + } + + val notificationToShow = when (n) { + -1 -> PeriodicNotificationType.TipsAndTricks + 0 -> PeriodicNotificationType.Backup + 1 -> PeriodicNotificationType.BrowserExtension + 2 -> PeriodicNotificationType.Donate + else -> return + } + + showNotification(notificationToShow) + + notificationsRepository.setPeriodicNotificationCounter(if (n == 2) 0 else n + 1) + notificationsRepository.setPeriodicNotificationTimestamp(now.toEpochMilli()) + } + + private suspend fun showNotification(type: PeriodicNotificationType) { + Timber.d("Show notification: $type") + notificationsRepository.clearPeriodicNotifications() + + when (type) { + PeriodicNotificationType.TipsAndTricks -> { + notificationsRepository.insertPeriodicNotification( + type = type, + notification = createPeriodicNotification( + category = Notification.Category.Youtube, + message = context.getString(com.twofasapp.locale.R.string.periodic_notification_tips), + link = "https://2fas.com/2fasauth-tutorial", + ) + ) + } + + PeriodicNotificationType.Backup -> { + if (servicesRepository.getServices().isNotEmpty() && backupRepository.observeCloudBackupStatus().first().active.not()) { + // User has services but backup is inactive + notificationsRepository.insertPeriodicNotification( + type = type, + notification = createPeriodicNotification( + category = Notification.Category.Updates, + message = context.getString(com.twofasapp.locale.R.string.periodic_notification_backup), + link = "", + internalRoute = Screen.Backup.route, + ) + ) + } + } + + PeriodicNotificationType.BrowserExtension -> { + if (servicesRepository.getServices().isNotEmpty() && browserExtRepository.observePairedBrowsers().first().isEmpty()) { + notificationsRepository.insertPeriodicNotification( + type = type, + notification = createPeriodicNotification( + category = Notification.Category.News, + message = context.getString(com.twofasapp.locale.R.string.periodic_notification_browser_extension), + link = "https://2fas.com/browser-extension/", + ) + ) + } + } + + PeriodicNotificationType.Donate -> { + if (servicesRepository.getServices().isNotEmpty()) { + notificationsRepository.insertPeriodicNotification( + type = type, + notification = createPeriodicNotification( + category = Notification.Category.Features, + message = context.getString(com.twofasapp.locale.R.string.periodic_notification_donate), + link = "https://2fas.com/donate/", + ) + ) + } + } + } + } + + private fun createPeriodicNotification( + category: Notification.Category, + message: String, + link: String, + internalRoute: String? = null, + ): Notification { + return Notification( + id = UUID.randomUUID().toString(), + category = category, + link = link, + message = message, + publishTime = Instant.now().toEpochMilli(), + push = false, + platform = "android", + isRead = false, + internalRoute = internalRoute, + ) + } +} \ No newline at end of file diff --git a/core/locale/src/main/res/values-de-rDE/strings.xml b/core/locale/src/main/res/values-de-rDE/strings.xml index fd7a9a43..386ef849 100644 --- a/core/locale/src/main/res/values-de-rDE/strings.xml +++ b/core/locale/src/main/res/values-de-rDE/strings.xml @@ -5,7 +5,7 @@ Release: Working copy Locale: de-DE, German (Germany) Exported by: rafakob - Exported at: Thu, 21 Dec 2023 12:34:52 -0800 + Exported at: Sun, 07 Jan 2024 11:57:56 -0800 -->