diff --git a/.idea/.name b/.idea/.name index 9ef16e6b..8a14fc66 100644 --- a/.idea/.name +++ b/.idea/.name @@ -1 +1 @@ -PEP \ No newline at end of file +Revolt \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3cd01369..2ffd8dde 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index af21b348..2b273b0d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -161,6 +161,34 @@ android:value="true" /> + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/README_APP_LINKS.md b/app/src/main/assets/README_APP_LINKS.md new file mode 100644 index 00000000..e73a9197 --- /dev/null +++ b/app/src/main/assets/README_APP_LINKS.md @@ -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). diff --git a/app/src/main/assets/assetlinks.json b/app/src/main/assets/assetlinks.json new file mode 100644 index 00000000..d80aa2a7 --- /dev/null +++ b/app/src/main/assets/assetlinks.json @@ -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" + ] + } +}] diff --git a/app/src/main/java/chat/revolt/activities/DeepLinkActivity.kt b/app/src/main/java/chat/revolt/activities/DeepLinkActivity.kt new file mode 100644 index 00000000..0ad65539 --- /dev/null +++ b/app/src/main/java/chat/revolt/activities/DeepLinkActivity.kt @@ -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() + + 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() + } +} diff --git a/app/src/main/java/chat/revolt/activities/MainActivity.kt b/app/src/main/java/chat/revolt/activities/MainActivity.kt index a41044ef..c8215eca 100644 --- a/app/src/main/java/chat/revolt/activities/MainActivity.kt +++ b/app/src/main/java/chat/revolt/activities/MainActivity.kt @@ -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?, menu: Menu?, @@ -444,6 +513,7 @@ class MainActivity : AppCompatActivity() { } companion object { + private const val TAG = "MainActivity" init { NativeLibraries.init() } diff --git a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt index ef660e5e..236eb076 100644 --- a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt @@ -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 -> diff --git a/app/src/main/java/chat/revolt/utils/DeepLinkUtils.kt b/app/src/main/java/chat/revolt/utils/DeepLinkUtils.kt new file mode 100644 index 00000000..7175517c --- /dev/null +++ b/app/src/main/java/chat/revolt/utils/DeepLinkUtils.kt @@ -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) + } + } + } +}