Merge branch 'dev' into feature-jump-to-reply-message

Signed-off-by: Alex Yong <alexjyong@gmail.com>
This commit is contained in:
Alex Yong 2025-08-23 20:33:16 -04:00 committed by GitHub
commit 7f07a5c5b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 302 additions and 143 deletions

4
.gitignore vendored
View File

@ -19,3 +19,7 @@ revoltbuild.properties
sentry.properties
/.kotlin/sessions
app/src/main/assets/embedded
/app/src/main/jniLibs/arm64-v8a/
/app/src/main/jniLibs/armeabi-v7a/
/app/src/main/jniLibs/x86/
/app/src/main/jniLibs/x86_64/

View File

@ -1,21 +1,20 @@
# Revolt on Android
<div align="center">
<h1>Revolt for Android</h1>
<p>Official <a href="https://revolt.chat">Revolt</a> Android app.</p>
<br/><br/>
<div>
<img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" width="200">
<br/>
</div>
<small>Google Play is a trademark of Google LLC.</small>
<br/><br/><br/>
</div>
## Description
This is the official Android app for the [Revolt](https://revolt.chat) chat platform.
The codebase includes the app itself, as well as an internal library for interacting with the Revolt
API.
| Module | Package | Description |
|--------|---------------|----------------------|
| `:app` | `chat.revolt` | The main app module. |
The API library is part of the `app` module, and is not intended to be used as a standalone library,
as it makes liberal use of Android-specific APIs for reactivity.
The app is written in Kotlin, and uses
the [Jetpack Compose](https://developer.android.com/jetpack/compose) UI toolkit, the current state
of the art for Android UI development.
API. The app is written in Kotlin, and wholly
uses [Jetpack Compose](https://developer.android.com/jetpack/compose).
## Stack
@ -29,9 +28,10 @@ of the art for Android UI development.
## Resources
### Revolt on Android
### Revolt for Android
- [Revolt on Android Technical Documentation](https://revoltchat.github.io/android/)
- [Roadmap](https://op.revolt.wtf/projects/revolt-for-android/work_packages)
- [Revolt for Android Technical Documentation](https://revoltchat.github.io/android/)
- [Android-specific Contribution Guide](https://revoltchat.github.io/android/contributing/guidelines/)
&mdash;**read carefully before contributing!**
@ -39,8 +39,9 @@ of the art for Android UI development.
- [Revolt Project Board](https://github.com/revoltchat/revolt/discussions) (Submit feature requests
here)
- [Revolt Testers Server](https://app.revolt.chat/invite/Testers)
- [General Revolt Contribution Guide](https://developers.revolt.chat/contributing)
- [Revolt Development Server](https://app.revolt.chat/invite/API)
- [Revolt Server](https://app.revolt.chat/invite/Testers)
- [General Revolt Contribution Guide](https://developers.revolt.chat/contrib.html)
## Quick Start
@ -48,4 +49,4 @@ Open the project in Android Studio. You can then run the app on an emulator or a
running the `app` module.
In-depth setup instructions can be found
at [Setting up your Development Environment](https://revoltchat.github.io/android/contributing/setup/)
at [Setting up your Development Environment](https://revoltchat.github.io/android/contributing/setup/)

View File

@ -73,12 +73,12 @@ fun buildproperty(propertyName: String, fallbackEnv: String? = null): String? {
}
android {
compileSdk = 35
compileSdk = 36
defaultConfig {
applicationId = "chat.revolt"
minSdk = 24
targetSdk = 35
targetSdk = 36
versionCode = Integer.parseInt("001_003_206".replace("_", ""), 10)
versionName = "1.3.6b"

View File

@ -1,6 +1,7 @@
package chat.revolt
import android.app.Application
import android.os.StrictMode
import com.google.android.material.color.DynamicColors
import dagger.hilt.android.HiltAndroidApp
import logcat.AndroidLogcatLogger
@ -15,6 +16,21 @@ class RevoltApplication : Application() {
override fun onCreate() {
super.onCreate()
AndroidLogcatLogger.installOnDebuggableApp(this, minPriority = LogPriority.VERBOSE)
if (BuildConfig.DEBUG) {
// Enable strict mode primarily to catch non-API usage, although we detect all
// violations for our reference.
// https://developer.android.com/reference/android/os/StrictMode
StrictMode.setVmPolicy(
StrictMode.VmPolicy
.Builder()
.apply {
detectAll()
penaltyLog()
}
.build()
)
}
}
init {

View File

@ -29,6 +29,7 @@ object Experiments {
val useKotlinBasedMarkdownRenderer = ExperimentInstance(false)
val usePolar = ExperimentInstance(false)
val enableServerIdentityOptions = ExperimentInstance(false)
val useFinalMarkdownRenderer = ExperimentInstance(false)
suspend fun hydrateWithKv() {
val kvStorage = KVStorage(RevoltApplication.instance)
@ -48,5 +49,14 @@ object Experiments {
enableServerIdentityOptions.setEnabled(
kvStorage.getBoolean("exp/enableServerIdentityOptions") == true
)
useFinalMarkdownRenderer.setEnabled(
kvStorage.getBoolean("exp/useFinalMarkdownRenderer") == true
)
if (useFinalMarkdownRenderer.isEnabled && useKotlinBasedMarkdownRenderer.isEnabled) {
// if jbm and fm are enabled, fm takes precedence. this should not be possible in practice
useKotlinBasedMarkdownRenderer.setEnabled(false)
kvStorage.set("exp/useKotlinBasedMarkdownRenderer", false)
}
}
}

View File

@ -56,6 +56,19 @@ sealed class VoiceChannels2_0Variates {
object Disabled : VoiceChannels2_0Variates()
}
@FeatureFlag("FinalMarkdown")
sealed class FinalMarkdownVariates {
@Treatment(
"Enable the new FinalMarkdown library for all users"
)
object Enabled : FinalMarkdownVariates()
@Treatment(
"Disable the new FinalMarkdown library for all users"
)
object Disabled : FinalMarkdownVariates()
}
object FeatureFlags {
@FeatureFlag("LabsAccessControl")
var labsAccessControl by mutableStateOf<LabsAccessControlVariates>(
@ -101,4 +114,14 @@ object FeatureFlags {
is VoiceChannels2_0Variates.Enabled -> true
is VoiceChannels2_0Variates.Disabled -> false
}
@FeatureFlag("FinalMarkdown")
var finalMarkdown by mutableStateOf<FinalMarkdownVariates>(
FinalMarkdownVariates.Disabled
)
val finalMarkdownGranted: Boolean
get() = when (finalMarkdown) {
is FinalMarkdownVariates.Enabled -> true
is FinalMarkdownVariates.Disabled -> false
}
}

View File

@ -0,0 +1,14 @@
package chat.revolt.ndk
import kotlinx.serialization.Serializable
@Serializable
data class FinalMarkdownNodeTest(
val test: Int,
)
@Suppress("KotlinJniMissingFunction")
object FinalMarkdown {
external fun init()
external fun process(input: String): FinalMarkdownNodeTest
}

View File

@ -3,12 +3,15 @@ package chat.revolt.ndk
annotation class NativeLibrary(val name: String) {
companion object {
const val LIB_NAME_NATIVE_MARKDOWN = "stendal"
const val LIB_NAME_NATIVE_MARKDOWN_V2 = "finalmarkdown"
}
}
object NativeLibraries {
fun init() {
System.loadLibrary(NativeLibrary.LIB_NAME_NATIVE_MARKDOWN)
System.loadLibrary(NativeLibrary.LIB_NAME_NATIVE_MARKDOWN_V2)
Stendal.init()
FinalMarkdown.init()
}
}

View File

@ -2,21 +2,36 @@ package chat.revolt.screens.settings
import android.content.Context
import android.content.Intent
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonGroupDefaults
import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ListItem
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.ToggleButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
@ -24,18 +39,35 @@ import androidx.navigation.NavController
import chat.revolt.BuildConfig
import chat.revolt.RevoltApplication
import chat.revolt.api.settings.Experiments
import chat.revolt.api.settings.FeatureFlags
import chat.revolt.api.settings.LoadedSettings
import chat.revolt.persistence.KVStorage
import chat.revolt.settings.dsl.SettingsPage
import chat.revolt.settings.dsl.SubcategoryContentInsets
import kotlinx.coroutines.launch
enum class MarkdownRenderer {
Stendal, JetBrains, FinalMarkdown
}
class ExperimentsSettingsScreenViewModel : ViewModel() {
private val kv = KVStorage(RevoltApplication.instance)
fun init() {
viewModelScope.launch {
useKotlinMdRendererChecked.value = Experiments.useKotlinBasedMarkdownRenderer.isEnabled
when {
Experiments.useKotlinBasedMarkdownRenderer.isEnabled -> {
mdRenderer.value = MarkdownRenderer.JetBrains
}
Experiments.useFinalMarkdownRenderer.isEnabled -> {
mdRenderer.value = MarkdownRenderer.FinalMarkdown
}
else -> {
mdRenderer.value = MarkdownRenderer.Stendal
}
}
usePolarChecked.value = Experiments.usePolar.isEnabled
enableServerIdentityOptionsChecked.value =
Experiments.enableServerIdentityOptions.isEnabled
@ -66,13 +98,33 @@ class ExperimentsSettingsScreenViewModel : ViewModel() {
}
}
val useKotlinMdRendererChecked = mutableStateOf(false)
val mdRenderer = mutableStateOf(MarkdownRenderer.Stendal)
fun setUseKotlinMdRendererChecked(value: Boolean) {
fun setMdRenderer(value: MarkdownRenderer) {
viewModelScope.launch {
kv.set("exp/useKotlinBasedMarkdownRenderer", value)
Experiments.useKotlinBasedMarkdownRenderer.setEnabled(value)
useKotlinMdRendererChecked.value = value
when (value) {
MarkdownRenderer.Stendal -> {
kv.set("exp/useKotlinBasedMarkdownRenderer", false)
Experiments.useKotlinBasedMarkdownRenderer.setEnabled(false)
kv.set("exp/useFinalMarkdownRenderer", false)
Experiments.useFinalMarkdownRenderer.setEnabled(false)
}
MarkdownRenderer.JetBrains -> {
kv.set("exp/useKotlinBasedMarkdownRenderer", true)
Experiments.useKotlinBasedMarkdownRenderer.setEnabled(true)
kv.set("exp/useFinalMarkdownRenderer", false)
Experiments.useFinalMarkdownRenderer.setEnabled(false)
}
MarkdownRenderer.FinalMarkdown -> {
kv.set("exp/useKotlinBasedMarkdownRenderer", false)
Experiments.useKotlinBasedMarkdownRenderer.setEnabled(false)
kv.set("exp/useFinalMarkdownRenderer", true)
Experiments.useFinalMarkdownRenderer.setEnabled(true)
}
}
mdRenderer.value = value
}
}
@ -98,6 +150,7 @@ class ExperimentsSettingsScreenViewModel : ViewModel() {
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ExperimentsSettingsScreen(
navController: NavController,
@ -147,21 +200,63 @@ fun ExperimentsSettingsScreen(
Text("Experiments", maxLines = 1, overflow = TextOverflow.Ellipsis)
}
) {
ListItem(
headlineContent = {
Text("New Message Markdown Renderer")
},
supportingContent = {
Text("Use a Kotlin-based Markdown renderer for messages rather than the C++ one. Missing features may be present.")
},
trailingContent = {
Switch(
checked = viewModel.useKotlinMdRendererChecked.value,
onCheckedChange = null
)
},
modifier = Modifier.clickable { viewModel.setUseKotlinMdRendererChecked(!viewModel.useKotlinMdRendererChecked.value) }
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
ListItem(
headlineContent = {
Text("Markdown Renderer")
},
supportingContent = {
when (viewModel.mdRenderer.value) {
MarkdownRenderer.Stendal -> Text("Use the original C++ Markdown renderer for messages.")
MarkdownRenderer.JetBrains -> Text("Use the Kotlin-based JetBrains Markdown renderer for messages. This renderer is more feature-complete and has better results.")
MarkdownRenderer.FinalMarkdown -> Text("Use a new. blazingly fast markdown renderer for messages. This renderer is experimental and may have missing features.")
}
},
modifier = Modifier
.animateContentSize()
.weight(1f)
)
Column(
modifier = Modifier
.width(IntrinsicSize.Max)
.padding(end = 8.dp),
verticalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween),
) {
ToggleButton(
checked = viewModel.mdRenderer.value == MarkdownRenderer.Stendal,
onCheckedChange = { viewModel.setMdRenderer(MarkdownRenderer.Stendal) },
modifier = Modifier
.fillMaxWidth()
.semantics { role = Role.RadioButton }
) {
Text("Default")
}
ToggleButton(
checked = viewModel.mdRenderer.value == MarkdownRenderer.JetBrains,
onCheckedChange = { viewModel.setMdRenderer(MarkdownRenderer.JetBrains) },
modifier = Modifier
.fillMaxWidth()
.semantics { role = Role.RadioButton }
) {
Text("Kotlin")
}
if (FeatureFlags.finalMarkdownGranted || viewModel.mdRenderer.value == MarkdownRenderer.FinalMarkdown) {
ToggleButton(
checked = viewModel.mdRenderer.value == MarkdownRenderer.FinalMarkdown,
onCheckedChange = { viewModel.setMdRenderer(MarkdownRenderer.FinalMarkdown) },
enabled = FeatureFlags.finalMarkdownGranted,
modifier = Modifier
.fillMaxWidth()
.semantics { role = Role.RadioButton }
) {
Text("Final")
}
}
}
}
ListItem(
headlineContent = {

View File

View File

@ -85,7 +85,7 @@
<string name="mfa_password_lead">Enter your password to continue.</string>
<string name="about">About</string>
<string name="about_full_name">Revolt on Android</string>
<string name="about_full_name">Revolt for Android</string>
<string name="about_brought_to_you_by">Brought to you with ❤️ by the Revolt team.</string>
<string name="about_tab_version">Version</string>
<string name="about_tab_details">Details</string>

View File

@ -26,20 +26,7 @@ export default defineConfig({
link: "/contributing/setup",
}
],
},
{
label: "Beta Test",
items: [
{
label: "Geographic Availability",
link: "/beta/availability-regions",
},
{
label: "Join the Beta Test",
link: "/beta/joining-the-public-beta-test",
}
],
},
}
/* {
label: "Reference",
autogenerate: { directory: "reference" },

View File

@ -1,65 +0,0 @@
---
title: Geographic Availability of the Beta Test
description: Find out where you can join the beta test for Revolt on Android.
template: doc
---
The beta test for Revolt on Android is available for testers in the following jurisdictions:
- Armenia
- Australia
- Austria
- Azerbaijan
- Belgium
- Brazil
- Bulgaria
- Canada
- Croatia
- Cyprus
- Czechia
- Denmark (Kingdom of)
- Dominican Republic
- Estonia
- Finland (including Åland)
- France (including overseas départements and other outre-mer territories)
- Georgia
- Germany
- Gibraltar
- Greece
- Hungary
- Iceland
- Ireland
- Israel
- Italy
- Japan
- Latvia
- Liechtenstein
- Lithuania
- Luxembourg
- Malaysia
- Malta
- Moldova
- Monaco
- Netherlands
- New Zealand
- Norway (Kingdom of)
- Poland
- Portugal
- Romania
- San Marino
- Slovakia
- Slovenia
- South Africa
- South Korea
- Spain
- Sweden
- Switzerland
- Türkiye
- Ukraine
- United Kingdom
- United States (including Territories of the U.S.)
- Vatican City
## My region isn't listed!
While we don't offer the beta test in *all* regions of the world, we may and most likely will add regions that are not listed upon request. Please request the addition on [Jenvolt](https://rvlt.gg/jen) (if you beta test, you are probably already in the server!).

View File

@ -1,20 +0,0 @@
---
title: Joining the Public Beta Test
description: Learn how to join the public beta test for Revolt for Android.
template: doc
---
The public beta test for Revolt for Android is available to anyone with an Android device
in [those jurisdictions](https://revoltchat.github.io/android/beta/availability-regions/) where the
beta test is available. The beta test is open to everyone, and you can join the beta test by
following these steps:
1. [Join the Revolt for Android Alpha Track on Google Groups](https://groups.google.com/g/revolt-android-alpha-track).
2. [Opt-in to the Revolt for Android Beta Test on Google Play](https://play.google.com/apps/testing/chat.revolt).
3. [Download Revolt for Android from Google Play](https://play.google.com/store/apps/details?id=chat.revolt).
You're all set! You can now use Revolt on Android and help us test new features and improvements.
If you encounter any issues or have any feedback, please let us know
by [reporting an issue](https://androidtasks.starlightjen.com/)
or [joining the Revolt Developers server](https://rvlt.gg/api).

View File

@ -107,6 +107,18 @@ import { Tabs, TabItem, Steps } from "@astrojs/starlight/components"
deno run -A scripts/download_deps.ts
```
:::tip
You can run this script with the `-y` flag to skip confirmation prompts.
:::
:::note
This script will query the Revolt version control server for the latest version of the
final-markdown native library and download it. If you do not wish to use pre-built libraries,
you can build final-markdown yourself from the source code at https://git.revolt.chat/android/final-markdown,
and copy the files to the `app/src/main/jniLibs` folder. Note the files that are downloaded are
exactly the same as the ones we use for the app on Google Play, so you can be sure they are safe to use.
:::
8. Copy the `revoltbuild.properties.example` file to `revoltbuild.properties` and fill in the required values.
```sh

View File

@ -1,5 +1,6 @@
import { resolve } from "jsr:@std/path"
import { parseArgs } from "jsr:@std/cli/parse-args"
import { ZipReader, Uint8ArrayReader, Uint8ArrayWriter } from "jsr:@zip-js/zip-js"
const args = parseArgs(Deno.args, {
boolean: ["yes", "y", "auto-accept"],
@ -11,9 +12,11 @@ const args = parseArgs(Deno.args, {
const autoAccept = args.yes || args["auto-accept"]
const outputFolderParent = resolve(Deno.cwd(), "app", "src", "main", "assets")
const jniLibsFolder = resolve(Deno.cwd(), "app", "src", "main", "jniLibs")
try {
Deno.statSync(outputFolderParent)
Deno.statSync(jniLibsFolder)
} catch (_) {
console.error(
"\x1b[31m" + // red
@ -330,4 +333,80 @@ for (const dep of deps) {
console.log(`Downloaded ${dep.file} to ${file}`)
}
console.log("Done.")
const libsQuery = "https://git.revolt.chat/api/v1/repos/android/final-markdown/releases/latest"
console.log("The script will now query the Revolt version control server for the latest version"
+ " of the final-markdown native library and download it. If you do not wish to use pre-built"
+ " libraries, you can build final-markdown yourself from the source code at"
+ " https://git.revolt.chat/android/final-markdown, and copy the files to the"
+ " app/src/main/jniLibs folder. Note the files that are downloaded are exactly the same as"
+ " the ones we use for the app on Google Play, so you can be sure they are safe to use.")
if (!autoAccept) {
console.log(`Will now query ${libsQuery} for the latest version of the library and download it.`)
if (!confirm("Continue?")) {
console.log("Aborted.")
Deno.exit(0)
}
} else {
console.log(`Will now query ${libsQuery} for the latest version of the library and download it. (auto-accepted)`)
}
const queryLibsRes = await fetch(libsQuery)
if (!queryLibsRes.ok) {
console.error(`Failed to fetch the latest library version: ${queryLibsRes.status} ${queryLibsRes.statusText}`)
Deno.exit(1)
}
const libsJson = await queryLibsRes.json()
const zipUrl = libsJson
.assets
.find((asset: { name: string }) => asset.name === "jniLibs.zip")?.browser_download_url
if (!zipUrl) {
console.error("No jniLibs.zip found in the latest release.")
Deno.exit(1)
}
const libsRes = await fetch(zipUrl)
if (!libsRes.ok) {
console.error(`Failed to fetch the jniLibs.zip: ${libsRes.status} ${libsRes.statusText}`)
Deno.exit(1)
}
const libsData = await libsRes.arrayBuffer()
const libArchitectures = ["arm64-v8a", "armeabi-v7a", "x86", "x86_64"]
const zipFileBytes = new Uint8Array(libsData)
const zipReader = new ZipReader(new Uint8ArrayReader(zipFileBytes))
const entries = await zipReader.getEntries()
const createDirPromises = libArchitectures.map(arch => {
const dirPath = resolve(jniLibsFolder, arch)
return Deno.mkdir(dirPath, { recursive: true })
})
await Promise.all(createDirPromises)
const writeFilePromises = libArchitectures.map(async arch => {
const filePathInZip = `${arch}/libfinalmarkdown.so`
const entry = entries.find(e => e.filename === filePathInZip)
if (!entry) {
throw new Error(`Expected file not found in zip: ${filePathInZip}`)
}
const writer = new Uint8ArrayWriter()
const fileData = await entry.getData!(writer)
const destinationPath = resolve(jniLibsFolder, filePathInZip)
return Deno.writeFile(destinationPath, fileData)
})
await Promise.all(writeFilePromises)
// Close the zip reader
await zipReader.close()