feat(app): deep links, notification launch, and night splash
Add wallenc:// URI patterns, manifest VIEW intent-filters, and NavDeepLink with handleDeepLink from MainActivity. Move FGS notification PendingIntent constants to WallencExternalLaunch. Theme splash/window background via splash_screen_background in values and values-night. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -28,6 +28,14 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="wallenc" android:host="main" />
|
||||
<data android:scheme="wallenc" android:host="tasks" />
|
||||
<data android:scheme="wallenc" android:host="settings" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
package com.github.nullptroma.wallenc.app
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.github.nullptroma.wallenc.app.auth.YandexSignInService
|
||||
import com.github.nullptroma.wallenc.ui.WallencUi
|
||||
import com.github.nullptroma.wallenc.ui.navigation.matchesWallencDeepLink
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
@@ -24,7 +28,8 @@ class MainActivity : ComponentActivity() {
|
||||
@Inject
|
||||
lateinit var yandexSignInService: YandexSignInService
|
||||
|
||||
private val openTaskPipelineFromNotification = mutableIntStateOf(0)
|
||||
private val deepLinkPulse = mutableIntStateOf(0)
|
||||
private var deepLinkIntentForNav: Intent by mutableStateOf(Intent())
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -34,11 +39,16 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
Timber.plant(Timber.DebugTree())
|
||||
|
||||
consumeOpenTaskPipelineIntent(intent)
|
||||
deepLinkIntentForNav = intent
|
||||
if (intent.matchesWallencDeepLink()) {
|
||||
deepLinkPulse.intValue++
|
||||
}
|
||||
|
||||
setContent {
|
||||
val pulse by deepLinkPulse
|
||||
WallencUi(
|
||||
taskPipelineOpenRequestCount = openTaskPipelineFromNotification.intValue,
|
||||
deepLinkPulse = pulse,
|
||||
deepLinkIntent = deepLinkIntentForNav,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -46,13 +56,10 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
consumeOpenTaskPipelineIntent(intent)
|
||||
}
|
||||
|
||||
private fun consumeOpenTaskPipelineIntent(intent: Intent?) {
|
||||
if (intent?.getBooleanExtra(EXTRA_OPEN_TASK_PIPELINE, false) != true) return
|
||||
intent.removeExtra(EXTRA_OPEN_TASK_PIPELINE)
|
||||
openTaskPipelineFromNotification.intValue += 1
|
||||
deepLinkIntentForNav = intent
|
||||
if (intent.matchesWallencDeepLink()) {
|
||||
deepLinkPulse.intValue++
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
@@ -75,9 +82,6 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_OPEN_TASK_PIPELINE =
|
||||
"com.github.nullptroma.wallenc.app.EXTRA_OPEN_TASK_PIPELINE"
|
||||
|
||||
private const val NOTIFICATION_PERMISSION_REQUEST_CODE = 100
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.github.nullptroma.wallenc.app.navigation
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import com.github.nullptroma.wallenc.app.MainActivity
|
||||
import com.github.nullptroma.wallenc.ui.navigation.WallencDeepLinks
|
||||
|
||||
/**
|
||||
* Запуск [MainActivity] и взаимодействие с foreground-сервисом из **вне** Compose: [android.app.PendingIntent],
|
||||
* стабильные action, id канала/нотификации.
|
||||
*
|
||||
* URI см. [WallencDeepLinks] в `:ui` — тот же контракт, что у [androidx.navigation.navDeepLink] и манифеста.
|
||||
*/
|
||||
object WallencExternalLaunch {
|
||||
|
||||
/** Коды [android.app.PendingIntent]: у каждого сценария свой requestCode. */
|
||||
object PendingIntentRequestCodes {
|
||||
const val NOTIFICATION_TASK_PIPELINE_CANCEL_SERVICE = 0
|
||||
const val NOTIFICATION_TASK_PIPELINE_OPEN_TASKS = 2
|
||||
}
|
||||
|
||||
/**
|
||||
* [Intent.getAction] для [com.github.nullptroma.wallenc.app.tasks.TaskPipelineForegroundService]
|
||||
* из кнопки уведомления ([PendingIntent.getService]).
|
||||
*/
|
||||
const val FOREGROUND_SERVICE_ACTION_CANCEL_ALL_TASKS =
|
||||
"com.github.nullptroma.wallenc.action.CANCEL_ALL_TASKS"
|
||||
|
||||
object ForegroundTaskPipelineNotification {
|
||||
const val CHANNEL_ID = "wallenc_task_pipeline"
|
||||
const val NOTIFICATION_ID = 1001
|
||||
}
|
||||
|
||||
fun mainActivityViewIntent(context: Context, uri: Uri): Intent =
|
||||
Intent(Intent.ACTION_VIEW, uri).apply {
|
||||
setClass(context, MainActivity::class.java)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
}
|
||||
|
||||
fun mainActivityViewIntentForTasks(context: Context): Intent =
|
||||
mainActivityViewIntent(context, Uri.parse(WallencDeepLinks.TASKS_URI_PATTERN))
|
||||
}
|
||||
@@ -11,8 +11,8 @@ import android.os.IBinder
|
||||
import android.view.View
|
||||
import android.widget.RemoteViews
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.github.nullptroma.wallenc.app.MainActivity
|
||||
import com.github.nullptroma.wallenc.app.R
|
||||
import com.github.nullptroma.wallenc.app.navigation.WallencExternalLaunch
|
||||
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
|
||||
import com.github.nullptroma.wallenc.domain.tasks.TaskForegroundItem
|
||||
import com.github.nullptroma.wallenc.domain.tasks.TaskForegroundUiState
|
||||
@@ -58,7 +58,9 @@ class TaskPipelineForegroundService : Service() {
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_CANCEL_ALL_TASKS && ::orchestrator.isInitialized) {
|
||||
if (intent?.action == WallencExternalLaunch.FOREGROUND_SERVICE_ACTION_CANCEL_ALL_TASKS &&
|
||||
::orchestrator.isInitialized
|
||||
) {
|
||||
orchestrator.cancelAll()
|
||||
}
|
||||
return START_STICKY
|
||||
@@ -68,7 +70,10 @@ class TaskPipelineForegroundService : Service() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ensureChannel()
|
||||
startForeground(FOREGROUND_NOTIFICATION_ID, buildPlaceholderNotification())
|
||||
startForeground(
|
||||
WallencExternalLaunch.ForegroundTaskPipelineNotification.NOTIFICATION_ID,
|
||||
buildPlaceholderNotification(),
|
||||
)
|
||||
|
||||
val nm = getSystemService(NotificationManager::class.java)
|
||||
|
||||
@@ -85,7 +90,7 @@ class TaskPipelineForegroundService : Service() {
|
||||
TaskForegroundUiState.Hidden -> {
|
||||
repeat = false
|
||||
if (sawVisible) {
|
||||
nm.cancel(FOREGROUND_NOTIFICATION_ID)
|
||||
nm.cancel(WallencExternalLaunch.ForegroundTaskPipelineNotification.NOTIFICATION_ID)
|
||||
indeterminateDotsPhaseByTaskId.clear()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
@@ -97,7 +102,7 @@ class TaskPipelineForegroundService : Service() {
|
||||
if (ui.tasks.isNotEmpty()) {
|
||||
sawVisible = true
|
||||
nm.notify(
|
||||
FOREGROUND_NOTIFICATION_ID,
|
||||
WallencExternalLaunch.ForegroundTaskPipelineNotification.NOTIFICATION_ID,
|
||||
buildAccumulatedNotification(ui.tasks),
|
||||
)
|
||||
}
|
||||
@@ -129,7 +134,7 @@ class TaskPipelineForegroundService : Service() {
|
||||
private fun ensureChannel() {
|
||||
val nm = getSystemService(NotificationManager::class.java)
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
WallencExternalLaunch.ForegroundTaskPipelineNotification.CHANNEL_ID,
|
||||
getString(R.string.task_notification_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
)
|
||||
@@ -137,7 +142,7 @@ class TaskPipelineForegroundService : Service() {
|
||||
}
|
||||
|
||||
private fun buildPlaceholderNotification(): Notification =
|
||||
NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
NotificationCompat.Builder(this, WallencExternalLaunch.ForegroundTaskPipelineNotification.CHANNEL_ID)
|
||||
.setContentTitle(getString(R.string.task_notification_title))
|
||||
.setContentText(getString(R.string.task_notification_preparing))
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
@@ -171,7 +176,10 @@ class TaskPipelineForegroundService : Service() {
|
||||
)
|
||||
}
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
return NotificationCompat.Builder(
|
||||
this,
|
||||
WallencExternalLaunch.ForegroundTaskPipelineNotification.CHANNEL_ID,
|
||||
)
|
||||
.setContentTitle(getString(R.string.task_notification_title))
|
||||
.setContentText(collapsedSubtext)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
@@ -191,19 +199,18 @@ class TaskPipelineForegroundService : Service() {
|
||||
private fun openTaskPipelinePendingIntent(): PendingIntent =
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
REQUEST_CODE_OPEN_TASK_PIPELINE,
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
putExtra(MainActivity.EXTRA_OPEN_TASK_PIPELINE, true)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
},
|
||||
WallencExternalLaunch.PendingIntentRequestCodes.NOTIFICATION_TASK_PIPELINE_OPEN_TASKS,
|
||||
WallencExternalLaunch.mainActivityViewIntentForTasks(this),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
private fun cancelAllTasksPendingIntent(): PendingIntent =
|
||||
PendingIntent.getService(
|
||||
this,
|
||||
0,
|
||||
Intent(this, TaskPipelineForegroundService::class.java).setAction(ACTION_CANCEL_ALL_TASKS),
|
||||
WallencExternalLaunch.PendingIntentRequestCodes.NOTIFICATION_TASK_PIPELINE_CANCEL_SERVICE,
|
||||
Intent(this, TaskPipelineForegroundService::class.java).setAction(
|
||||
WallencExternalLaunch.FOREGROUND_SERVICE_ACTION_CANCEL_ALL_TASKS,
|
||||
),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
@@ -334,14 +341,6 @@ class TaskPipelineForegroundService : Service() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ACTION_CANCEL_ALL_TASKS =
|
||||
"com.github.nullptroma.wallenc.action.CANCEL_ALL_TASKS"
|
||||
|
||||
private const val REQUEST_CODE_OPEN_TASK_PIPELINE = 2
|
||||
|
||||
private const val CHANNEL_ID = "wallenc_task_pipeline"
|
||||
private const val FOREGROUND_NOTIFICATION_ID = 1001
|
||||
|
||||
private const val NOTIFICATION_QUEUE_STEP_MS = 500L
|
||||
|
||||
/** Must match [R.layout.notification_wallenc_tasks_big] row count. */
|
||||
|
||||
5
app/src/main/res/values-night/colors.xml
Normal file
5
app/src/main/res/values-night/colors.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Тёмная тема: фон splash и окна до отрисовки Compose -->
|
||||
<color name="splash_screen_background">#FF1C1B1F</color>
|
||||
</resources>
|
||||
@@ -1,2 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources />
|
||||
<resources>
|
||||
<color name="splash_screen_background">#FFFFFFFF</color>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<style name="Theme.Wallenc" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
<style name="Theme.Wallenc" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<!-- До первого кадра Compose и системный splash (12+) -->
|
||||
<item name="android:windowBackground">@color/splash_screen_background</item>
|
||||
<item name="android:windowSplashScreenBackground" tools:targetApi="31">
|
||||
@color/splash_screen_background
|
||||
</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.github.nullptroma.wallenc.ui
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
@@ -26,7 +27,10 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.navDeepLink
|
||||
import com.github.nullptroma.wallenc.ui.navigation.NavBarItemData
|
||||
import com.github.nullptroma.wallenc.ui.navigation.WallencDeepLinks
|
||||
import com.github.nullptroma.wallenc.ui.navigation.matchesWallencDeepLink
|
||||
import com.github.nullptroma.wallenc.ui.navigation.rememberNavigationState
|
||||
import com.github.nullptroma.wallenc.ui.screens.main.MainRoute
|
||||
import com.github.nullptroma.wallenc.ui.screens.main.MainScreen
|
||||
@@ -40,10 +44,16 @@ import com.github.nullptroma.wallenc.ui.theme.WallencTheme
|
||||
|
||||
|
||||
@Composable
|
||||
fun WallencUi(taskPipelineOpenRequestCount: Int = 0) {
|
||||
fun WallencUi(
|
||||
deepLinkPulse: Int = 0,
|
||||
deepLinkIntent: Intent = Intent(),
|
||||
) {
|
||||
WallencTheme {
|
||||
Surface {
|
||||
WallencNavRoot(taskPipelineOpenRequestCount = taskPipelineOpenRequestCount)
|
||||
WallencNavRoot(
|
||||
deepLinkPulse = deepLinkPulse,
|
||||
deepLinkIntent = deepLinkIntent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,7 +62,8 @@ fun WallencUi(taskPipelineOpenRequestCount: Int = 0) {
|
||||
@Composable
|
||||
fun WallencNavRoot(
|
||||
viewModel: WallencViewModel = hiltViewModel(),
|
||||
taskPipelineOpenRequestCount: Int = 0,
|
||||
deepLinkPulse: Int = 0,
|
||||
deepLinkIntent: Intent = Intent(),
|
||||
) {
|
||||
val navState = rememberNavigationState()
|
||||
val mainNavState = rememberNavigationState()
|
||||
@@ -62,10 +73,10 @@ fun WallencNavRoot(
|
||||
|
||||
val topLevelRoutes = viewModel.routes
|
||||
|
||||
LaunchedEffect(taskPipelineOpenRequestCount) {
|
||||
if (taskPipelineOpenRequestCount <= 0) return@LaunchedEffect
|
||||
val route = topLevelRoutes[TaskPipelineRoute::class.qualifiedName!!] ?: return@LaunchedEffect
|
||||
navState.changeTop(route)
|
||||
LaunchedEffect(deepLinkPulse) {
|
||||
if (deepLinkPulse <= 0) return@LaunchedEffect
|
||||
if (!deepLinkIntent.matchesWallencDeepLink()) return@LaunchedEffect
|
||||
navState.navHostController.handleDeepLink(deepLinkIntent)
|
||||
}
|
||||
|
||||
val topLevelNavBarItems = remember {
|
||||
@@ -121,7 +132,11 @@ fun WallencNavRoot(
|
||||
navState.navHostController,
|
||||
startDestination = topLevelRoutes[MainRoute::class.qualifiedName]!!
|
||||
) {
|
||||
composable<MainRoute>(enterTransition = {
|
||||
composable<MainRoute>(
|
||||
deepLinks = listOf(
|
||||
navDeepLink { uriPattern = WallencDeepLinks.MAIN_URI_PATTERN },
|
||||
),
|
||||
enterTransition = {
|
||||
fadeIn(tween(200))
|
||||
}, exitTransition = {
|
||||
fadeOut(tween(200))
|
||||
@@ -132,14 +147,22 @@ fun WallencNavRoot(
|
||||
viewModel = mainViewModel
|
||||
)
|
||||
}
|
||||
composable<SettingsRoute>(enterTransition = {
|
||||
composable<SettingsRoute>(
|
||||
deepLinks = listOf(
|
||||
navDeepLink { uriPattern = WallencDeepLinks.SETTINGS_URI_PATTERN },
|
||||
),
|
||||
enterTransition = {
|
||||
fadeIn(tween(200))
|
||||
}, exitTransition = {
|
||||
fadeOut(tween(200))
|
||||
}) {
|
||||
SettingsScreen(Modifier.padding(innerPaddings), settingsViewModel)
|
||||
}
|
||||
composable<TaskPipelineRoute>(enterTransition = {
|
||||
composable<TaskPipelineRoute>(
|
||||
deepLinks = listOf(
|
||||
navDeepLink { uriPattern = WallencDeepLinks.TASKS_URI_PATTERN },
|
||||
),
|
||||
enterTransition = {
|
||||
fadeIn(tween(200))
|
||||
}, exitTransition = {
|
||||
fadeOut(tween(200))
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.github.nullptroma.wallenc.ui.navigation
|
||||
|
||||
import android.content.Intent
|
||||
|
||||
/**
|
||||
* URI для входа извне приложения (уведомления, виджеты, шорткаты, другие Activity).
|
||||
*
|
||||
* Должны совпадать с `<intent-filter><data android:scheme=… android:host=…/></intent-filter>` в манифесте
|
||||
* и с [androidx.navigation.navDeepLink] на соответствующих `composable` в [androidx.navigation.compose.NavHost].
|
||||
*
|
||||
* Для публичных HTTPS-ссылок позже добавляют отдельные хосты и `android:autoVerify`.
|
||||
*/
|
||||
object WallencDeepLinks {
|
||||
const val SCHEME = "wallenc"
|
||||
|
||||
object Host {
|
||||
const val MAIN = "main"
|
||||
const val TASKS = "tasks"
|
||||
const val SETTINGS = "settings"
|
||||
}
|
||||
|
||||
const val MAIN_URI_PATTERN = "$SCHEME://${Host.MAIN}"
|
||||
const val TASKS_URI_PATTERN = "$SCHEME://${Host.TASKS}"
|
||||
const val SETTINGS_URI_PATTERN = "$SCHEME://${Host.SETTINGS}"
|
||||
}
|
||||
|
||||
/** `ACTION_VIEW` с URI нашей схемы — обрабатывается [androidx.navigation.NavController.handleDeepLink]. */
|
||||
fun Intent.matchesWallencDeepLink(): Boolean =
|
||||
action == Intent.ACTION_VIEW && data?.scheme == WallencDeepLinks.SCHEME
|
||||
Reference in New Issue
Block a user