diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 19a2410..cdfa2c0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ + xmlns:tools="http://schemas.android.com/tools" > @@ -15,7 +15,8 @@ android:label="@string/app_name" android:supportsRtl="false" android:theme="@style/Theme.ShiraBox" - tools:targetApi="34"> + tools:targetApi="34" > + - - - - - - - - - + android:theme="@style/Theme.ShiraBox" > + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/live/shirabox/shirabox/NotificationService.kt b/app/src/main/java/live/shirabox/shirabox/NotificationService.kt new file mode 100644 index 0000000..7fef4d6 --- /dev/null +++ b/app/src/main/java/live/shirabox/shirabox/NotificationService.kt @@ -0,0 +1,143 @@ +package live.shirabox.shirabox + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.TaskStackBuilder +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import live.shirabox.core.entity.NotificationEntity +import live.shirabox.core.model.ContentType +import live.shirabox.core.util.Util +import live.shirabox.shirabox.db.AppDatabase +import live.shirabox.shirabox.ui.activity.resource.ResourceActivity +import java.net.URL + +class NotificationService : FirebaseMessagingService() { + companion object { + private const val TAG = "ShiraBoxService" + private const val MAIN_CHANNEL_ID = "SB_NOTIFICATIONS" + lateinit var db: AppDatabase + } + + private val job = SupervisorJob() + private val scope = CoroutineScope(Dispatchers.IO + job) + + private data class MessageData( + val title: String, + val body: String, + val thumbnailUrl: String?, + val shikimoriId: Int + ) + + override fun onNewToken(token: String) { + super.onNewToken(token) + Log.d(TAG, "New token observed: $token") + } + + override fun onMessageReceived(remoteMessage: RemoteMessage) { + super.onMessageReceived(remoteMessage) + remoteMessage.data.ifEmpty { return } + + Log.d(TAG, "Message data payload: ${remoteMessage.data}") + + try { + db = AppDatabase.getAppDataBase(this)!! + + val data = remoteMessage.data + + val messageData = MessageData( + title = data["title"] ?: "Undefined title", + body = data["body"] ?: "Undefined notification body", + shikimoriId = data["shikimori_id"]!!.toInt(), + thumbnailUrl = data["thumbnail"] + ) + + scope.launch { + launch { sendNotification(messageData) } + launch { saveNotification(messageData) } + } + + } catch (ex: Exception) { + ex.printStackTrace() + } + } + + private suspend fun sendNotification(messageData: MessageData) { + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + manager.createNotificationChannel(notificationChannel(this)) + } + + val thumbnailBitmap = messageData.thumbnailUrl?.let { + withContext(Dispatchers.IO) { + async { Util.getBitmapFromURL(URL(it)) } + }.await() + } + + val activityIntent = Intent(this, ResourceActivity::class.java).apply { + putExtra("id", messageData.shikimoriId) + putExtra("type", ContentType.ANIME.toString()) + } + val activityPendingIntent: PendingIntent? = TaskStackBuilder.create(this).run { + addNextIntentWithParentStack(activityIntent) + getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + } + + val notification: Notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(this@NotificationService, MAIN_CHANNEL_ID).apply { + setContentTitle(messageData.title) + setContentText(messageData.body) + setLargeIcon(thumbnailBitmap) + setSmallIcon(R.drawable.ic_stat_shirabox_notification) + setContentIntent(activityPendingIntent) + setAutoCancel(true) + }.build() + } else { + Notification.Builder(this@NotificationService).apply { + setContentTitle(messageData.title) + setContentText(messageData.body) + setLargeIcon(thumbnailBitmap) + setSmallIcon(R.drawable.ic_stat_shirabox_notification) + setContentIntent(activityPendingIntent) + setAutoCancel(true) + }.build() + } + + manager.notify(System.nanoTime().toInt(), notification) + } + + private suspend fun saveNotification(messageData: MessageData) { + withContext(Dispatchers.IO) { + db.notificationDao().insertNotification( + NotificationEntity( + contentShikimoriId = messageData.shikimoriId, + receiveTimestamp = System.currentTimeMillis(), + title = messageData.title, + body = messageData.body, + thumbnailUrl = messageData.thumbnailUrl ?: "" + ) + ) + } + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun notificationChannel(context: Context) = NotificationChannel( + MAIN_CHANNEL_ID, + context.getString(R.string.notificaion_channel), + NotificationManager.IMPORTANCE_DEFAULT + ).apply { description = context.getString(R.string.notificaion_channel_description) } +} \ No newline at end of file diff --git a/app/src/main/java/live/shirabox/shirabox/db/AppDatabase.kt b/app/src/main/java/live/shirabox/shirabox/db/AppDatabase.kt index 6cb4144..2345c15 100644 --- a/app/src/main/java/live/shirabox/shirabox/db/AppDatabase.kt +++ b/app/src/main/java/live/shirabox/shirabox/db/AppDatabase.kt @@ -7,17 +7,21 @@ import androidx.room.RoomDatabase import androidx.room.TypeConverters import live.shirabox.core.entity.ContentEntity import live.shirabox.core.entity.EpisodeEntity +import live.shirabox.core.entity.NotificationEntity +import live.shirabox.core.util.Values import live.shirabox.shirabox.db.dao.ContentDao import live.shirabox.shirabox.db.dao.EpisodeDao +import live.shirabox.shirabox.db.dao.NotificationDao @Database( - entities = [ContentEntity::class, EpisodeEntity::class], + entities = [ContentEntity::class, EpisodeEntity::class, NotificationEntity::class], version = 1 ) @TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { abstract fun contentDao(): ContentDao abstract fun episodeDao(): EpisodeDao + abstract fun notificationDao(): NotificationDao companion object { private var INSTANCE: AppDatabase? = null @@ -28,7 +32,7 @@ abstract class AppDatabase : RoomDatabase() { INSTANCE = Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, - "shirabox_db" + Values.DATABASE_NAME ).build() } } diff --git a/app/src/main/java/live/shirabox/shirabox/db/dao/NotificationDao.kt b/app/src/main/java/live/shirabox/shirabox/db/dao/NotificationDao.kt new file mode 100644 index 0000000..52dc8a0 --- /dev/null +++ b/app/src/main/java/live/shirabox/shirabox/db/dao/NotificationDao.kt @@ -0,0 +1,38 @@ +package live.shirabox.shirabox.db.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import kotlinx.coroutines.flow.Flow +import live.shirabox.core.entity.NotificationEntity +import live.shirabox.core.entity.relation.NotificationAndContent + +@Dao +interface NotificationDao { + @Query("SELECT * FROM notification") + fun all(): Flow> + + @Transaction + @Query("SELECT * FROM notification") + fun allNotificationsWithContent(): Flow> + + @Transaction + @Query("SELECT * FROM notification WHERE content_shikimori_id IS :shikimoriId") + fun notificationsFromParent(shikimoriId: Int): Flow> + + @Insert(onConflict = OnConflictStrategy.ABORT) + fun insertNotification(vararg notificationEntity: NotificationEntity) + + @Update + fun updateNotification(vararg notificationEntity: NotificationEntity) + + @Delete + fun deleteNotification(vararg notificationEntity: NotificationEntity) + + @Query("DELETE FROM notification") + fun deleteAll() +} \ No newline at end of file diff --git a/app/src/main/java/live/shirabox/shirabox/ui/activity/MainActivity.kt b/app/src/main/java/live/shirabox/shirabox/ui/activity/MainActivity.kt index 0efede4..c2a273e 100644 --- a/app/src/main/java/live/shirabox/shirabox/ui/activity/MainActivity.kt +++ b/app/src/main/java/live/shirabox/shirabox/ui/activity/MainActivity.kt @@ -9,19 +9,24 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.ui.Modifier import live.shirabox.shirabox.ui.component.navigation.BottomNavigationView +import live.shirabox.shirabox.ui.screen.explore.notifications.NotificationsDialog import live.shirabox.shirabox.ui.theme.ShiraBoxTheme class MainActivity : ComponentActivity() { @OptIn(ExperimentalFoundationApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + setContent { ShiraBoxTheme { // A surface container using the 'background' color from the theme Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background - ) { BottomNavigationView() } + ) { + NotificationsDialog() + BottomNavigationView() + } } } } diff --git a/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceActivity.kt b/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceActivity.kt index 7fbdc14..ea625b3 100644 --- a/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceActivity.kt +++ b/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceActivity.kt @@ -161,6 +161,7 @@ fun Resource( LaunchedEffect(Unit) { model.fetchContent(id) model.fetchRelated(id) + model.clearNotifications(id) } AnimatedVisibility( diff --git a/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceSheetScreens.kt b/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceSheetScreens.kt index eec8a74..9bedb75 100644 --- a/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceSheetScreens.kt +++ b/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceSheetScreens.kt @@ -209,7 +209,7 @@ fun SourcesSheetScreen( coverImage = source.icon, trailingIcon = if (isPinned) Icons.Filled.PushPin else Icons.Outlined.PushPin, onTrailingIconClick = { - model.switchSourcePinStatus(content.shikimoriID, source) + model.switchSourcePinStatus(context, content.shikimoriID, source) } ) { currentSheetScreenState.value = diff --git a/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceViewModel.kt b/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceViewModel.kt index ae16bc4..20a1158 100644 --- a/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceViewModel.kt +++ b/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceViewModel.kt @@ -6,16 +6,23 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.google.firebase.ktx.Firebase +import com.google.firebase.messaging.ktx.messaging import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import live.shirabox.core.datastore.AppDataStore +import live.shirabox.core.datastore.DataStoreScheme import live.shirabox.core.entity.EpisodeEntity import live.shirabox.core.model.Content import live.shirabox.core.model.ContentType +import live.shirabox.core.util.Util import live.shirabox.core.util.Util.Companion.mapContentToEntity import live.shirabox.core.util.Util.Companion.mapEntityToContent import live.shirabox.data.DataSources @@ -131,15 +138,45 @@ class ResourceViewModel(context: Context, private val contentType: ContentType) } } - fun switchSourcePinStatus(id: Int, source: AbstractContentRepository) { + fun switchSourcePinStatus(context: Context, id: Int, repository: AbstractContentRepository) { viewModelScope.launch(Dispatchers.IO) { val content = db?.contentDao()?.getContent(id) - content?.let { - if (pinnedSources.contains(source.name)) pinnedSources.remove(source.name) else pinnedSources.add( - source.name + val subscriptionAllowed = + AppDataStore.read(context, DataStoreScheme.FIELD_SUBSCRIPTION.key).firstOrNull() + ?: DataStoreScheme.FIELD_SUBSCRIPTION.defaultValue + + content?.let { entity -> + val contentTopic = Util.encodeTopic( + repository = repository.name, + actingTeam = repository.name, + contentEnName = entity.enName ) - db?.contentDao()?.updateContents(it.copy(pinnedSources = pinnedSources)) + when (pinnedSources.contains(repository.name)) { + true -> { + pinnedSources.remove(repository.name) + if(subscriptionAllowed) Firebase.messaging.unsubscribeFromTopic(contentTopic) + } + else -> { + pinnedSources.add(repository.name) + if(subscriptionAllowed) Firebase.messaging.subscribeToTopic(contentTopic) + } + } + + db?.contentDao()?.updateContents(entity.copy(pinnedSources = pinnedSources)) + } + } + } + + fun clearNotifications(shikimoriId: Int) { + viewModelScope.launch(Dispatchers.IO) { + db?.notificationDao()?.notificationsFromParent(shikimoriId)?.catch { + it.printStackTrace() + emitAll(emptyFlow()) + }?.map { + it.toTypedArray() + }?.collect { + db.notificationDao().deleteNotification(*it) } } } diff --git a/app/src/main/java/live/shirabox/shirabox/ui/component/general/Notifications.kt b/app/src/main/java/live/shirabox/shirabox/ui/component/general/Notifications.kt new file mode 100644 index 0000000..4fd64f4 --- /dev/null +++ b/app/src/main/java/live/shirabox/shirabox/ui/component/general/Notifications.kt @@ -0,0 +1,126 @@ +package live.shirabox.shirabox.ui.component.general + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.NotificationsActive +import androidx.compose.material.icons.outlined.NotificationsOff +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import live.shirabox.shirabox.R + +@Composable +fun NotificationsRequestDialog(isOpen: MutableState, onConfirm: () -> Unit) { + if(isOpen.value) { + AlertDialog( + onDismissRequest = { + isOpen.value = false + }, + icon = { + Icon( + modifier = Modifier.size(48.dp), + imageVector = Icons.Outlined.NotificationsActive, + tint = MaterialTheme.colorScheme.primary, + contentDescription = null + ) + }, + title = { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.notifications_request), + textAlign = TextAlign.Center + ) + }, + text = { + Text( + stringResource(R.string.notifications_request_text) + ) + }, + confirmButton = { + TextButton( + onClick = { + isOpen.value = false + onConfirm() + } + ) { + Text(stringResource(R.string.enable_notifications)) + } + }, + dismissButton = { + TextButton( + onClick = { + isOpen.value = false + } + ) { + Text(stringResource(R.string.disable_notifications)) + } + } + ) + } +} + +@Composable +fun NotificationsDismissDialog(context: Context, isOpen: MutableState) { + if(isOpen.value) { + AlertDialog( + onDismissRequest = { + isOpen.value = false + }, + icon = { + Icon( + modifier = Modifier.size(48.dp), + imageVector = Icons.Outlined.NotificationsOff, + tint = MaterialTheme.colorScheme.primary, + contentDescription = null + ) + }, + title = { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.notifications_disabled), + textAlign = TextAlign.Center + ) + }, + text = { + Text( + stringResource(R.string.notifications_disabled_text) + ) + }, + confirmButton = { + TextButton( + onClick = { + isOpen.value = false + } + ) { + Text(stringResource(R.string.notifications_disabled_confirm)) + } + }, + dismissButton = { + TextButton( + onClick = { + isOpen.value = false + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.parse("package:${context.applicationContext.packageName}") + } + context.startActivity(intent) + } + ) { + Text(stringResource(R.string.notifications_request)) + } + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/live/shirabox/shirabox/ui/component/navigation/ShiraNavHost.kt b/app/src/main/java/live/shirabox/shirabox/ui/component/navigation/ShiraNavHost.kt index 9fdf6cf..2843a56 100644 --- a/app/src/main/java/live/shirabox/shirabox/ui/component/navigation/ShiraNavHost.kt +++ b/app/src/main/java/live/shirabox/shirabox/ui/component/navigation/ShiraNavHost.kt @@ -7,7 +7,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.navigation import live.shirabox.shirabox.ui.screen.explore.ExploreScreen -import live.shirabox.shirabox.ui.screen.explore.NotificationsScreen +import live.shirabox.shirabox.ui.screen.explore.notifications.NotificationsScreen import live.shirabox.shirabox.ui.screen.favourites.FavouritesScreen import live.shirabox.shirabox.ui.screen.profile.ProfileScreen import live.shirabox.shirabox.ui.screen.profile.history.History @@ -22,6 +22,6 @@ fun ShiraBoxNavHost(navController: NavHostController){ composable(BottomNavItems.Profile.route) { ProfileScreen(navController) } } composable(NestedNavItems.History.route) { History(navController) } - composable(NestedNavItems.Notifications.route) { NotificationsScreen() } + composable(NestedNavItems.Notifications.route) { NotificationsScreen(navController) } } } \ No newline at end of file diff --git a/app/src/main/java/live/shirabox/shirabox/ui/component/top/TopBar.kt b/app/src/main/java/live/shirabox/shirabox/ui/component/top/TopBar.kt index b8f9332..11cbfe3 100644 --- a/app/src/main/java/live/shirabox/shirabox/ui/component/top/TopBar.kt +++ b/app/src/main/java/live/shirabox/shirabox/ui/component/top/TopBar.kt @@ -9,10 +9,13 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.rounded.NotificationsNone +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -27,16 +30,33 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.emptyFlow +import live.shirabox.core.util.Util import live.shirabox.shirabox.R import live.shirabox.shirabox.ui.activity.search.SearchActivity import live.shirabox.shirabox.ui.component.navigation.NestedNavItems +import live.shirabox.shirabox.ui.screen.explore.notifications.NotificationsViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable -fun TopBar(navController: NavController?) { +fun TopBar( + navController: NavController, + model: NotificationsViewModel = viewModel(factory = Util.viewModelFactory { + NotificationsViewModel(context = navController.context.applicationContext) + }) +) { val context = LocalContext.current + val notifications = model.allNotificationsFlow().catch { + it.printStackTrace() + emitAll(emptyFlow()) + }.collectAsStateWithLifecycle(initialValue = emptyList()) + Row( modifier = Modifier .padding(16.dp, 16.dp, 16.dp, 0.dp), @@ -71,14 +91,23 @@ fun TopBar(navController: NavController?) { } IconButton( - onClick = { navController?.navigate(NestedNavItems.Notifications.route) } + modifier = Modifier.requiredSize(48.dp), + onClick = { navController.navigate(NestedNavItems.Notifications.route) } ) { - Icon( - modifier = Modifier.size(24.dp), - imageVector = Icons.Rounded.NotificationsNone, - contentDescription = "Notifications", - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) + BadgedBox( + badge = { + if (notifications.value.isNotEmpty()) Badge { + Text(text = Util.formatBadgeNumber(notifications.value.size)) + } + }) { + Icon( + modifier = Modifier.size(24.dp), + imageVector = Icons.Rounded.NotificationsNone, + contentDescription = "Notifications", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } } } \ No newline at end of file diff --git a/app/src/main/java/live/shirabox/shirabox/ui/screen/explore/BaseMediaScreen.kt b/app/src/main/java/live/shirabox/shirabox/ui/screen/explore/BaseMediaScreen.kt index 9395b27..bec3d8c 100644 --- a/app/src/main/java/live/shirabox/shirabox/ui/screen/explore/BaseMediaScreen.kt +++ b/app/src/main/java/live/shirabox/shirabox/ui/screen/explore/BaseMediaScreen.kt @@ -257,7 +257,7 @@ private fun PopularsGrid(isReady: Boolean, contents: List) { ResourceActivity::class.java ).apply { putExtra("id", it.shikimoriID) - putExtra("type", it.type) + putExtra("type", it.type.toString()) } ) } diff --git a/app/src/main/java/live/shirabox/shirabox/ui/screen/explore/NotificationsScreen.kt b/app/src/main/java/live/shirabox/shirabox/ui/screen/explore/NotificationsScreen.kt deleted file mode 100644 index 2e9622e..0000000 --- a/app/src/main/java/live/shirabox/shirabox/ui/screen/explore/NotificationsScreen.kt +++ /dev/null @@ -1,69 +0,0 @@ -package live.shirabox.shirabox.ui.screen.explore - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.res.imageResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import live.shirabox.shirabox.R -import live.shirabox.shirabox.ui.component.general.ListItem -import live.shirabox.shirabox.ui.component.top.TopBar - -@Composable -fun NotificationsScreen(){ - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - TopBar(null) - - Column( - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - modifier = Modifier.padding(16.dp, 0.dp), - text = stringResource(R.string.notifications), - fontSize = 22.sp, - fontWeight = FontWeight(500) - ) - Text( - modifier = Modifier.padding(16.dp, 0.dp), - text = "05.05.2023", - fontSize = 15.sp, - fontWeight = FontWeight(500) - ) - repeat(4) { - ListItem( - headlineContent = { - Box(Modifier.fillMaxWidth()) { - Text(modifier = Modifier.align(Alignment.TopStart), - text = "Название") - Text(modifier = Modifier.align(Alignment.CenterEnd), - text = "14:32", - fontSize = 12.sp) - } - }, - supportingString = "Вышла новая серия 10 от Unknown Team!", - coverImage = ImageBitmap.imageResource(id = R.drawable.blank) - ) { - /* TODO */ - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/live/shirabox/shirabox/ui/screen/explore/notifications/NotificationsDialog.kt b/app/src/main/java/live/shirabox/shirabox/ui/screen/explore/notifications/NotificationsDialog.kt new file mode 100644 index 0000000..8c6aee2 --- /dev/null +++ b/app/src/main/java/live/shirabox/shirabox/ui/screen/explore/notifications/NotificationsDialog.kt @@ -0,0 +1,56 @@ +package live.shirabox.shirabox.ui.screen.explore.notifications + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import live.shirabox.shirabox.ui.component.general.NotificationsDismissDialog +import live.shirabox.shirabox.ui.component.general.NotificationsRequestDialog + +@Composable +fun NotificationsDialog() { + val context = LocalContext.current + val activity = context as Activity + + val openRequestDialog = remember { mutableStateOf(false) } + val openDismissDialog = remember { mutableStateOf(false) } + + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = { isGranted: Boolean -> + openDismissDialog.value = !isGranted + } + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val showRationale = !ActivityCompat.shouldShowRequestPermissionRationale( + activity, Manifest.permission.POST_NOTIFICATIONS) + + if(!isNotificationsPermissionGranted(context) && showRationale){ + openRequestDialog.value = true + } + + NotificationsRequestDialog(isOpen = openRequestDialog) { + launcher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + + NotificationsDismissDialog(context = context, isOpen = openDismissDialog) +} + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +private fun isNotificationsPermissionGranted(context: Context): Boolean = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED \ No newline at end of file diff --git a/app/src/main/java/live/shirabox/shirabox/ui/screen/explore/notifications/NotificationsScreen.kt b/app/src/main/java/live/shirabox/shirabox/ui/screen/explore/notifications/NotificationsScreen.kt new file mode 100644 index 0000000..0653673 --- /dev/null +++ b/app/src/main/java/live/shirabox/shirabox/ui/screen/explore/notifications/NotificationsScreen.kt @@ -0,0 +1,182 @@ +package live.shirabox.shirabox.ui.screen.explore.notifications + +import android.content.Intent +import android.text.format.DateUtils +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.emptyFlow +import live.shirabox.core.util.Util +import live.shirabox.shirabox.R +import live.shirabox.shirabox.ui.activity.resource.ResourceActivity +import live.shirabox.shirabox.ui.component.general.ListItem +import live.shirabox.shirabox.ui.component.top.TopBar +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Date + +@Composable +fun NotificationsScreen( + navController: NavController, + model: NotificationsViewModel = viewModel(factory = Util.viewModelFactory { + NotificationsViewModel(context = navController.context.applicationContext) + }) +){ + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + TopBar(navController) + val context = LocalContext.current + + val notifications = model.notificationsWithContentFlow().catch { + it.printStackTrace() + emitAll(emptyFlow()) + }.collectAsStateWithLifecycle(initialValue = emptyList()) + + val filteredNotificationsByDate by remember(notifications.value) { + derivedStateOf { + notifications.value.groupBy { + when (DateUtils.isToday(it.notificationEntity.receiveTimestamp)) { + true -> context.resources.getString(R.string.today) + false -> DateUtils.formatSameDayTime( + it.notificationEntity.receiveTimestamp, + System.currentTimeMillis(), + DateFormat.SHORT, + DateFormat.SHORT + ).toString() + } + } + } + } + + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp, 0.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.notifications), + fontSize = 22.sp, + fontWeight = FontWeight(500) + ) + + Button( + enabled = notifications.value.isNotEmpty(), + onClick = { + model.clearNotifications() + } + ) { + Text(stringResource(id = R.string.clear_notifications)) + } + } + + if(notifications.value.isEmpty()) { + Column( + modifier = Modifier + .padding(64.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + modifier = Modifier.size(64.dp), + imageVector = Icons.Default.AutoAwesome, + contentDescription = "awesome", + tint = MaterialTheme.colorScheme.surfaceTint.copy(0.4f) + ) + Text( + text = stringResource(id = R.string.no_notifications), + fontSize = 15.sp, + fontWeight = FontWeight.Normal, + textAlign = TextAlign.Center + ) + } + } + + filteredNotificationsByDate.forEach { entry -> + Text( + modifier = Modifier + .padding(16.dp, 0.dp) + .fillMaxWidth(), + text = entry.key, + textAlign = TextAlign.Left, + fontSize = 15.sp, + fontWeight = FontWeight(500) + ) + + entry.value.forEach { + val languageCode = Locale.current.language + + val time = SimpleDateFormat( + "H:mm", + java.util.Locale(languageCode) + ).format(Date(it.notificationEntity.receiveTimestamp)) + + ListItem( + headlineContent = { + Box(Modifier.fillMaxWidth()) { + Text(modifier = Modifier.align(Alignment.TopStart), + text = it.contentEntity.name) + Text(modifier = Modifier.align(Alignment.CenterEnd), + text = time, + fontSize = 12.sp) + } + }, + supportingString = it.notificationEntity.body, + coverImage = it.contentEntity.image + ) { + context.startActivity( + Intent( + context, + ResourceActivity::class.java + ).apply { + putExtra("id", it.contentEntity.shikimoriID) + putExtra("type", it.contentEntity.type.toString()) + } + ) + model.removeNotification(it.notificationEntity) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/live/shirabox/shirabox/ui/screen/explore/notifications/NotificationsViewModel.kt b/app/src/main/java/live/shirabox/shirabox/ui/screen/explore/notifications/NotificationsViewModel.kt new file mode 100644 index 0000000..b8ed567 --- /dev/null +++ b/app/src/main/java/live/shirabox/shirabox/ui/screen/explore/notifications/NotificationsViewModel.kt @@ -0,0 +1,34 @@ +package live.shirabox.shirabox.ui.screen.explore.notifications + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.launch +import live.shirabox.core.entity.NotificationEntity +import live.shirabox.core.entity.relation.NotificationAndContent +import live.shirabox.shirabox.db.AppDatabase + +class NotificationsViewModel(context: Context): ViewModel() { + private val db = AppDatabase.getAppDataBase(context) + + fun allNotificationsFlow(): Flow> = + db?.notificationDao()?.all() ?: emptyFlow() + + fun notificationsWithContentFlow(): Flow> = + db?.notificationDao()?.allNotificationsWithContent() ?: emptyFlow() + + fun removeNotification(entity: NotificationEntity) { + viewModelScope.launch(Dispatchers.IO) { + db?.notificationDao()?.deleteNotification(entity) + } + } + + fun clearNotifications() { + viewModelScope.launch(Dispatchers.IO) { + db?.notificationDao()?.deleteAll() + } + } +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8705b80..9e45d2b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -28,6 +28,8 @@ Среди новинок Популярно Уведомления + Сегодня + + Новые серии + Уведомления о новых выпусках эпизодов + @@ -98,14 +106,14 @@ Ничего не найдено Включить уведомления - Вы сможете получать уведомления о выходе новых серий и обновлений ShiraBox. Обещаем, мы не будем надоедать вам ❤️️ + Вы сможете получать уведомления о выходе новых серий и обновлений ShiraBox. \nОбещаем, мы не будем надоедать вам! Включить уведомления Нет, спасибо Уведомления отключены - Вы больше не сможете получать уведомления о выходе новых серий и обновлений ShiraBox. Обещаем, мы не будем надоедать вам ❤️ + Вы больше не сможете получать уведомления о выходе новых серий и обновлений ShiraBox. \nУведомления можно включить перейдя в раздел приложений в настройках вашего устройства. Хорошо Очистить Уведомлений нет diff --git a/build.gradle.kts b/build.gradle.kts index 200ecd2..332a87d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,8 @@ +buildscript { + dependencies { + classpath("com.google.gms:google-services:4.4.1") + } +} // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { id ("com.android.application") version "8.3.1" apply false diff --git a/core/src/main/java/live/shirabox/core/entity/NotificationEntity.kt b/core/src/main/java/live/shirabox/core/entity/NotificationEntity.kt new file mode 100644 index 0000000..4c78f91 --- /dev/null +++ b/core/src/main/java/live/shirabox/core/entity/NotificationEntity.kt @@ -0,0 +1,15 @@ +package live.shirabox.core.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "notification") +data class NotificationEntity( + @PrimaryKey(autoGenerate = true) val uid: Long = 0, + @ColumnInfo(name = "content_shikimori_id") val contentShikimoriId: Int, + @ColumnInfo(name = "receive_timestamp") val receiveTimestamp: Long, + @ColumnInfo(name = "title") val title: String, + @ColumnInfo(name = "body") val body: String, + @ColumnInfo(name = "thumbnail") val thumbnailUrl: String +) \ No newline at end of file diff --git a/core/src/main/java/live/shirabox/core/entity/relation/NotificationAndContent.kt b/core/src/main/java/live/shirabox/core/entity/relation/NotificationAndContent.kt new file mode 100644 index 0000000..103d7f9 --- /dev/null +++ b/core/src/main/java/live/shirabox/core/entity/relation/NotificationAndContent.kt @@ -0,0 +1,16 @@ +package live.shirabox.core.entity.relation + +import androidx.room.Embedded +import androidx.room.Relation +import live.shirabox.core.entity.ContentEntity +import live.shirabox.core.entity.NotificationEntity + +data class NotificationAndContent ( + @Embedded val notificationEntity: NotificationEntity, + + @Relation( + parentColumn = "content_shikimori_id", + entityColumn = "shikimori_id" + ) + val contentEntity: ContentEntity +) \ No newline at end of file diff --git a/core/src/main/java/live/shirabox/core/serializable/Topic.kt b/core/src/main/java/live/shirabox/core/serializable/Topic.kt new file mode 100644 index 0000000..83a00ea --- /dev/null +++ b/core/src/main/java/live/shirabox/core/serializable/Topic.kt @@ -0,0 +1,11 @@ +package live.shirabox.core.serializable + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Topic( + val repository: String, + @SerialName("acting_team") val actingTeam: String, + val md5: String +) \ No newline at end of file diff --git a/core/src/main/java/live/shirabox/core/util/Util.kt b/core/src/main/java/live/shirabox/core/util/Util.kt index e444af9..56222bc 100644 --- a/core/src/main/java/live/shirabox/core/util/Util.kt +++ b/core/src/main/java/live/shirabox/core/util/Util.kt @@ -3,6 +3,8 @@ package live.shirabox.core.util import android.content.Context import android.content.pm.PackageManager import android.content.res.Configuration +import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.os.Build import android.text.Html import androidx.core.view.WindowInsetsControllerCompat @@ -10,10 +12,20 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.media3.common.C import com.google.accompanist.systemuicontroller.SystemUiController +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import live.shirabox.core.entity.ContentEntity import live.shirabox.core.model.Content +import live.shirabox.core.serializable.Topic +import java.net.URL +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.time.Duration.Companion.milliseconds + class Util { companion object { fun hideSystemUi(controller: SystemUiController) { @@ -100,6 +112,37 @@ class Util { ) } + @OptIn(ExperimentalEncodingApi::class) + fun encodeTopic(repository: String, actingTeam: String, contentEnName: String): String { + val json = Json.encodeToString( + Topic( + repository = repository, + actingTeam = actingTeam, + md5 = contentEnName.md5() + ) + ) + + return Base64.encode(json.toByteArray()) + } + suspend fun getBitmapFromURL(url: URL): Bitmap? { + return try { + val inputStream = withContext(Dispatchers.IO) { + async { + val connection = url.openConnection() + connection.setDoInput(true) + connection.connect() + connection.inputStream + } + } + + BitmapFactory.decodeStream(inputStream.await()) + } catch (_: Exception) { null } + } + + fun formatBadgeNumber(number: Int): String { + return if (number < 9) number.toString() else "9+" + } + inline fun viewModelFactory(crossinline f: () -> VM) = object : ViewModelProvider.Factory { override fun create(modelClass: Class): T = f() as T diff --git a/core/src/main/java/live/shirabox/core/util/Values.kt b/core/src/main/java/live/shirabox/core/util/Values.kt index f3f2569..f727ac4 100644 --- a/core/src/main/java/live/shirabox/core/util/Values.kt +++ b/core/src/main/java/live/shirabox/core/util/Values.kt @@ -4,6 +4,7 @@ class Values { companion object { const val INSTANT_SEEK_TIME = 10000 const val CONTROLS_HIDE_DELAY = 3000L + const val DATABASE_NAME = "shirabox_db" const val USER_AGENT = "Mozilla/5.0 (Linux; Android 13; SAMSUNG SM-A127F) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/21.0 Chrome/110.0.5481.154 Mobile Safari/537.36" } } \ No newline at end of file