TiebaLite/app/src/main/java/com/huanchengfly/tieba/post/MainActivityV2.kt

392 lines
17 KiB
Kotlin

package com.huanchengfly.tieba.post
import android.annotation.SuppressLint
import android.app.job.JobInfo
import android.app.job.JobScheduler
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.Surface
import androidx.compose.material.SwipeableDefaults
import androidx.compose.material.Text
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavController
import androidx.navigation.plusAssign
import androidx.window.layout.FoldingFeature
import androidx.window.layout.WindowInfoTracker
import com.github.panpf.sketch.compose.AsyncImage
import com.github.panpf.sketch.fetch.newFileUri
import com.google.accompanist.navigation.animation.rememberAnimatedNavController
import com.google.accompanist.navigation.material.BottomSheetNavigator
import com.google.accompanist.navigation.material.ExperimentalMaterialNavigationApi
import com.google.accompanist.navigation.material.ModalBottomSheetLayout
import com.google.accompanist.systemuicontroller.SystemUiController
import com.huanchengfly.tieba.post.api.retrofit.exception.getErrorMessage
import com.huanchengfly.tieba.post.arch.BaseComposeActivity
import com.huanchengfly.tieba.post.arch.GlobalEvent
import com.huanchengfly.tieba.post.arch.emitGlobalEvent
import com.huanchengfly.tieba.post.arch.onGlobalEvent
import com.huanchengfly.tieba.post.services.NotifyJobService
import com.huanchengfly.tieba.post.ui.common.theme.compose.ExtendedTheme
import com.huanchengfly.tieba.post.ui.page.NavGraphs
import com.huanchengfly.tieba.post.ui.page.destinations.MainPageDestination
import com.huanchengfly.tieba.post.ui.utils.DevicePosture
import com.huanchengfly.tieba.post.ui.utils.isBookPosture
import com.huanchengfly.tieba.post.ui.utils.isSeparating
import com.huanchengfly.tieba.post.ui.widgets.compose.AlertDialog
import com.huanchengfly.tieba.post.ui.widgets.compose.DialogNegativeButton
import com.huanchengfly.tieba.post.ui.widgets.compose.DialogPositiveButton
import com.huanchengfly.tieba.post.ui.widgets.compose.rememberDialogState
import com.huanchengfly.tieba.post.utils.AccountUtil
import com.huanchengfly.tieba.post.utils.ClientUtils
import com.huanchengfly.tieba.post.utils.JobServiceUtil
import com.huanchengfly.tieba.post.utils.PermissionUtils
import com.huanchengfly.tieba.post.utils.PickMediasRequest
import com.huanchengfly.tieba.post.utils.ThemeUtil
import com.huanchengfly.tieba.post.utils.TiebaUtil
import com.huanchengfly.tieba.post.utils.isIgnoringBatteryOptimizations
import com.huanchengfly.tieba.post.utils.newIntentFilter
import com.huanchengfly.tieba.post.utils.registerPickMediasLauncher
import com.huanchengfly.tieba.post.utils.requestIgnoreBatteryOptimizations
import com.huanchengfly.tieba.post.utils.requestPermission
import com.ramcosta.composedestinations.DestinationsNavHost
import com.ramcosta.composedestinations.animations.defaults.RootNavGraphDefaultAnimations
import com.ramcosta.composedestinations.animations.rememberAnimatedNavHostEngine
import com.ramcosta.composedestinations.navigation.dependency
import com.ramcosta.composedestinations.spec.DestinationSpec
import com.ramcosta.composedestinations.utils.currentDestinationAsState
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
val LocalNotificationCountFlow =
staticCompositionLocalOf<Flow<Int>> { throw IllegalStateException("not allowed here!") }
val LocalDevicePosture =
staticCompositionLocalOf<State<DevicePosture>> { throw IllegalStateException("not allowed here!") }
val LocalNavController =
staticCompositionLocalOf<NavController> { throw IllegalStateException("not allowed here!") }
val LocalDestination = compositionLocalOf<DestinationSpec<*>?> { null }
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterialNavigationApi::class)
@Composable
fun rememberBottomSheetNavigator(
animationSpec: AnimationSpec<Float> = SwipeableDefaults.AnimationSpec,
skipHalfExpanded: Boolean = false
): BottomSheetNavigator {
val sheetState = rememberModalBottomSheetState(
ModalBottomSheetValue.Hidden,
animationSpec = animationSpec,
skipHalfExpanded = skipHalfExpanded
)
return remember { BottomSheetNavigator(sheetState) }
}
@AndroidEntryPoint
class MainActivityV2 : BaseComposeActivity() {
private val handler = Handler(Looper.getMainLooper())
private val newMessageReceiver: BroadcastReceiver = NewMessageReceiver()
private val notificationCountFlow: MutableSharedFlow<Int> =
MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
private val pickMediasLauncher =
registerPickMediasLauncher {
emitGlobalEvent(GlobalEvent.SelectedImages(it.id, it.uris))
}
private val devicePostureFlow: StateFlow<DevicePosture> by lazy {
WindowInfoTracker.getOrCreate(this)
.windowLayoutInfo(this)
.flowWithLifecycle(lifecycle)
.map { layoutInfo ->
val foldingFeature =
layoutInfo.displayFeatures
.filterIsInstance<FoldingFeature>()
.firstOrNull()
when {
isBookPosture(foldingFeature) ->
DevicePosture.BookPosture(foldingFeature.bounds)
isSeparating(foldingFeature) ->
DevicePosture.Separating(foldingFeature.bounds, foldingFeature.orientation)
else -> DevicePosture.NormalPosture
}
}
.stateIn(
scope = lifecycleScope,
started = SharingStarted.Eagerly,
initialValue = DevicePosture.NormalPosture
)
}
private fun fetchAccount() {
lifecycleScope.launch(Dispatchers.Main) {
if (AccountUtil.isLoggedIn()) {
AccountUtil.fetchAccountFlow()
.flowOn(Dispatchers.IO)
.catch { e ->
toastShort(e.getErrorMessage())
e.printStackTrace()
}
.collect()
}
}
}
private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && AccountUtil.isLoggedIn()) {
requestPermission {
permissions = listOf(PermissionUtils.POST_NOTIFICATIONS)
description = getString(R.string.desc_permission_post_notifications)
}
}
}
private fun initAutoSign() {
runCatching {
TiebaUtil.initAutoSign(this)
}
}
override fun onStart() {
super.onStart()
runCatching {
ContextCompat.registerReceiver(
this,
newMessageReceiver,
newIntentFilter(NotifyJobService.ACTION_NEW_MESSAGE),
ContextCompat.RECEIVER_NOT_EXPORTED
)
startService(Intent(this, NotifyJobService::class.java))
val builder = JobInfo.Builder(
JobServiceUtil.getJobId(this),
ComponentName(this, NotifyJobService::class.java)
)
.setPersisted(true)
.setPeriodic(30 * 60 * 1000L)
.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY)
val jobScheduler = getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler
jobScheduler.schedule(builder.build())
}
handler.postDelayed({
requestNotificationPermission()
}, 100)
}
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
window.decorView.setBackgroundColor(Color.TRANSPARENT)
window.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
launch {
ClientUtils.setActiveTimestamp()
}
}
override fun onCreateContent(systemUiController: SystemUiController) {
super.onCreateContent(systemUiController)
fetchAccount()
initAutoSign()
}
@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterialNavigationApi::class)
@Composable
override fun Content() {
val okSignAlertDialogState = rememberDialogState()
AlertDialog(
dialogState = okSignAlertDialogState,
title = { Text(text = stringResource(id = R.string.title_dialog_oksign_battery_optimization)) },
content = { Text(text = stringResource(id = R.string.message_dialog_oksign_battery_optimization)) },
buttons = {
DialogPositiveButton(
text = stringResource(id = R.string.button_go_to_ignore_battery_optimization),
onClick = {
requestIgnoreBatteryOptimizations()
}
)
DialogNegativeButton(
text = stringResource(id = R.string.button_cancel)
)
DialogNegativeButton(
text = stringResource(id = R.string.button_dont_remind_again),
onClick = {
appPreferences.ignoreBatteryOptimizationsDialog = true
}
)
},
)
LaunchedEffect(Unit) {
if (appPreferences.autoSign && !isIgnoringBatteryOptimizations() && !appPreferences.ignoreBatteryOptimizationsDialog) {
okSignAlertDialogState.show()
}
}
onGlobalEvent<GlobalEvent.StartSelectImages> {
pickMediasLauncher.launch(
PickMediasRequest(it.id, it.maxCount, it.mediaType)
)
}
CompositionLocalProvider(
LocalNotificationCountFlow provides notificationCountFlow,
LocalDevicePosture provides devicePostureFlow.collectAsState(),
) {
Box {
TranslucentThemeBackground()
Surface(
color = ExtendedTheme.colors.background
) {
val animationSpec = remember {
spring(
stiffness = Spring.StiffnessMediumLow,
visibilityThreshold = IntOffset.VisibilityThreshold
)
}
val engine = rememberAnimatedNavHostEngine(
navHostContentAlignment = Alignment.TopStart,
rootDefaultAnimations = RootNavGraphDefaultAnimations(
enterTransition = {
slideIntoContainer(
AnimatedContentScope.SlideDirection.Start,
animationSpec = animationSpec,
initialOffset = { it }
)
},
exitTransition = {
slideOutOfContainer(
AnimatedContentScope.SlideDirection.End,
animationSpec = animationSpec,
targetOffset = { -it }
)
},
popEnterTransition = {
slideIntoContainer(
AnimatedContentScope.SlideDirection.Start,
animationSpec = animationSpec,
initialOffset = { -it }
)
},
popExitTransition = {
slideOutOfContainer(
AnimatedContentScope.SlideDirection.End,
animationSpec = animationSpec,
targetOffset = { it }
)
},
),
)
val navController = rememberAnimatedNavController()
val bottomSheetNavigator =
rememberBottomSheetNavigator(
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
skipHalfExpanded = true
)
navController.navigatorProvider += bottomSheetNavigator
val currentDestination by navController.currentDestinationAsState()
CompositionLocalProvider(
LocalNavController provides navController,
LocalDestination provides currentDestination
) {
ModalBottomSheetLayout(
bottomSheetNavigator = bottomSheetNavigator,
sheetShape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
sheetBackgroundColor = ExtendedTheme.colors.windowBackground,
) {
DestinationsNavHost(
navController = navController,
navGraph = NavGraphs.root,
engine = engine,
dependenciesContainerBuilder = {
dependency(MainPageDestination) { this@MainActivityV2 }
}
)
}
}
}
}
}
}
@Composable
private fun TranslucentThemeBackground() {
if (ThemeUtil.isTranslucentTheme(ExtendedTheme.colors.theme)) {
val backgroundPath by rememberPreferenceAsMutableState(
key = stringPreferencesKey("translucent_theme_background_path"),
defaultValue = ""
)
val backgroundUri by remember { derivedStateOf { newFileUri(backgroundPath) } }
AsyncImage(
imageUri = backgroundUri,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
}
private inner class NewMessageReceiver : BroadcastReceiver() {
@SuppressLint("RestrictedApi")
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == NotifyJobService.ACTION_NEW_MESSAGE) {
val channel = intent.getStringExtra("channel")
val count = intent.getIntExtra("count", 0)
if (channel != null && channel == NotifyJobService.CHANNEL_TOTAL) {
lifecycleScope.launch {
notificationCountFlow.emit(count)
}
}
}
}
}
}