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" />
<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

View File

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

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.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. */

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"?>
<resources />
<resources>
<color name="splash_screen_background">#FFFFFFFF</color>
</resources>

View File

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

View File

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

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