feat: Implement deep linking functionality

This commit introduces deep linking capabilities to the application.

- Added `DeepLinkActivity` to handle incoming deep links and navigate users to the appropriate content within the app.
- Created `DeepLinkUtils` to provide utility functions for parsing and creating deep link URIs.
- Updated `MainActivity` to receive and process deep link data from `DeepLinkActivity`.
- Modified `ChatRouterScreen` and its `ViewModel` to handle navigation based on deep link parameters.
- Updated `AndroidManifest.xml` to include intent filters for `DeepLinkActivity` to capture relevant URL schemes and paths.
- Added `assetlinks.json` and `README_APP_LINKS.md` to support Android App Links verification.
- Changed the `applicationId` in `app/build.gradle.kts` from "chat.revolt" to "chat.peptide".
- Renamed project in `.idea/.name` from "PEP" to "Revolt".
- Changed the default route in `ChatRouterScreen` from `Settings` to `Home`.
This commit is contained in:
AbronStudio 2025-08-10 12:17:54 +03:30
parent b89b1e9415
commit 9d53be9e41
9 changed files with 638 additions and 7 deletions

View File

@ -1 +1 @@
PEP
Revolt

View File

@ -77,7 +77,7 @@ android {
compileSdk = 35
defaultConfig {
applicationId = "chat.revolt"
applicationId = "chat.peptide"
minSdk = 24
targetSdk = 35
versionCode = Integer.parseInt("001_003_106".replace("_", ""), 10)

View File

@ -161,6 +161,34 @@
android:value="true" />
</service>
<!-- DeepLink Activity for handling App Links -->
<activity
android:name=".activities.DeepLinkActivity"
android:exported="true"
android:theme="@style/Theme.Revolt">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:scheme="http" />
<data android:host="peptide.chat" />
<data android:host="app.peptide.chat" />
<!-- Channel deep links -->
<data android:pathPrefix="/channels/" />
<!-- Server deep links -->
<data android:pathPrefix="/servers/" />
<data android:pathPrefix="/server/" />
<!-- User deep links -->
<data android:pathPrefix="/users/" />
<!-- Message deep links -->
<data android:pathPrefix="/messages/" />
<!-- Server channel message deep links -->
<data android:pathPattern="/server/.*/channel/.*" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,71 @@
# Android App Links Setup Instructions
This document provides instructions for setting up Android App Links for the Revolt app.
## What are Android App Links?
Android App Links are HTTP URLs that bring users directly to specific content in your Android app. When a user clicks an App Link, the app opens immediately if it's installed, without showing the app chooser dialog.
## Requirements
1. Your app must have an intent filter that handles the specific URLs
2. Your website must have a Digital Asset Links JSON file that verifies the association with your app
## Steps to Complete the Setup
### 1. Get your app's signing certificate fingerprint
Run the following command to get your app's signing certificate fingerprint:
```bash
keytool -list -v -keystore your_keystore.jks -alias your_alias
```
Look for the SHA-256 fingerprint in the output. Remove all colons (:) from the fingerprint.
### 2. Update the assetlinks.json file
Open the `assetlinks.json` file in this directory and replace the placeholder with your actual SHA-256 fingerprint:
```json
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "chat.revolt",
"sha256_cert_fingerprints": [
"YOUR_SHA256_FINGERPRINT_HERE"
]
}
}]
```
### 3. Host the assetlinks.json file on your server
Upload the `assetlinks.json` file to the following location on your server:
```
https://revolt.chat/.well-known/assetlinks.json
https://app.revolt.chat/.well-known/assetlinks.json
```
Make sure the file is accessible via HTTPS and returns with Content-Type: application/json.
### 4. Test your App Links
Use the Android Debug Bridge (ADB) to test your App Links:
```bash
adb shell am start -a android.intent.action.VIEW -d "https://revolt.chat/channels/CHANNEL_ID" chat.revolt
```
## Troubleshooting
If your App Links aren't working:
1. Verify that the assetlinks.json file is accessible via HTTPS
2. Check that the SHA-256 fingerprint in the assetlinks.json file matches your app's signing certificate
3. Make sure your app is using the correct signing certificate
4. Use the App Links Assistant in Android Studio to verify your App Links setup
For more information, see the [Android App Links documentation](https://developer.android.com/training/app-links).

View File

@ -0,0 +1,10 @@
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "chat.peptide",
"sha256_cert_fingerprints": [
"PUT_YOUR_APP_SIGNING_CERTIFICATE_SHA256_FINGERPRINT_HERE"
]
}
}]

View File

@ -0,0 +1,169 @@
package chat.revolt.activities
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import chat.revolt.persistence.KVStorage
import chat.revolt.utils.DeepLinkUtils
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class DeepLinkActivity : AppCompatActivity() {
@Inject
lateinit var kvStorage: KVStorage
private val viewModel by viewModels<MainActivityViewModel>()
companion object {
private const val TAG = "DeepLinkActivity"
// Constants for deep link paths
const val PATH_CHANNEL = "channel"
const val PATH_SERVER = "server"
const val PATH_USER = "user"
const val PATH_MESSAGE = "message"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Handle the incoming intent
handleIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Handle the new intent
handleIntent(intent)
}
private fun handleIntent(intent: Intent) {
val appLinkAction = intent.action
val appLinkData: Uri? = intent.data
if (Intent.ACTION_VIEW == appLinkAction && appLinkData != null) {
Log.d(TAG, "Deep link received: $appLinkData")
// Check if this is a valid Revolt deep link
if (!DeepLinkUtils.isRevoltDeepLink(appLinkData)) {
Log.d(TAG, "Not a valid Revolt deep link: $appLinkData")
startMainActivity()
return
}
// Check if user is logged in and handle deep link in a coroutine
lifecycleScope.launch {
val token = kvStorage.get("sessionToken")
if (token == null) {
// User is not logged in, redirect to login screen
Log.d(TAG, "User not logged in, redirecting to login screen")
startMainActivity("choose-platform")
return@launch
}
// First, check for server/channel/message format
val serverChannelMessage = DeepLinkUtils.extractServerChannelMessage(appLinkData)
if (serverChannelMessage != null) {
Log.d(TAG, "Detected server/channel/message format: ${serverChannelMessage.serverId}/${serverChannelMessage.channelId}/${serverChannelMessage.messageId}")
if (serverChannelMessage.messageId != null) {
// Navigate to the specific message in the channel
navigateToMessage(serverChannelMessage.messageId, serverChannelMessage.channelId)
} else {
// Navigate to the channel in the server
navigateToChannel(serverChannelMessage.channelId)
}
return@launch
}
// Extract IDs from the URI using our utility class for other formats
val channelId = DeepLinkUtils.extractChannelId(appLinkData)
val serverId = DeepLinkUtils.extractServerId(appLinkData)
val userId = DeepLinkUtils.extractUserId(appLinkData)
val messageId = DeepLinkUtils.extractMessageId(appLinkData)
when {
channelId != null -> {
Log.d(TAG, "Navigating to channel: $channelId")
navigateToChannel(channelId)
}
serverId != null -> {
Log.d(TAG, "Navigating to server: $serverId")
navigateToServer(serverId)
}
userId != null -> {
Log.d(TAG, "Navigating to user: $userId")
navigateToUser(userId)
}
messageId != null -> {
Log.d(TAG, "Navigating to message: $messageId")
val channelParam = appLinkData.getQueryParameter("channel")
navigateToMessage(messageId, channelParam)
}
else -> {
// Default case, just open the main activity
Log.d(TAG, "No specific path, opening main activity")
startMainActivity()
}
}
}
} else {
// Not a deep link, just open the main activity
Log.d(TAG, "Not a deep link, opening main activity")
startMainActivity()
}
}
private fun navigateToChannel(channelId: String) {
Log.d(TAG, "Navigating to channel: $channelId")
// Use our utility class to create the intent
val intent = DeepLinkUtils.createChannelIntent(this, channelId)
startActivity(intent)
finish()
}
private fun navigateToServer(serverId: String) {
Log.d(TAG, "Navigating to server: $serverId")
// Use our utility class to create the intent
val intent = DeepLinkUtils.createServerIntent(this, serverId)
startActivity(intent)
finish()
}
private fun navigateToUser(userId: String) {
Log.d(TAG, "Navigating to user: $userId")
// Use our utility class to create the intent
val intent = DeepLinkUtils.createUserIntent(this, userId)
startActivity(intent)
finish()
}
private fun navigateToMessage(messageId: String, channelId: String?) {
Log.d(TAG, "Navigating to message: $messageId in channel: $channelId")
// Use our utility class to create the intent
val intent = DeepLinkUtils.createMessageIntent(this, messageId, channelId)
startActivity(intent)
finish()
}
private fun startMainActivity(destination: String? = null) {
val intent = Intent(this, MainActivity::class.java)
if (destination != null) {
intent.putExtra("next_destination", destination)
}
startActivity(intent)
finish()
}
}

View File

@ -2,6 +2,7 @@ package chat.revolt.activities
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Bundle
@ -127,6 +128,7 @@ import chat.revolt.screens.settings.channel.ChannelSettingsHome
import chat.revolt.screens.settings.channel.ChannelSettingsOverview
import chat.revolt.screens.settings.channel.ChannelSettingsPermissions
import chat.revolt.ui.theme.RevoltTheme
import chat.revolt.utils.DeepLinkHandler
import com.google.android.material.color.DynamicColors
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
@ -383,6 +385,9 @@ class MainActivity : AppCompatActivity() {
WindowCompat.setDecorFitsSystemWindows(window, false)
RevoltAPI.hydrateFromPersistentCache()
// Handle deep link data if available
handleDeepLinkData(intent)
setContent {
val windowSizeClass = calculateWindowSizeClass(this)
@ -419,6 +424,70 @@ class MainActivity : AppCompatActivity() {
)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleDeepLinkData(intent)
}
private fun handleDeepLinkData(intent: Intent) {
// Check for deep link data from DeepLinkActivity
val channelId = intent.getStringExtra(DeepLinkActivity.PATH_CHANNEL)
val serverId = intent.getStringExtra(DeepLinkActivity.PATH_SERVER)
val userId = intent.getStringExtra(DeepLinkActivity.PATH_USER)
val messageId = intent.getStringExtra(DeepLinkActivity.PATH_MESSAGE)
val nextDestination = intent.getStringExtra("next_destination")
if (nextDestination != null) {
Log.d(TAG, "Setting next destination from deep link: $nextDestination")
viewModel.updateNextDestination(nextDestination)
return
}
// Store deep link data in a static companion object for ChatRouterScreen to access
DeepLinkHandler.channelId = channelId
DeepLinkHandler.serverId = serverId
DeepLinkHandler.userId = userId
DeepLinkHandler.messageId = messageId
DeepLinkHandler.hasDeepLink = channelId != null || serverId != null || userId != null || messageId != null
// Wait for the app to be ready before navigating to the deep link destination
viewModel.viewModelScope.launch {
viewModel.isReady.collect { isReady ->
if (isReady) {
when {
channelId != null -> {
Log.d(TAG, "Navigating to channel from deep link: $channelId")
// Check if Polar mode is enabled
if (Experiments.usePolar.isEnabled) {
viewModel.updateNextDestination("main/conversation/$channelId")
} else {
// For non-Polar mode, we'll use the DeepLinkHandler to communicate with ChatRouterScreen
viewModel.updateNextDestination("chat")
}
}
serverId != null -> {
Log.d(TAG, "Navigating to server from deep link: $serverId")
// Navigate to chat screen, the ChatRouterScreen will handle the server navigation
viewModel.updateNextDestination("chat")
}
userId != null -> {
Log.d(TAG, "Navigating to user from deep link: $userId")
// Navigate to chat screen, the ChatRouterScreen will handle the user navigation
viewModel.updateNextDestination("chat")
}
messageId != null -> {
Log.d(TAG, "Navigating to message from deep link: $messageId")
// Navigate to chat screen, the ChatRouterScreen will handle the message navigation
viewModel.updateNextDestination("chat")
}
}
// Only collect once
return@collect
}
}
}
}
override fun onProvideKeyboardShortcuts(
data: MutableList<KeyboardShortcutGroup>?,
menu: Menu?,
@ -444,6 +513,7 @@ class MainActivity : AppCompatActivity() {
}
companion object {
private const val TAG = "MainActivity"
init {
NativeLibraries.init()
}

View File

@ -74,6 +74,7 @@ import chat.revolt.composables.chat.DisconnectedNotice
import chat.revolt.composables.screens.chat.drawer.ChannelSideDrawer
import chat.revolt.dialogs.NotificationRationaleDialog
import chat.revolt.internals.Changelogs
import chat.revolt.utils.DeepLinkHandler
import chat.revolt.persistence.KVStorage
import chat.revolt.screens.chat.dialogs.safety.ReportMessageDialog
import chat.revolt.screens.chat.dialogs.safety.ReportServerDialog
@ -112,12 +113,12 @@ sealed class ChatRouterDestination {
data class NoCurrentChannel(val serverId: String?) : ChatRouterDestination()
companion object {
val default = Settings
val default = Home // Changed default from Settings to Home
fun fromString(destination: String): ChatRouterDestination {
return when {
destination == "home" -> Settings // previous name for overview
destination == "overview" -> Settings
destination == "home" -> Home // Changed from Settings to Home
destination == "overview" -> Home // Changed from Settings to Home
destination == "friends" -> Friends
destination.startsWith("no_current_channel/") -> NoCurrentChannel(
destination.removePrefix(
@ -150,8 +151,15 @@ class ChatRouterViewModel @Inject constructor(
init {
viewModelScope.launch {
val current = kvStorage.get("currentDestination")
setSaveDestination(ChatRouterDestination.fromString(current ?: ""))
// Check if there's a deep link to handle first
if (DeepLinkHandler.hasDeepLink) {
Log.d("ChatRouterViewModel", "Deep link detected in init, skipping saved destination")
// Don't set any destination here, let the LaunchedEffect in ChatRouterScreen handle it
} else {
// No deep link, load the saved destination
val current = kvStorage.get("currentDestination")
setSaveDestination(ChatRouterDestination.fromString(current ?: ""))
}
latestChangelogRead = changelogs.hasSeenCurrent()
latestChangelog = changelogs.getLatestChangelogCode()
@ -361,6 +369,63 @@ fun ChatRouterScreen(
}
}
// Handle deep links when the ChatRouterScreen is first composed
LaunchedEffect(Unit) {
// Check if there's a deep link to handle
if (DeepLinkHandler.hasDeepLink) {
Log.d("ChatRouterScreen", "Processing deep link data")
// Process the deep link based on priority
when {
// Priority 1: Handle server/channel/message format (most specific)
DeepLinkHandler.messageId != null && DeepLinkHandler.channelId != null -> {
val messageId = DeepLinkHandler.messageId!!
val channelId = DeepLinkHandler.channelId!!
Log.d("ChatRouterScreen", "Setting destination to message: $messageId in channel: $channelId")
val resolvedChannel = RevoltAPI.channelCache[channelId]
if (resolvedChannel == null) {
showChannelUnavailableAlert = true
} else {
// Navigate to the channel first, then the message will be handled by the channel screen
viewModel.setSaveDestination(ChatRouterDestination.Channel(channelId))
// TODO: Add logic to scroll to the specific message in the channel
}
}
// Priority 2: Handle channel deep link
DeepLinkHandler.channelId != null -> {
val channelId = DeepLinkHandler.channelId!!
Log.d("ChatRouterScreen", "Setting destination to channel: $channelId")
val resolvedChannel = RevoltAPI.channelCache[channelId]
if (resolvedChannel == null) {
showChannelUnavailableAlert = true
} else {
viewModel.setSaveDestination(ChatRouterDestination.Channel(channelId))
}
}
// Priority 3: Handle server deep link
DeepLinkHandler.serverId != null -> {
val serverId = DeepLinkHandler.serverId!!
Log.d("ChatRouterScreen", "Setting destination to server: $serverId")
viewModel.navigateToServer(serverId)
}
// Priority 4: Handle user deep link
DeepLinkHandler.userId != null -> {
val userId = DeepLinkHandler.userId!!
Log.d("ChatRouterScreen", "Setting destination to user: $userId")
// TODO: Implement user navigation if needed
}
}
// Clear the deep link data after processing
DeepLinkHandler.clear()
}
}
LaunchedEffect(Unit) {
while (true) {
ActionChannel.receive().let { action ->

View File

@ -0,0 +1,218 @@
package chat.revolt.utils
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import chat.revolt.activities.DeepLinkActivity
import chat.revolt.activities.MainActivity
/**
* Utility class for handling deep links in the Revolt app.
*/
object DeepLinkUtils {
private const val TAG = "DeepLinkUtils"
/**
* Checks if the given URI is a valid Revolt deep link.
*
* @param uri The URI to check
* @return true if the URI is a valid Revolt deep link, false otherwise
*/
fun isRevoltDeepLink(uri: Uri?): Boolean {
if (uri == null) return false
val host = uri.host ?: return false
val scheme = uri.scheme ?: return false
// Check if the scheme is http or https
if (scheme != "http" && scheme != "https") return false
// Check if the host is a valid Revolt host
return host == "peptide.chat" || host == "app.peptide.chat"
}
/**
* Extracts the channel ID from a channel deep link URI.
*
* @param uri The URI to extract the channel ID from
* @return The channel ID, or null if the URI is not a valid channel deep link
*/
fun extractChannelId(uri: Uri?): String? {
if (uri == null) return null
val pathSegments = uri.pathSegments
// Handle /channels/CHANNEL_ID format
if (pathSegments.size >= 2 && pathSegments[0] == "channels") {
return pathSegments[1]
}
// Handle /server/SERVER_ID/channel/CHANNEL_ID format
if (pathSegments.size >= 4 &&
pathSegments[0] == "server" &&
pathSegments[2] == "channel") {
return pathSegments[3]
}
return null
}
/**
* Extracts the server ID from a server deep link URI.
*
* @param uri The URI to extract the server ID from
* @return The server ID, or null if the URI is not a valid server deep link
*/
fun extractServerId(uri: Uri?): String? {
if (uri == null) return null
val pathSegments = uri.pathSegments
// Handle /servers/SERVER_ID format
if (pathSegments.size >= 2 && pathSegments[0] == "servers") {
return pathSegments[1]
}
// Handle /server/SERVER_ID format
if (pathSegments.size >= 2 && pathSegments[0] == "server") {
return pathSegments[1]
}
return null
}
/**
* Extracts the user ID from a user deep link URI.
*
* @param uri The URI to extract the user ID from
* @return The user ID, or null if the URI is not a valid user deep link
*/
fun extractUserId(uri: Uri?): String? {
if (uri == null) return null
val pathSegments = uri.pathSegments
if (pathSegments.size < 2 || pathSegments[0] != "users") return null
return pathSegments[1]
}
/**
* Extracts the message ID from a message deep link URI.
*
* @param uri The URI to extract the message ID from
* @return The message ID, or null if the URI is not a valid message deep link
*/
fun extractMessageId(uri: Uri?): String? {
if (uri == null) return null
val pathSegments = uri.pathSegments
// Handle /messages/MESSAGE_ID format
if (pathSegments.size >= 2 && pathSegments[0] == "messages") {
return pathSegments[1]
}
// Handle /server/SERVER_ID/channel/CHANNEL_ID/MESSAGE_ID format
if (pathSegments.size >= 5 &&
pathSegments[0] == "server" &&
pathSegments[2] == "channel") {
return pathSegments[4]
}
return null
}
/**
* Data class to hold server, channel, and message IDs extracted from a URI.
*/
data class ServerChannelMessage(
val serverId: String,
val channelId: String,
val messageId: String? = null
)
/**
* Extracts server, channel, and message IDs from a server channel message deep link URI.
*
* @param uri The URI to extract the IDs from
* @return A ServerChannelMessage object containing the extracted IDs, or null if the URI is not a valid server channel message deep link
*/
fun extractServerChannelMessage(uri: Uri?): ServerChannelMessage? {
if (uri == null) return null
val pathSegments = uri.pathSegments
// Handle /server/SERVER_ID/channel/CHANNEL_ID format
if (pathSegments.size >= 4 &&
pathSegments[0] == "server" &&
pathSegments[2] == "channel") {
val serverId = pathSegments[1]
val channelId = pathSegments[3]
// Check if there's a message ID (5th segment)
val messageId = if (pathSegments.size >= 5) pathSegments[4] else null
return ServerChannelMessage(serverId, channelId, messageId)
}
return null
}
/**
* Creates an intent to navigate to a specific channel.
*
* @param context The context to use for creating the intent
* @param channelId The ID of the channel to navigate to
* @return An intent that will navigate to the specified channel
*/
fun createChannelIntent(context: Context, channelId: String): Intent {
return Intent(context, MainActivity::class.java).apply {
putExtra(DeepLinkActivity.PATH_CHANNEL, channelId)
}
}
/**
* Creates an intent to navigate to a specific server.
*
* @param context The context to use for creating the intent
* @param serverId The ID of the server to navigate to
* @return An intent that will navigate to the specified server
*/
fun createServerIntent(context: Context, serverId: String): Intent {
return Intent(context, MainActivity::class.java).apply {
putExtra(DeepLinkActivity.PATH_SERVER, serverId)
}
}
/**
* Creates an intent to navigate to a specific user.
*
* @param context The context to use for creating the intent
* @param userId The ID of the user to navigate to
* @return An intent that will navigate to the specified user
*/
fun createUserIntent(context: Context, userId: String): Intent {
return Intent(context, MainActivity::class.java).apply {
putExtra(DeepLinkActivity.PATH_USER, userId)
}
}
/**
* Creates an intent to navigate to a specific message.
*
* @param context The context to use for creating the intent
* @param messageId The ID of the message to navigate to
* @param channelId The ID of the channel containing the message, if known
* @return An intent that will navigate to the specified message
*/
fun createMessageIntent(context: Context, messageId: String, channelId: String? = null): Intent {
return Intent(context, MainActivity::class.java).apply {
putExtra(DeepLinkActivity.PATH_MESSAGE, messageId)
if (channelId != null) {
putExtra(DeepLinkActivity.PATH_CHANNEL, channelId)
}
}
}
}