Skip to content

Commit 2182490

Browse files
committed
Refactoring and QoL
1 parent 944316d commit 2182490

File tree

4 files changed

+168
-79
lines changed

4 files changed

+168
-79
lines changed

app/src/main/java/dev/alllexey/itmowidgets/data/UtilityStorage.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ class UtilityStorage(val prefs: SharedPreferences, val context: Context) {
1515
const val QR_WIDGET_STATE_PREFIX = "qr_widget_state_"
1616
const val LESSON_WIDGET_STYLE_CHANGED_KEY = "lesson_widget_style_changed"
1717
const val SKIPPED_VERSION_KEY = "skipped_version"
18+
const val VERSION_NOTIFIED_AT_KEY = "version_notified_at_key"
1819
const val ONBOARDING_COMPLETED_KEY = "onboarding_completed"
1920
}
2021

@@ -35,6 +36,10 @@ class UtilityStorage(val prefs: SharedPreferences, val context: Context) {
3536
return QrWidgetState.valueOf(stateName ?: QrWidgetState.SPOILER.name)
3637
}
3738

39+
fun getVersionNotifiedAt(): Long {
40+
return prefs.getLong(VERSION_NOTIFIED_AT_KEY, 0L)
41+
}
42+
3843
fun getSkippedVersion(): String {
3944
return prefs.getString(SKIPPED_VERSION_KEY, null) ?: ContextCompat.getString(context, R.string.app_version)
4045
}
@@ -78,4 +83,10 @@ class UtilityStorage(val prefs: SharedPreferences, val context: Context) {
7883
putBoolean(ONBOARDING_COMPLETED_KEY, completed)
7984
}
8085
}
86+
87+
fun setVersionNotifiedAd(notifiedAt: Long) {
88+
prefs.edit(commit = true) {
89+
putLong(VERSION_NOTIFIED_AT_KEY, notifiedAt)
90+
}
91+
}
8192
}

app/src/main/java/dev/alllexey/itmowidgets/services/MyFirebaseMessagingService.kt

Lines changed: 137 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@ import android.util.Log
88
import androidx.core.app.NotificationCompat
99
import com.google.firebase.messaging.FirebaseMessagingService
1010
import com.google.firebase.messaging.RemoteMessage
11+
import com.google.gson.JsonElement
1112
import dev.alllexey.itmowidgets.ItmoWidgetsApp
1213
import dev.alllexey.itmowidgets.R
14+
import dev.alllexey.itmowidgets.core.model.ApiResponse
15+
import dev.alllexey.itmowidgets.core.model.QueueEntry
1316
import dev.alllexey.itmowidgets.core.model.QueueEntryStatus
1417
import dev.alllexey.itmowidgets.core.model.fcm.FcmJsonWrapper
1518
import dev.alllexey.itmowidgets.core.model.fcm.impl.SportAutoSignLessonsPayload
@@ -18,88 +21,140 @@ import dev.alllexey.itmowidgets.core.model.fcm.impl.SportNewLessonsPayload
1821
import dev.alllexey.itmowidgets.ui.main.MainActivity
1922
import kotlinx.coroutines.CoroutineScope
2023
import kotlinx.coroutines.Dispatchers
24+
import kotlinx.coroutines.SupervisorJob
25+
import kotlinx.coroutines.cancel
2126
import kotlinx.coroutines.launch
22-
import kotlinx.coroutines.runBlocking
27+
import kotlin.random.Random
2328

2429
class MyFirebaseMessagingService : FirebaseMessagingService() {
2530

31+
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
32+
33+
private val appContainer by lazy { (applicationContext as ItmoWidgetsApp).appContainer }
34+
private val gson by lazy { appContainer.gson }
35+
private val errorLogRepository by lazy { appContainer.errorLogRepository }
36+
private val myItmoApi by lazy { appContainer.myItmo.api() }
37+
private val widgetsApi by lazy { appContainer.itmoWidgets.api() }
38+
2639
override fun onNewToken(token: String) {
2740
Log.d(TAG, "Refreshed token: $token")
2841
sendTokenToServer(token)
2942
}
3043

3144
override fun onMessageReceived(remoteMessage: RemoteMessage) {
32-
val appContainer = (applicationContext as ItmoWidgetsApp).appContainer
3345
Log.d(TAG, "From: ${remoteMessage.from}")
3446

3547
remoteMessage.notification?.let {
3648
Log.d(TAG, "Message Notification Body: ${it.body}")
3749
sendNotification(it.title, it.body)
3850
}
3951

40-
// todo: remove later (test only)
4152
remoteMessage.data["data"]?.let { jsonPayload ->
42-
val gson = appContainer.gson
53+
handleDataMessage(jsonPayload)
54+
}
55+
}
56+
57+
override fun onDestroy() {
58+
super.onDestroy()
59+
serviceScope.cancel()
60+
}
61+
62+
private fun handleDataMessage(jsonPayload: String) {
63+
try {
64+
val wrapper = gson.fromJson(jsonPayload, FcmJsonWrapper::class.java)
65+
when (wrapper.type) {
66+
SportNewLessonsPayload.TYPE -> handleNewLessons(wrapper.payload)
67+
SportFreeSignLessonsPayload.TYPE -> handleFreeSign(wrapper.payload)
68+
SportAutoSignLessonsPayload.TYPE -> handleAutoSign(wrapper.payload)
69+
else -> Log.w(TAG, "Unknown payload type: ${wrapper.type}")
70+
}
71+
} catch (e: Exception) {
72+
handleError(e)
73+
}
74+
}
75+
76+
private fun handleNewLessons(payloadJson: JsonElement) {
77+
// unused
78+
// val data = gson.fromJson(payloadJson, SportNewLessonsPayload::class.java)
79+
// sendNotification("Уведомление фильтра", "${data.sportLessonIds.size} new lessons")
80+
}
81+
82+
private fun handleFreeSign(payloadJson: JsonElement) {
83+
val data = gson.fromJson(payloadJson, SportFreeSignLessonsPayload::class.java)
84+
85+
serviceScope.launch {
86+
processLessonSignUp(
87+
lessonIds = data.sportLessonIds,
88+
notificationTitle = "Автозапись (при освобождении)",
89+
fetchEntries = { widgetsApi.mySportFreeSignEntries() },
90+
markSatisfied = { id -> widgetsApi.markSportFreeSignEntrySatisfied(id) }
91+
)
92+
}
93+
}
94+
95+
private fun handleAutoSign(payloadJson: JsonElement) {
96+
val data = gson.fromJson(payloadJson, SportFreeSignLessonsPayload::class.java)
97+
98+
serviceScope.launch {
99+
processLessonSignUp(
100+
lessonIds = data.sportLessonIds,
101+
notificationTitle = "Автозапись (на прогнозируемое занятие)",
102+
fetchEntries = { widgetsApi.mySportAutoSignEntries() },
103+
markSatisfied = { id -> widgetsApi.markSportAutoSignEntrySatisfied(id) }
104+
)
105+
}
106+
}
107+
108+
private suspend fun <T : QueueEntry> processLessonSignUp(
109+
lessonIds: List<Long>,
110+
notificationTitle: String,
111+
fetchEntries: suspend () -> ApiResponse<List<T>>,
112+
markSatisfied: suspend (Long) -> Unit
113+
) {
114+
val entriesResponse = try {
115+
fetchEntries()
116+
} catch (e: Exception) {
117+
errorLogRepository.logThrowable(e, TAG)
118+
return
119+
}
120+
121+
lessonIds.forEach { lessonId ->
43122
try {
44-
val wrapper = gson.fromJson(jsonPayload, FcmJsonWrapper::class.java)
45-
when (wrapper.type) {
46-
SportNewLessonsPayload.TYPE -> {
47-
val data = gson.fromJson(wrapper.payload, SportNewLessonsPayload::class.java)
48-
sendNotification("SPORT NOTIF", "${data.sportLessonIds.size} new lessons")
49-
}
50-
SportFreeSignLessonsPayload.TYPE -> {
51-
val data = gson.fromJson(wrapper.payload, SportFreeSignLessonsPayload::class.java)
52-
CoroutineScope(Dispatchers.IO).launch {
53-
val freeSignEntries = appContainer.itmoWidgets.api().mySportFreeSignEntries()
54-
data.sportLessonIds.forEach {
55-
try {
56-
appContainer.myItmo.api().signInLessons(listOf(it)).execute().body()!!.result
57-
freeSignEntries.data?.firstOrNull { e -> e.status == QueueEntryStatus.NOTIFIED }
58-
?.let { e -> appContainer.itmoWidgets.api().markSportFreeSignEntrySatisfied(e.id) }
59-
sendNotification("Автозапись (при освобождении)", "Вы успешно записаны на занятие!")
60-
} catch (e: Exception) {
61-
appContainer.errorLogRepository.logThrowable(e,
62-
MyFirebaseMessagingService::class.java.name)
63-
}
64-
}
65-
}
66-
}
67-
SportAutoSignLessonsPayload.TYPE -> {
68-
val data = gson.fromJson(wrapper.payload, SportFreeSignLessonsPayload::class.java)
69-
CoroutineScope(Dispatchers.IO).launch {
70-
val autoSignEntries = appContainer.itmoWidgets.api().mySportAutoSignEntries()
71-
data.sportLessonIds.forEach {
72-
try {
73-
appContainer.myItmo.api().signInLessons(listOf(it)).execute().body()!!.result
74-
autoSignEntries.data?.firstOrNull { e -> e.status == QueueEntryStatus.NOTIFIED }
75-
?.let { e -> appContainer.itmoWidgets.api().markSportAutoSignEntrySatisfied(e.id) }
76-
sendNotification("Автозапись (на прогнозируемое занятие)", "Вы успешно записаны на занятие!")
77-
} catch (e: Exception) {
78-
appContainer.errorLogRepository.logThrowable(e,
79-
MyFirebaseMessagingService::class.java.name)
80-
}
81-
}
123+
val body = myItmoApi.signInLessons(listOf(lessonId)).execute().body()
124+
125+
if (body?.result != null) {
126+
entriesResponse.data
127+
?.firstOrNull { e -> e.status == QueueEntryStatus.NOTIFIED }
128+
?.let { entry ->
129+
markSatisfied(entry.id)
82130
}
83-
}
131+
132+
sendNotification(notificationTitle, "Вы успешно записаны на занятие!")
133+
} else {
134+
throw RuntimeException("Could not sign in sport lesson: ${body?.errorMessage}")
84135
}
85136
} catch (e: Exception) {
86-
try {
87-
sendNotification("FCM", "Ошибка обработки события, посмотрите логи")
88-
appContainer.errorLogRepository.logThrowable(e, MyFirebaseMessagingService::class.java.name)
89-
} catch (e: Exception) {
90-
appContainer.errorLogRepository.logThrowable(e, MyFirebaseMessagingService::class.java.name)
91-
}
137+
errorLogRepository.logThrowable(e, TAG)
92138
}
93139
}
94140
}
95141

96142
private fun sendNotification(title: String?, messageBody: String?) {
97-
val intent = Intent(this, MainActivity::class.java)
98-
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
99-
val pendingIntent = PendingIntent.getActivity(this, 0, intent,
100-
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE)
143+
val intent = Intent(this, MainActivity::class.java).apply {
144+
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
145+
}
146+
147+
val pendingIntent = PendingIntent.getActivity(
148+
this,
149+
0,
150+
intent,
151+
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
152+
)
101153

102154
val channelId = getString(R.string.default_notification_channel_id)
155+
156+
createNotificationChannel(channelId)
157+
103158
val notificationBuilder = NotificationCompat.Builder(this, channelId)
104159
.setSmallIcon(R.drawable.ic_notification)
105160
.setContentTitle(title)
@@ -109,22 +164,41 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
109164

110165
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
111166

112-
val channel = NotificationChannel(
113-
channelId,
114-
"Default Channel",
115-
NotificationManager.IMPORTANCE_DEFAULT
116-
)
117-
notificationManager.createNotificationChannel(channel)
167+
val notificationId = (messageBody?.hashCode() ?: Random.nextInt())
168+
notificationManager.notify(notificationId, notificationBuilder.build())
169+
}
118170

119-
notificationManager.notify(0, notificationBuilder.build())
171+
private fun createNotificationChannel(channelId: String) {
172+
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
173+
if (notificationManager.getNotificationChannel(channelId) == null) {
174+
val channel = NotificationChannel(
175+
channelId,
176+
"Default Channel",
177+
NotificationManager.IMPORTANCE_DEFAULT
178+
)
179+
notificationManager.createNotificationChannel(channel)
180+
}
120181
}
121182

122183
private fun sendTokenToServer(token: String) {
123-
val appContainer = (applicationContext as ItmoWidgetsApp).appContainer
124-
val itmoWidgets = appContainer.itmoWidgets
125184
appContainer.storage.utility.setFirebaseToken(token)
126185
if (appContainer.storage.settings.getCustomServicesState()) {
127-
runBlocking { itmoWidgets.sendFirebaseToken(token) }
186+
serviceScope.launch {
187+
try {
188+
appContainer.itmoWidgets.sendFirebaseToken(token)
189+
} catch (e: Exception) {
190+
Log.e(TAG, "Failed to send token", e)
191+
}
192+
}
193+
}
194+
}
195+
196+
private fun handleError(e: Exception) {
197+
try {
198+
sendNotification("FCM Error", "Ошибка обработки события, посмотрите логи")
199+
errorLogRepository.logThrowable(e, TAG)
200+
} catch (innerEx: Exception) {
201+
Log.e(TAG, "Error handling failed", innerEx)
128202
}
129203
}
130204

app/src/main/java/dev/alllexey/itmowidgets/ui/main/MainActivity.kt

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,7 @@ class MainActivity : AppCompatActivity() {
4343
override fun onCreate(savedInstanceState: Bundle?) {
4444
super.onCreate(savedInstanceState)
4545

46-
val appContainer = (applicationContext as ItmoWidgetsApp).appContainer
47-
if (!appContainer.storage.utility.getOnboardingCompleted()) {
46+
if (!appContainer().storage.utility.getOnboardingCompleted()) {
4847
startActivity(Intent(this, OnboardingActivity::class.java))
4948
finish()
5049
return
@@ -107,8 +106,8 @@ class MainActivity : AppCompatActivity() {
107106

108107
if (intent.getBooleanExtra("onboarding", false)) {
109108
MaterialAlertDialogBuilder(this, R.style.AlertDialogTheme_FilledButton)
110-
.setTitle("И напоследок...")
111-
.setMessage("Крайне советую посмотреть остальные настройки приложения в самой правой вкладке.\nВероятно, там есть что-то интересное для тебя.")
109+
.setTitle("А ещё...")
110+
.setMessage("Обязательно посмотри остальные настройки приложения в правой вкладке\n\nТам точно есть что-то интересное для тебя \uD83D\uDC40")
112111
.setPositiveButton("Хорошо") { dialog, which -> }
113112
.show()
114113
} else {
@@ -117,33 +116,33 @@ class MainActivity : AppCompatActivity() {
117116
}
118117

119118
fun resendFcmTokens() {
120-
val appContainer = applicationContext.appContainer()
121-
val firebaseToken = appContainer.storage.utility.getFirebaseToken()
122-
if (firebaseToken != null && appContainer.storage.settings.getCustomServicesState()) {
119+
val firebaseToken = appContainer().storage.utility.getFirebaseToken()
120+
if (firebaseToken != null && appContainer().storage.settings.getCustomServicesState()) {
123121
CoroutineScope(Dispatchers.IO).launch {
124122
try {
125-
appContainer.itmoWidgets.sendFirebaseToken(firebaseToken)
123+
appContainer().itmoWidgets.sendFirebaseToken(firebaseToken)
126124
} catch (e: Exception) {
127-
appContainer.errorLogRepository.logThrowable(e, MainActivity::class.java.name + " [FCM]")
125+
appContainer().errorLogRepository.logThrowable(e, MainActivity::class.java.name + " [FCM]")
128126
}
129127
}
130128
}
131129
}
132130

133131
fun checkVersion() {
134-
val appContainer = applicationContext.appContainer()
135132
CoroutineScope(Dispatchers.IO).launch {
136-
if (!appContainer.storage.settings.getCustomServicesState()) return@launch
133+
if (!appContainer().storage.settings.getCustomServicesState()) return@launch
134+
val delta = System.currentTimeMillis() - appContainer().storage.utility.getVersionNotifiedAt()
135+
if (delta < 1000 * 60 * 60 * 24) return@launch // once every day
137136
try {
138-
val latestVersion = appContainer.itmoWidgets.api().latestAppVersion().data!!
137+
val latestVersion = appContainer().itmoWidgets.api().latestAppVersion().data!!
139138
val currentVersion = getString(applicationContext, R.string.app_version)
140139
withContext(Dispatchers.Main) {
141-
if (latestVersion > currentVersion && latestVersion > appContainer.storage.utility.getSkippedVersion()) {
140+
if (latestVersion > currentVersion && latestVersion > appContainer().storage.utility.getSkippedVersion()) {
142141
showVersionPopup(latestVersion, currentVersion)
143142
}
144143
}
145144
} catch (e: Exception) {
146-
appContainer.errorLogRepository.logThrowable(e, MainActivity::class.java.name + " [VER]")
145+
appContainer().errorLogRepository.logThrowable(e, MainActivity::class.java.name + " [VER]")
147146
}
148147
}
149148
}
@@ -153,7 +152,12 @@ class MainActivity : AppCompatActivity() {
153152
.setTitle("Новая версия \uD83D\uDE80")
154153
.setMessage("У приложения вышло обновление! Возможно, там будет что-то интересное для тебя \uD83D\uDC40\n${currentVersion}${latestVersion} ")
155154
.setNeutralButton("Напомнить позже") { dialog, which ->
156-
155+
appContainer().storage.utility.setVersionNotifiedAd(System.currentTimeMillis())
156+
Toast.makeText(
157+
applicationContext,
158+
"Хорошо, напомним позже \uD83D\uDC4C",
159+
Toast.LENGTH_SHORT
160+
).show()
157161
}
158162
.setNegativeButton("Пропустить версию") { dialog, which ->
159163
val appContainer = (applicationContext as ItmoWidgetsApp).appContainer

gradle/libs.versions.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[versions]
2-
itmoWidgetsCore = "1.1.2"
2+
itmoWidgetsCore = "1.1.5"
33
myItmoApi = "1.4.1"
44
agp = "8.12.3"
55
androidImageCropper = "4.6.0"

0 commit comments

Comments
 (0)