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:
2026-05-11 22:43:22 +03:00
parent 88a13080e5
commit 60627f11d6
9 changed files with 170 additions and 51 deletions

View File

@@ -28,6 +28,14 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </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> </activity>
<service <service

View File

@@ -1,18 +1,22 @@
package com.github.nullptroma.wallenc.app package com.github.nullptroma.wallenc.app
import android.Manifest import android.Manifest
import android.content.pm.PackageManager
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.github.nullptroma.wallenc.app.auth.YandexSignInService import com.github.nullptroma.wallenc.app.auth.YandexSignInService
import com.github.nullptroma.wallenc.ui.WallencUi import com.github.nullptroma.wallenc.ui.WallencUi
import com.github.nullptroma.wallenc.ui.navigation.matchesWallencDeepLink
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@@ -24,7 +28,8 @@ class MainActivity : ComponentActivity() {
@Inject @Inject
lateinit var yandexSignInService: YandexSignInService 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -34,11 +39,16 @@ class MainActivity : ComponentActivity() {
Timber.plant(Timber.DebugTree()) Timber.plant(Timber.DebugTree())
consumeOpenTaskPipelineIntent(intent) deepLinkIntentForNav = intent
if (intent.matchesWallencDeepLink()) {
deepLinkPulse.intValue++
}
setContent { setContent {
val pulse by deepLinkPulse
WallencUi( WallencUi(
taskPipelineOpenRequestCount = openTaskPipelineFromNotification.intValue, deepLinkPulse = pulse,
deepLinkIntent = deepLinkIntentForNav,
) )
} }
} }
@@ -46,13 +56,10 @@ class MainActivity : ComponentActivity() {
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)
consumeOpenTaskPipelineIntent(intent) deepLinkIntentForNav = intent
} if (intent.matchesWallencDeepLink()) {
deepLinkPulse.intValue++
private fun consumeOpenTaskPipelineIntent(intent: Intent?) { }
if (intent?.getBooleanExtra(EXTRA_OPEN_TASK_PIPELINE, false) != true) return
intent.removeExtra(EXTRA_OPEN_TASK_PIPELINE)
openTaskPipelineFromNotification.intValue += 1
} }
override fun onDestroy() { override fun onDestroy() {
@@ -75,9 +82,6 @@ class MainActivity : ComponentActivity() {
} }
companion object { 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 private const val NOTIFICATION_PERMISSION_REQUEST_CODE = 100
} }
} }

View File

@@ -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))
}

View File

@@ -11,8 +11,8 @@ import android.os.IBinder
import android.view.View import android.view.View
import android.widget.RemoteViews import android.widget.RemoteViews
import androidx.core.app.NotificationCompat 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.R
import com.github.nullptroma.wallenc.app.navigation.WallencExternalLaunch
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.tasks.TaskForegroundItem import com.github.nullptroma.wallenc.domain.tasks.TaskForegroundItem
import com.github.nullptroma.wallenc.domain.tasks.TaskForegroundUiState import com.github.nullptroma.wallenc.domain.tasks.TaskForegroundUiState
@@ -58,7 +58,9 @@ class TaskPipelineForegroundService : Service() {
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { 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() orchestrator.cancelAll()
} }
return START_STICKY return START_STICKY
@@ -68,7 +70,10 @@ class TaskPipelineForegroundService : Service() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
ensureChannel() ensureChannel()
startForeground(FOREGROUND_NOTIFICATION_ID, buildPlaceholderNotification()) startForeground(
WallencExternalLaunch.ForegroundTaskPipelineNotification.NOTIFICATION_ID,
buildPlaceholderNotification(),
)
val nm = getSystemService(NotificationManager::class.java) val nm = getSystemService(NotificationManager::class.java)
@@ -85,7 +90,7 @@ class TaskPipelineForegroundService : Service() {
TaskForegroundUiState.Hidden -> { TaskForegroundUiState.Hidden -> {
repeat = false repeat = false
if (sawVisible) { if (sawVisible) {
nm.cancel(FOREGROUND_NOTIFICATION_ID) nm.cancel(WallencExternalLaunch.ForegroundTaskPipelineNotification.NOTIFICATION_ID)
indeterminateDotsPhaseByTaskId.clear() indeterminateDotsPhaseByTaskId.clear()
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
@@ -97,7 +102,7 @@ class TaskPipelineForegroundService : Service() {
if (ui.tasks.isNotEmpty()) { if (ui.tasks.isNotEmpty()) {
sawVisible = true sawVisible = true
nm.notify( nm.notify(
FOREGROUND_NOTIFICATION_ID, WallencExternalLaunch.ForegroundTaskPipelineNotification.NOTIFICATION_ID,
buildAccumulatedNotification(ui.tasks), buildAccumulatedNotification(ui.tasks),
) )
} }
@@ -129,7 +134,7 @@ class TaskPipelineForegroundService : Service() {
private fun ensureChannel() { private fun ensureChannel() {
val nm = getSystemService(NotificationManager::class.java) val nm = getSystemService(NotificationManager::class.java)
val channel = NotificationChannel( val channel = NotificationChannel(
CHANNEL_ID, WallencExternalLaunch.ForegroundTaskPipelineNotification.CHANNEL_ID,
getString(R.string.task_notification_channel_name), getString(R.string.task_notification_channel_name),
NotificationManager.IMPORTANCE_LOW, NotificationManager.IMPORTANCE_LOW,
) )
@@ -137,7 +142,7 @@ class TaskPipelineForegroundService : Service() {
} }
private fun buildPlaceholderNotification(): Notification = private fun buildPlaceholderNotification(): Notification =
NotificationCompat.Builder(this, CHANNEL_ID) NotificationCompat.Builder(this, WallencExternalLaunch.ForegroundTaskPipelineNotification.CHANNEL_ID)
.setContentTitle(getString(R.string.task_notification_title)) .setContentTitle(getString(R.string.task_notification_title))
.setContentText(getString(R.string.task_notification_preparing)) .setContentText(getString(R.string.task_notification_preparing))
.setSmallIcon(android.R.drawable.stat_sys_download) .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)) .setContentTitle(getString(R.string.task_notification_title))
.setContentText(collapsedSubtext) .setContentText(collapsedSubtext)
.setSmallIcon(android.R.drawable.stat_sys_download) .setSmallIcon(android.R.drawable.stat_sys_download)
@@ -191,19 +199,18 @@ class TaskPipelineForegroundService : Service() {
private fun openTaskPipelinePendingIntent(): PendingIntent = private fun openTaskPipelinePendingIntent(): PendingIntent =
PendingIntent.getActivity( PendingIntent.getActivity(
this, this,
REQUEST_CODE_OPEN_TASK_PIPELINE, WallencExternalLaunch.PendingIntentRequestCodes.NOTIFICATION_TASK_PIPELINE_OPEN_TASKS,
Intent(this, MainActivity::class.java).apply { WallencExternalLaunch.mainActivityViewIntentForTasks(this),
putExtra(MainActivity.EXTRA_OPEN_TASK_PIPELINE, true)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) )
private fun cancelAllTasksPendingIntent(): PendingIntent = private fun cancelAllTasksPendingIntent(): PendingIntent =
PendingIntent.getService( PendingIntent.getService(
this, this,
0, WallencExternalLaunch.PendingIntentRequestCodes.NOTIFICATION_TASK_PIPELINE_CANCEL_SERVICE,
Intent(this, TaskPipelineForegroundService::class.java).setAction(ACTION_CANCEL_ALL_TASKS), Intent(this, TaskPipelineForegroundService::class.java).setAction(
WallencExternalLaunch.FOREGROUND_SERVICE_ACTION_CANCEL_ALL_TASKS,
),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) )
@@ -334,14 +341,6 @@ class TaskPipelineForegroundService : Service() {
} }
companion object { 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 private const val NOTIFICATION_QUEUE_STEP_MS = 500L
/** Must match [R.layout.notification_wallenc_tasks_big] row count. */ /** Must match [R.layout.notification_wallenc_tasks_big] row count. */

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Тёмная тема: фон splash и окна до отрисовки Compose -->
<color name="splash_screen_background">#FF1C1B1F</color>
</resources>

View File

@@ -1,2 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources /> <resources>
<color name="splash_screen_background">#FFFFFFFF</color>
</resources>

View File

@@ -1,5 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?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" /> <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> </resources>

View File

@@ -1,5 +1,6 @@
package com.github.nullptroma.wallenc.ui package com.github.nullptroma.wallenc.ui
import android.content.Intent
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut 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.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.navDeepLink
import com.github.nullptroma.wallenc.ui.navigation.NavBarItemData 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.navigation.rememberNavigationState
import com.github.nullptroma.wallenc.ui.screens.main.MainRoute import com.github.nullptroma.wallenc.ui.screens.main.MainRoute
import com.github.nullptroma.wallenc.ui.screens.main.MainScreen import com.github.nullptroma.wallenc.ui.screens.main.MainScreen
@@ -40,10 +44,16 @@ import com.github.nullptroma.wallenc.ui.theme.WallencTheme
@Composable @Composable
fun WallencUi(taskPipelineOpenRequestCount: Int = 0) { fun WallencUi(
deepLinkPulse: Int = 0,
deepLinkIntent: Intent = Intent(),
) {
WallencTheme { WallencTheme {
Surface { Surface {
WallencNavRoot(taskPipelineOpenRequestCount = taskPipelineOpenRequestCount) WallencNavRoot(
deepLinkPulse = deepLinkPulse,
deepLinkIntent = deepLinkIntent,
)
} }
} }
} }
@@ -52,7 +62,8 @@ fun WallencUi(taskPipelineOpenRequestCount: Int = 0) {
@Composable @Composable
fun WallencNavRoot( fun WallencNavRoot(
viewModel: WallencViewModel = hiltViewModel(), viewModel: WallencViewModel = hiltViewModel(),
taskPipelineOpenRequestCount: Int = 0, deepLinkPulse: Int = 0,
deepLinkIntent: Intent = Intent(),
) { ) {
val navState = rememberNavigationState() val navState = rememberNavigationState()
val mainNavState = rememberNavigationState() val mainNavState = rememberNavigationState()
@@ -62,10 +73,10 @@ fun WallencNavRoot(
val topLevelRoutes = viewModel.routes val topLevelRoutes = viewModel.routes
LaunchedEffect(taskPipelineOpenRequestCount) { LaunchedEffect(deepLinkPulse) {
if (taskPipelineOpenRequestCount <= 0) return@LaunchedEffect if (deepLinkPulse <= 0) return@LaunchedEffect
val route = topLevelRoutes[TaskPipelineRoute::class.qualifiedName!!] ?: return@LaunchedEffect if (!deepLinkIntent.matchesWallencDeepLink()) return@LaunchedEffect
navState.changeTop(route) navState.navHostController.handleDeepLink(deepLinkIntent)
} }
val topLevelNavBarItems = remember { val topLevelNavBarItems = remember {
@@ -121,7 +132,11 @@ fun WallencNavRoot(
navState.navHostController, navState.navHostController,
startDestination = topLevelRoutes[MainRoute::class.qualifiedName]!! startDestination = topLevelRoutes[MainRoute::class.qualifiedName]!!
) { ) {
composable<MainRoute>(enterTransition = { composable<MainRoute>(
deepLinks = listOf(
navDeepLink { uriPattern = WallencDeepLinks.MAIN_URI_PATTERN },
),
enterTransition = {
fadeIn(tween(200)) fadeIn(tween(200))
}, exitTransition = { }, exitTransition = {
fadeOut(tween(200)) fadeOut(tween(200))
@@ -132,14 +147,22 @@ fun WallencNavRoot(
viewModel = mainViewModel viewModel = mainViewModel
) )
} }
composable<SettingsRoute>(enterTransition = { composable<SettingsRoute>(
deepLinks = listOf(
navDeepLink { uriPattern = WallencDeepLinks.SETTINGS_URI_PATTERN },
),
enterTransition = {
fadeIn(tween(200)) fadeIn(tween(200))
}, exitTransition = { }, exitTransition = {
fadeOut(tween(200)) fadeOut(tween(200))
}) { }) {
SettingsScreen(Modifier.padding(innerPaddings), settingsViewModel) SettingsScreen(Modifier.padding(innerPaddings), settingsViewModel)
} }
composable<TaskPipelineRoute>(enterTransition = { composable<TaskPipelineRoute>(
deepLinks = listOf(
navDeepLink { uriPattern = WallencDeepLinks.TASKS_URI_PATTERN },
),
enterTransition = {
fadeIn(tween(200)) fadeIn(tween(200))
}, exitTransition = { }, exitTransition = {
fadeOut(tween(200)) fadeOut(tween(200))

View File

@@ -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