mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-21 23:19:48 +00:00
Merge branch 'stable'
This commit is contained in:
@@ -859,8 +859,8 @@ enum ConnectTarget {
|
||||
func strConnectTarget(_ str: String) -> ConnectTarget? {
|
||||
let parsedMd = parseSimpleXMarkdown(str)
|
||||
let links = parsedMd?.filter { $0.format?.isSimplexLink ?? false } ?? []
|
||||
return if links.count == 1, case let .simplexLink(_, linkType, _, smpHosts) = links[0].format {
|
||||
.link(text: links[0].text, linkType: linkType, linkText: simplexLinkText(linkType, smpHosts))
|
||||
return if links.count == 1, case let .simplexLink(showText, linkType, simplexUri, smpHosts) = links[0].format {
|
||||
.link(text: showText != nil ? simplexUri : links[0].text, linkType: linkType, linkText: simplexLinkText(linkType, smpHosts))
|
||||
} else if links.isEmpty,
|
||||
let nameFt = parsedMd?.first(where: { if case .simplexName = $0.format { true } else { false } }),
|
||||
case let .simplexName(nameInfo) = nameFt.format {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
// Spec: spec/client/navigation.md
|
||||
|
||||
import SwiftUI
|
||||
import StoreKit
|
||||
import SimpleXChat
|
||||
|
||||
private struct VersionDescription {
|
||||
@@ -41,6 +42,8 @@ private struct FeatureView {
|
||||
let view: () -> any View
|
||||
}
|
||||
|
||||
private let isInUS = SKStorefront().countryCode == "USA"
|
||||
|
||||
private let versionDescriptions: [VersionDescription] = [
|
||||
VersionDescription(
|
||||
version: "v4.2",
|
||||
@@ -667,7 +670,13 @@ private let versionDescriptions: [VersionDescription] = [
|
||||
VersionDescription(
|
||||
version: "v7.0",
|
||||
post: nil,
|
||||
features: [
|
||||
features: (isInUS ? [
|
||||
.view(FeatureView(
|
||||
icon: nil,
|
||||
title: "Invest in SimpleX Chat",
|
||||
view: { InvestInSimpleXChat() }
|
||||
))
|
||||
] : []) + [
|
||||
.feature(Description(
|
||||
icon: "at",
|
||||
title: "SimpleX public names (BETA)",
|
||||
@@ -762,6 +771,42 @@ fileprivate struct CreateUpdateAddressShortLink: View {
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct InvestInSimpleXChat: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
Image(systemName: "dollarsign.circle")
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.frame(minWidth: 30, alignment: .center)
|
||||
Text(verbatim: "Invest in SimpleX Chat").font(.title3).bold()
|
||||
}
|
||||
Text(verbatim: "Equity crowdfunding launched!")
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(2)
|
||||
if let url = URL("https://wefunder.com/simplexchat") {
|
||||
ExternalLink(destination: url) {
|
||||
HStack {
|
||||
Text(verbatim: "Learn more on Wefunder")
|
||||
Image(systemName: "arrow.up.right.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Image(colorScheme == .light ? "own-stake" : "own-stake-light")
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: UIScreen.main.bounds.width / 5)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private enum WhatsNewViewSheet: Identifiable {
|
||||
case showConditions
|
||||
|
||||
|
||||
@@ -9,11 +9,19 @@ This is the **Kotlin Multiplatform (KMP)** mobile and desktop client for SimpleX
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
# Android debug APK
|
||||
./gradlew assembleDebug
|
||||
# Android debug APK, assembleGoogleDebug builds the flavor with the Play Billing dependency
|
||||
./gradlew assembleFossDebug
|
||||
|
||||
# Android release APK
|
||||
./gradlew assembleRelease
|
||||
# Android release APK, distributed via F-Droid and GitHub
|
||||
./gradlew assembleFossRelease
|
||||
|
||||
# Android app bundle, distributed via Google Play, includes Play Billing
|
||||
./gradlew bundleGoogleRelease
|
||||
|
||||
# Always name the flavor for releases. The aggregate tasks (build, assemble, assembleRelease,
|
||||
# bundle, bundleRelease) fail on purpose: they would package a release APK with Play Billing,
|
||||
# or an app bundle without it.
|
||||
# The fdroiddata recipe defaults to assembleRelease and must be changed to assembleFossRelease.
|
||||
|
||||
# Desktop distribution (current OS)
|
||||
./gradlew :desktop:packageDistributionForCurrentOS
|
||||
@@ -22,7 +30,7 @@ This is the **Kotlin Multiplatform (KMP)** mobile and desktop client for SimpleX
|
||||
./gradlew desktopTest
|
||||
|
||||
# Run Android instrumented tests (requires connected device/emulator)
|
||||
./gradlew connectedAndroidTest
|
||||
./gradlew connectedFossDebugAndroidTest
|
||||
|
||||
# Build native libraries for all platforms
|
||||
./gradlew common:cmakeBuild -PcrossCompile
|
||||
|
||||
@@ -35,6 +35,21 @@ android {
|
||||
manifestPlaceholders["extract_native_libs"] = rootProject.extra["compression.level"] as Int != 0
|
||||
}
|
||||
|
||||
// `google` is distributed via Google Play as an app bundle and includes Play Billing.
|
||||
// `foss` is distributed via F-Droid and as APKs on GitHub, without Play dependencies.
|
||||
flavorDimensions += "store"
|
||||
productFlavors {
|
||||
create("google") {
|
||||
dimension = "store"
|
||||
buildConfigField("boolean", "PLAY_STORE", "true")
|
||||
}
|
||||
create("foss") {
|
||||
dimension = "store"
|
||||
isDefault = true
|
||||
buildConfigField("boolean", "PLAY_STORE", "false")
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix = rootProject.extra["application_id.suffix"] as String
|
||||
@@ -128,8 +143,28 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// The graph is checked rather than the requested task, because every aggregate task
|
||||
// (assemble, assembleRelease, build, bundle, ...) packages these variants too.
|
||||
val projectPath = project.path
|
||||
val apkTasks = setOf("packageFossDebug", "packageGoogleDebug", "packageFossRelease", "packageGoogleRelease")
|
||||
val apkTaskPaths = apkTasks.map { "$projectPath:$it" }.toSet()
|
||||
val bundleTaskPaths = apkTaskPaths.map { it + "Bundle" }.toSet()
|
||||
gradle.taskGraph.whenReady {
|
||||
if (hasTask("$projectPath:packageGoogleRelease")) {
|
||||
throw GradleException("A release apk must not include Play Billing, use assembleFossRelease or bundleGoogleRelease")
|
||||
}
|
||||
if (hasTask("$projectPath:packageFossReleaseBundle")) {
|
||||
throw GradleException("An app bundle must include Play Billing, use bundleGoogleRelease or assembleFossRelease")
|
||||
}
|
||||
// `isBundle` above is derived from the whole invocation, so a bundle in it disables abi splits
|
||||
if (apkTaskPaths.any { hasTask(it) } && bundleTaskPaths.any { hasTask(it) }) {
|
||||
throw GradleException("Build the apks and the bundle in separate invocations, the bundle disables abi splits")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":common"))
|
||||
"googleImplementation"("com.android.billingclient:billing:9.1.0")
|
||||
implementation("androidx.core:core-ktx:1.13.1")
|
||||
//implementation("androidx.compose.ui:ui:${rootProject.extra["compose.version"] as String}")
|
||||
//implementation("androidx.compose.material:material:$compose_version")
|
||||
@@ -160,58 +195,61 @@ dependencies {
|
||||
tasks {
|
||||
val compressApk by creating {
|
||||
doLast {
|
||||
val isRelease = gradle.startParameter.taskNames.find { it.lowercase().contains("release") } != null
|
||||
val buildType: String = if (isRelease) "release" else "debug"
|
||||
val javaHome = System.getProperties()["java.home"] ?: org.gradle.internal.jvm.Jvm.current().javaHome
|
||||
val sdkDir = android.sdkDirectory.absolutePath
|
||||
val keyAlias: String
|
||||
val keyPassword: String
|
||||
val storeFile: String
|
||||
val storePassword: String
|
||||
if (project.properties["android.injected.signing.key.alias"] != null) {
|
||||
keyAlias = project.properties["android.injected.signing.key.alias"] as String
|
||||
keyPassword = project.properties["android.injected.signing.key.password"] as String
|
||||
storeFile = project.properties["android.injected.signing.store.file"] as String
|
||||
storePassword = project.properties["android.injected.signing.store.password"] as String
|
||||
} else {
|
||||
try {
|
||||
val gradleConfig = android.signingConfigs.getByName(buildType)
|
||||
keyAlias = gradleConfig.keyAlias!!
|
||||
keyPassword = gradleConfig.keyPassword!!
|
||||
storeFile = gradleConfig.storeFile!!.absolutePath
|
||||
storePassword = gradleConfig.storePassword!!
|
||||
} catch (e: UnknownDomainObjectException) {
|
||||
// There is no signing config for current build type, can"t sign the apk
|
||||
println("No signing configs for this build type: $buildType")
|
||||
return@doLast
|
||||
// A single invocation can package more than one variant, for example assembleDebug
|
||||
gradle.taskGraph.allTasks.filter { it.path in apkTaskPaths }.forEach { packageTask ->
|
||||
val variant = packageTask.name.removePrefix("package")
|
||||
val buildType: String = if (variant.endsWith("Release")) "release" else "debug"
|
||||
val keyAlias: String
|
||||
val keyPassword: String
|
||||
val storeFile: String
|
||||
val storePassword: String
|
||||
if (project.properties["android.injected.signing.key.alias"] != null) {
|
||||
keyAlias = project.properties["android.injected.signing.key.alias"] as String
|
||||
keyPassword = project.properties["android.injected.signing.key.password"] as String
|
||||
storeFile = project.properties["android.injected.signing.store.file"] as String
|
||||
storePassword = project.properties["android.injected.signing.store.password"] as String
|
||||
} else {
|
||||
try {
|
||||
val gradleConfig = android.signingConfigs.getByName(buildType)
|
||||
keyAlias = gradleConfig.keyAlias!!
|
||||
keyPassword = gradleConfig.keyPassword!!
|
||||
storeFile = gradleConfig.storeFile!!.absolutePath
|
||||
storePassword = gradleConfig.storePassword!!
|
||||
} catch (e: UnknownDomainObjectException) {
|
||||
// There is no signing config for current build type, can"t sign the apk
|
||||
println("No signing configs for this build type: $buildType")
|
||||
return@forEach
|
||||
}
|
||||
}
|
||||
val outputDir = packageTask.outputs.files.files.last()
|
||||
exec {
|
||||
workingDir("../../scripts/android")
|
||||
environment = mapOf(
|
||||
"JAVA_HOME" to "$javaHome",
|
||||
"PATH" to "${System.getenv("PATH")}:$javaHome/bin"
|
||||
)
|
||||
commandLine = listOf(
|
||||
"./compress-and-sign-apk.sh",
|
||||
"${rootProject.extra["compression.level"]}",
|
||||
"$outputDir",
|
||||
sdkDir,
|
||||
storeFile,
|
||||
storePassword,
|
||||
keyAlias,
|
||||
keyPassword
|
||||
)
|
||||
}
|
||||
}
|
||||
lateinit var outputDir: File
|
||||
named(if (isRelease) "packageRelease" else "packageDebug") {
|
||||
outputDir = outputs.files.files.last()
|
||||
}
|
||||
exec {
|
||||
workingDir("../../scripts/android")
|
||||
environment = mapOf(
|
||||
"JAVA_HOME" to "$javaHome",
|
||||
"PATH" to "${System.getenv("PATH")}:$javaHome/bin"
|
||||
)
|
||||
commandLine = listOf(
|
||||
"./compress-and-sign-apk.sh",
|
||||
"${rootProject.extra["compression.level"]}",
|
||||
"$outputDir",
|
||||
sdkDir,
|
||||
storeFile,
|
||||
storePassword,
|
||||
keyAlias,
|
||||
keyPassword
|
||||
)
|
||||
}
|
||||
|
||||
if (project.properties["android.injected.signing.key.alias"] != null && buildType == "release") {
|
||||
File(outputDir, "android-release.apk").renameTo(File(outputDir, "simplex.apk"))
|
||||
File(outputDir, "android-armeabi-v7a-release.apk").renameTo(File(outputDir, "simplex-armv7a.apk"))
|
||||
File(outputDir, "android-arm64-v8a-release.apk").renameTo(File(outputDir, "simplex.apk"))
|
||||
if (project.properties["android.injected.signing.key.alias"] != null && buildType == "release") {
|
||||
val flavor = variant.removeSuffix("Release").lowercase()
|
||||
mapOf("arm64-v8a" to "simplex.apk", "armeabi-v7a" to "simplex-armv7a.apk").forEach { (abi, name) ->
|
||||
if (!File(outputDir, "android-$flavor-$abi-release.apk").renameTo(File(outputDir, name))) {
|
||||
logger.warn("No $abi apk to rename to $name")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// View all gradle properties set
|
||||
// project.properties.each { k, v -> println "$k -> $v" }
|
||||
@@ -221,9 +259,7 @@ tasks {
|
||||
// Don"t do anything if no compression is needed
|
||||
if (rootProject.extra["compression.level"] as Int != 0) {
|
||||
whenTaskAdded {
|
||||
if (name == "packageDebug") {
|
||||
finalizedBy(compressApk)
|
||||
} else if (name == "packageRelease") {
|
||||
if (name in apkTasks) {
|
||||
finalizedBy(compressApk)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
package chat.simplex.app
|
||||
|
||||
// Play Billing is only in the google flavor, so the Play country stays unknown here
|
||||
fun loadPlayStoreCountry() {}
|
||||
@@ -0,0 +1,31 @@
|
||||
package chat.simplex.app
|
||||
|
||||
import chat.simplex.common.platform.androidAppContext
|
||||
import chat.simplex.common.platform.androidPlayStoreCountry
|
||||
import com.android.billingclient.api.*
|
||||
|
||||
// Requests the country of the Google Play account into [androidPlayStoreCountry].
|
||||
// It stays null when Play is unavailable or the user is not signed in.
|
||||
fun loadPlayStoreCountry() {
|
||||
val client = BillingClient.newBuilder(androidAppContext)
|
||||
.setListener { _, _ -> }
|
||||
.enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build())
|
||||
.build()
|
||||
client.startConnection(object : BillingClientStateListener {
|
||||
override fun onBillingSetupFinished(result: BillingResult) {
|
||||
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
|
||||
client.endConnection()
|
||||
return
|
||||
}
|
||||
client.getBillingConfigAsync(GetBillingConfigParams.newBuilder().build()) { configResult, config ->
|
||||
if (configResult.responseCode == BillingClient.BillingResponseCode.OK) {
|
||||
androidPlayStoreCountry.value = config?.countryCode
|
||||
}
|
||||
client.endConnection()
|
||||
}
|
||||
}
|
||||
|
||||
// The connection is only used for this one request, it is not retried
|
||||
override fun onBillingServiceDisconnected() = client.endConnection()
|
||||
})
|
||||
}
|
||||
@@ -341,6 +341,8 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
|
||||
override fun androidIsXiaomiDevice(): Boolean = setOf("xiaomi", "redmi", "poco").contains(Build.BRAND.lowercase())
|
||||
|
||||
override fun androidLoadPlayStoreCountry() = loadPlayStoreCountry()
|
||||
|
||||
@SuppressLint("SourceLockedOrientationActivity")
|
||||
@Composable
|
||||
override fun androidLockPortraitOrientation() {
|
||||
@@ -370,6 +372,8 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
override fun androidCreateActiveCallState(): Closeable = ActiveCallState()
|
||||
|
||||
override val androidApiLevel: Int get() = Build.VERSION.SDK_INT
|
||||
|
||||
override val androidIsPlayStoreBuild: Boolean get() = BuildConfig.PLAY_STORE
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -189,7 +189,6 @@ buildConfig {
|
||||
buildConfigField("String", "DESKTOP_VERSION_NAME", "\"${extra["desktop.version_name"]}\"")
|
||||
buildConfigField("int", "DESKTOP_VERSION_CODE", "${extra["desktop.version_code"]}")
|
||||
buildConfigField("String", "DATABASE_BACKEND", "\"${extra["database.backend"]}\"")
|
||||
buildConfigField("Boolean", "ANDROID_BUNDLE", "${extra["android.bundle"]}")
|
||||
buildConfigField("Boolean", "SIMPLEX_ASSETS", "$hasSimplexAssets")
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -341,6 +341,19 @@ actual suspend fun getBitmapFromVideo(uri: URI, timestamp: Long?, random: Boolea
|
||||
VideoPlayerInterface.PreviewAndDuration(null, 0, 0)
|
||||
}
|
||||
|
||||
actual suspend fun hasVideoTrack(uri: URI): Boolean {
|
||||
val mmr = MediaMetadataRetriever()
|
||||
return try {
|
||||
mmr.setDataSource(androidAppContext, uri.toUri())
|
||||
mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO) == "yes"
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Utils.android hasVideoTrack error: ${e.message}")
|
||||
false
|
||||
} finally {
|
||||
mmr.release()
|
||||
}
|
||||
}
|
||||
|
||||
actual fun ByteArray.toBase64StringForPassphrase(): String = Base64.encodeToString(this, Base64.DEFAULT)
|
||||
|
||||
actual fun String.toByteArrayFromBase64ForPassphrase(): ByteArray = Base64.decode(this, Base64.DEFAULT)
|
||||
|
||||
+5
@@ -1,5 +1,7 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import chat.simplex.common.BuildConfigCommon
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.ui.theme.DefaultTheme
|
||||
@@ -30,6 +32,9 @@ else
|
||||
|
||||
val databaseBackend: String = if (appPlatform == AppPlatform.ANDROID) "sqlite" else BuildConfigCommon.DATABASE_BACKEND
|
||||
|
||||
// Country of the Google Play account, only set in the google flavor of the Android app
|
||||
val androidPlayStoreCountry: MutableState<String?> = mutableStateOf(null)
|
||||
|
||||
class FifoQueue<E>(private var capacity: Int) : LinkedList<E>() {
|
||||
override fun add(element: E): Boolean {
|
||||
if (size > capacity) removeFirstOrNull()
|
||||
|
||||
+4
@@ -29,7 +29,11 @@ interface PlatformInterface {
|
||||
fun androidRestartNetworkObserver() {}
|
||||
fun androidCreateActiveCallState(): Closeable = Closeable { }
|
||||
fun androidIsXiaomiDevice(): Boolean = false
|
||||
// Requests the Google Play account country into [androidPlayStoreCountry]
|
||||
fun androidLoadPlayStoreCountry() {}
|
||||
val androidApiLevel: Int? get() = null
|
||||
// The build distributed via Google Play, which has to follow its policies
|
||||
val androidIsPlayStoreBuild: Boolean get() = false
|
||||
@Composable fun androidLockPortraitOrientation() {}
|
||||
suspend fun androidAskToAllowBackgroundCalls(): Boolean = true
|
||||
@Composable fun desktopShowAppUpdateNotice() {}
|
||||
|
||||
+20
-2
@@ -285,7 +285,22 @@ expect fun AttachmentSelection(
|
||||
)
|
||||
|
||||
fun MutableState<ComposeState>.onFilesAttached(uris: List<URI>) {
|
||||
val groups = uris.groupBy { isImage(it) || isVideoUri(it) }
|
||||
// The extension is enough to classify every format except .webm, which is just as commonly an
|
||||
// audio-only container as a video one. An audio-only file has no frame to embed and is sent as a file,
|
||||
// but that can only be told from the content, so reading it is deferred to a background thread.
|
||||
// Only done here, where files arrive without the user saying how to send them (drag & drop, paste) -
|
||||
// an explicitly picked video is still sent as one.
|
||||
if (uris.none { isWebmUri(it) }) {
|
||||
attachFiles(uris, emptySet())
|
||||
} else {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
attachFiles(uris, uris.filter { isWebmUri(it) && hasVideoTrack(it) }.toSet())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableState<ComposeState>.attachFiles(uris: List<URI>, webmVideos: Set<URI>) {
|
||||
val groups = uris.groupBy { isImage(it) || (isVideoUri(it) && (!isWebmUri(it) || it in webmVideos)) }
|
||||
val media = groups[true] ?: emptyList()
|
||||
val files = groups[false] ?: emptyList()
|
||||
if (media.isNotEmpty()) {
|
||||
@@ -298,9 +313,12 @@ fun MutableState<ComposeState>.onFilesAttached(uris: List<URI>) {
|
||||
private fun isVideoUri(uri: URI): Boolean {
|
||||
val name = getFileName(uri)?.lowercase() ?: return false
|
||||
return name.endsWith(".mov") || name.endsWith(".avi") || name.endsWith(".mp4") ||
|
||||
name.endsWith(".mpg") || name.endsWith(".mpeg") || name.endsWith(".mkv")
|
||||
name.endsWith(".mpg") || name.endsWith(".mpeg") || name.endsWith(".mkv") ||
|
||||
name.endsWith(".webm")
|
||||
}
|
||||
|
||||
private fun isWebmUri(uri: URI): Boolean = getFileName(uri)?.lowercase()?.endsWith(".webm") == true
|
||||
|
||||
fun MutableState<ComposeState>.processPickedFile(uri: URI?, text: String?) {
|
||||
if (uri != null) {
|
||||
val maxFileSize = value.maxFileSize
|
||||
|
||||
+2
@@ -183,6 +183,8 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow<Animate
|
||||
val showWhatsNew = shouldShowWhatsNew(chatModel)
|
||||
val showUpdatedConditions = chatModel.conditions.value.conditionsAction?.shouldShowNotice ?: false
|
||||
if (showWhatsNew || showUpdatedConditions) {
|
||||
// Requested here, so that the country is known by the time the modal opens
|
||||
platform.androidLoadPlayStoreCountry()
|
||||
delay(1000L)
|
||||
ModalManager.center.showCustomModal { close -> WhatsNewView(close = close, updatedConditions = showUpdatedConditions) }
|
||||
}
|
||||
|
||||
+3
@@ -495,6 +495,9 @@ fun ciSenderProfile(ci: ChatItem, chatInfo: ChatInfo): LocalProfile? = when (val
|
||||
|
||||
expect suspend fun getBitmapFromVideo(uri: URI, timestamp: Long? = null, random: Boolean = true, withAlertOnException: Boolean = true): VideoPlayerInterface.PreviewAndDuration
|
||||
|
||||
// Whether the file really contains a video track. Reads container metadata only, without decoding a frame.
|
||||
expect suspend fun hasVideoTrack(uri: URI): Boolean
|
||||
|
||||
fun showWrongUriAlert() {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.non_content_uri_alert_title),
|
||||
|
||||
+2
-1
@@ -836,7 +836,8 @@ fun strConnectTarget(str: String): ConnectTarget? {
|
||||
val links = parsedMd.filter { it.format?.isSimplexLink ?: false }
|
||||
if (links.size == 1) {
|
||||
val fmt = links[0].format as Format.SimplexLink
|
||||
return ConnectTarget.Link(links[0].text, fmt.linkType, fmt.simplexLinkText)
|
||||
val text = if (fmt.showText != null) fmt.simplexUri else links[0].text
|
||||
return ConnectTarget.Link(text, fmt.linkType, fmt.simplexLinkText)
|
||||
}
|
||||
if (links.isEmpty()) {
|
||||
val nameFt = parsedMd.firstOrNull { it.format is Format.SimplexName }
|
||||
|
||||
+58
@@ -19,6 +19,7 @@ import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.BuildConfigCommon
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.model.*
|
||||
@@ -916,6 +917,11 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
||||
version = "v7.0",
|
||||
post = null,
|
||||
features = listOf(
|
||||
// VersionFeature.FeatureView(
|
||||
// icon = null,
|
||||
// titleId = MR.strings.v7_0_invest,
|
||||
// view = { _ -> InvestInSimpleXChatView() }
|
||||
// ),
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_alternate_email,
|
||||
titleId = MR.strings.v7_0_simplex_names,
|
||||
@@ -950,6 +956,58 @@ fun shouldShowWhatsNew(m: ChatModel): Boolean {
|
||||
return v != lastVersion
|
||||
}
|
||||
|
||||
// private const val WEFUNDER_URL = "https://wefunder.com/simplexchat"
|
||||
//
|
||||
// @Composable
|
||||
// private fun InvestInSimpleXChatView() {
|
||||
// if (platform.androidIsPlayStoreBuild) {
|
||||
// LaunchedEffect(Unit) { if (androidPlayStoreCountry.value == null) platform.androidLoadPlayStoreCountry() }
|
||||
// if (androidPlayStoreCountry.value != "US") return
|
||||
// }
|
||||
// val uriHandler = LocalUriHandler.current
|
||||
// Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(bottom = 12.dp)) {
|
||||
// Column(modifier = Modifier.weight(1f)) {
|
||||
// Row(
|
||||
// verticalAlignment = Alignment.CenterVertically,
|
||||
// horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
// modifier = Modifier.padding(bottom = 4.dp)
|
||||
// ) {
|
||||
// Icon(painterResource(MR.images.ic_redeem), stringResource(MR.strings.v7_0_invest), tint = MaterialTheme.colors.secondary)
|
||||
// Text(
|
||||
// generalGetString(MR.strings.v7_0_invest),
|
||||
// maxLines = 2,
|
||||
// overflow = TextOverflow.Ellipsis,
|
||||
// style = MaterialTheme.typography.h4,
|
||||
// fontWeight = FontWeight.Medium,
|
||||
// modifier = Modifier.padding(bottom = 6.dp)
|
||||
// )
|
||||
// }
|
||||
// Text(generalGetString(MR.strings.v7_0_invest_descr), fontSize = 15.sp, modifier = Modifier.padding(bottom = 4.dp))
|
||||
// Row(
|
||||
// verticalAlignment = Alignment.CenterVertically,
|
||||
// horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
// modifier = Modifier
|
||||
// .clickable(
|
||||
// interactionSource = remember { MutableInteractionSource() },
|
||||
// indication = null
|
||||
// ) {
|
||||
// uriHandler.openExternalLink(WEFUNDER_URL)
|
||||
// }
|
||||
// ) {
|
||||
// Text(stringResource(MR.strings.v7_0_invest_learn_more), color = MaterialTheme.colors.primary, fontSize = 15.sp)
|
||||
// Icon(painterResource(MR.images.ic_open_in_new), stringResource(MR.strings.v7_0_invest_learn_more), tint = MaterialTheme.colors.primary)
|
||||
// }
|
||||
// }
|
||||
// if (BuildConfigCommon.SIMPLEX_ASSETS) {
|
||||
// Image(
|
||||
// painterResource(if (isInDarkTheme()) MR.images.own_stake_light else MR.images.own_stake),
|
||||
// contentDescription = null,
|
||||
// modifier = Modifier.width(80.dp)
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
@Composable
|
||||
fun CreateUpdateAddressShortLinkView(modalManager: ModalManager) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
|
||||
+1
-2
@@ -22,7 +22,6 @@ import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.BuildConfigCommon
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.platform.*
|
||||
@@ -143,7 +142,7 @@ fun HelpAndSupportView(
|
||||
SectionDividerSpaced()
|
||||
|
||||
SectionView(stringResource(MR.strings.settings_section_title_support_project)) {
|
||||
if (!BuildConfigCommon.ANDROID_BUNDLE) {
|
||||
if (!platform.androidIsPlayStoreBuild) {
|
||||
ContributeItem(uriHandler)
|
||||
}
|
||||
if (appPlatform.isAndroid) {
|
||||
|
||||
@@ -2736,6 +2736,9 @@
|
||||
<string name="v6_5_safe_web_links_descr">- opt-in to send link previews.\n- use SOCKS proxy if enabled.\n- prevent hyperlink phishing.\n- remove link tracking.</string>
|
||||
<string name="v6_5_non_profit_governance">Non-profit governance</string>
|
||||
<string name="v6_5_non_profit_governance_descr">To make SimpleX Network last.</string>
|
||||
<!-- <string name="v7_0_invest">Invest in SimpleX Chat</string> -->
|
||||
<!-- <string name="v7_0_invest_descr">Equity crowdfunding launched!</string> -->
|
||||
<!-- <string name="v7_0_invest_learn_more">Learn more on Wefunder</string> -->
|
||||
<string name="v7_0_simplex_names">SimpleX public names (BETA)</string>
|
||||
<string name="v7_0_simplex_names_descr">Public names for your channel or business.</string>
|
||||
<string name="v7_0_channels">Better channels 📢</string>
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<rect width="24" height="24" fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 175 B |
+4
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<rect width="24" height="24" fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 175 B |
+41
@@ -7,6 +7,10 @@ import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.*
|
||||
import org.jetbrains.compose.videoplayer.SkiaBitmapVideoSurface
|
||||
import uk.co.caprica.vlcj.media.Media
|
||||
import uk.co.caprica.vlcj.media.MediaEventAdapter
|
||||
import uk.co.caprica.vlcj.media.MediaParsedStatus
|
||||
import uk.co.caprica.vlcj.media.ParseFlag
|
||||
import uk.co.caprica.vlcj.media.VideoOrientation
|
||||
import uk.co.caprica.vlcj.player.base.*
|
||||
import uk.co.caprica.vlcj.player.component.CallbackMediaPlayerComponent
|
||||
@@ -255,6 +259,43 @@ actual class VideoPlayer actual constructor(
|
||||
return@withContext VideoPlayerInterface.PreviewAndDuration(preview = preview, timestamp = 0L, duration = duration)
|
||||
}
|
||||
|
||||
// Parsing a local container header takes a few dozen ms, this is only a guard against a stuck parse
|
||||
private const val PARSE_TIMEOUT_MS = 3000L
|
||||
|
||||
// Reads container metadata to tell whether there is a video track at all, without decoding a frame.
|
||||
// libvlc signals the end of parsing with an event, so no polling or frame-decoding budget is needed.
|
||||
suspend fun hasVideoTrack(uri: URI): Boolean = withContext(previewThread.asCoroutineDispatcher()) {
|
||||
if (!uri.toFile().exists()) return@withContext false
|
||||
val media = try {
|
||||
vlcPreviewFactory.media().newMedia(uri.toFile().absolutePath)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "hasVideoTrack unable to create media: ${e.stackTraceToString()}")
|
||||
null
|
||||
} ?: return@withContext false
|
||||
try {
|
||||
val parsed = CompletableDeferred<MediaParsedStatus?>()
|
||||
media.events().addMediaEventListener(object: MediaEventAdapter() {
|
||||
// vlcj maps an unknown status int to null, and a null here would throw on its event thread
|
||||
override fun mediaParsedChanged(parsedMedia: Media?, newStatus: MediaParsedStatus?) {
|
||||
parsed.complete(newStatus)
|
||||
}
|
||||
})
|
||||
if (!media.parsing().parse(PARSE_TIMEOUT_MS.toInt(), ParseFlag.PARSE_LOCAL)) {
|
||||
return@withContext false
|
||||
}
|
||||
if (withTimeoutOrNull(PARSE_TIMEOUT_MS) { parsed.await() } != MediaParsedStatus.DONE) {
|
||||
media.parsing().stop()
|
||||
return@withContext false
|
||||
}
|
||||
media.info().videoTracks().isNotEmpty()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "hasVideoTrack error: ${e.stackTraceToString()}")
|
||||
false
|
||||
} finally {
|
||||
media.release()
|
||||
}
|
||||
}
|
||||
|
||||
val playerThread = Executors.newSingleThreadExecutor()
|
||||
private val previewThread = Executors.newSingleThreadExecutor()
|
||||
private val playersPool: ArrayList<Component> = ArrayList()
|
||||
|
||||
+2
-1
@@ -9,5 +9,6 @@ fun isVideo(uri: URI): Boolean {
|
||||
path.endsWith(".mp4") ||
|
||||
path.endsWith(".mpg") ||
|
||||
path.endsWith(".mpeg") ||
|
||||
path.endsWith(".mkv")
|
||||
path.endsWith(".mkv") ||
|
||||
path.endsWith(".webm")
|
||||
}
|
||||
|
||||
+2
@@ -255,6 +255,8 @@ actual suspend fun getBitmapFromVideo(uri: URI, timestamp: Long?, random: Boolea
|
||||
return VideoPlayer.getBitmapFromVideo(null, uri, withAlertOnException)
|
||||
}
|
||||
|
||||
actual suspend fun hasVideoTrack(uri: URI): Boolean = VideoPlayer.hasVideoTrack(uri)
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
actual fun ByteArray.toBase64StringForPassphrase(): String = Base64.encode(this)
|
||||
|
||||
|
||||
@@ -27,8 +27,6 @@ kotlin.jvm.target=11
|
||||
android.version_name=7.0
|
||||
android.version_code=366
|
||||
|
||||
android.bundle=false
|
||||
|
||||
desktop.version_name=7.0
|
||||
desktop.version_code=155
|
||||
|
||||
|
||||
@@ -370,6 +370,8 @@ var platform: PlatformInterface = object : PlatformInterface {}
|
||||
| `androidCreateActiveCallState()` | empty `Closeable` | Create `ActiveCallState` |
|
||||
| `androidIsXiaomiDevice()` | `false` | Check device brand |
|
||||
| `androidApiLevel` | `null` | `Build.VERSION.SDK_INT` |
|
||||
| `androidIsPlayStoreBuild` | `false` | `BuildConfig.PLAY_STORE` |
|
||||
| `androidLoadPlayStoreCountry()` | no-op | Request the Play account country (google flavor only) |
|
||||
| `androidLockPortraitOrientation()` | no-op | Lock to `SCREEN_ORIENTATION_PORTRAIT` |
|
||||
| `androidAskToAllowBackgroundCalls()` | `true` | Show battery restriction dialog |
|
||||
| `desktopShowAppUpdateNotice()` | no-op | Show update notice (Desktop only) |
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
@@ -68,14 +68,15 @@ The project uses several custom forks managed via `cabal.project`:
|
||||
```bash
|
||||
cd apps/multiplatform
|
||||
|
||||
# Build Android debug APK
|
||||
./gradlew assembleDebug
|
||||
# Build Android debug APK; `foss` ships to F-Droid/GitHub, `google` adds Play Billing.
|
||||
# The aggregate tasks fail by design, see apps/multiplatform/README.md
|
||||
./gradlew assembleFossDebug
|
||||
|
||||
# Build desktop
|
||||
./gradlew :desktop:packageDistributionForCurrentOS
|
||||
|
||||
# Run Android tests
|
||||
./gradlew connectedAndroidTest
|
||||
./gradlew connectedFossDebugAndroidTest
|
||||
```
|
||||
|
||||
### iOS
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Connecting via a SimpleX link written as a markdown hyperlink
|
||||
|
||||
## Problem
|
||||
|
||||
Pasting a short SimpleX link written as a markdown hyperlink — `[label](https://smp6.simplex.im/a#...)` — into the chat list search, the new chat sheet search, or "Tap to paste link" fails with "Invalid connection link" instead of connecting.
|
||||
|
||||
## Cause
|
||||
|
||||
`markdownP` parses such a link into a single fragment whose `format` is `SimplexLink` but whose `text` is the whole markdown source:
|
||||
|
||||
```
|
||||
[{"format":{"type":"simplexLink","showText":"label","linkType":"contact",
|
||||
"simplexUri":"simplex:/a#...?h=smp6.simplex.im","smpHosts":["smp6.simplex.im"]},
|
||||
"text":"[label](https://smp6.simplex.im/a#...)"}]
|
||||
```
|
||||
|
||||
`strConnectTarget` returns that `text` as the string to connect with. For a bare link `text` is the link, so it works; for a hyperlink it is `[label](link)`, which the core rejects as `InvalidConnReq`.
|
||||
|
||||
## Design
|
||||
|
||||
Use `simplexUri` — the link the parser already resolved — when the fragment came from the hyperlink parser, and keep using `text` otherwise:
|
||||
|
||||
```
|
||||
text = if showText != null then simplexUri else text
|
||||
```
|
||||
|
||||
`showText` is an exact discriminator, not a heuristic: `simplexUriFormat` is called with `Just t` only from `sowLinkP` (the hyperlink parser) and with `Nothing` from `wordMD` (bare link). Gating on it leaves every bare-link path unchanged.
|
||||
|
||||
This also matches how the chat item renderer already resolves the same format — `TextItemView.kt` takes `simplexUri`, never the fragment `text`, when `showText` is set. `strConnectTarget` was the outlier.
|
||||
|
||||
## Scope
|
||||
|
||||
Short links only. `sowLinkP` rejects a full link inside a hyperlink (`fail "full SimpleX link in hyperlink"`), so `[label](full-link)` yields no formatting at all and never reaches this code — it stays treated as search text, as before. Bare full links are unaffected.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Send dropped `.webm` as video only when it has a video track
|
||||
|
||||
## Problem
|
||||
|
||||
Dragging a `.webm` file onto the desktop compose area attaches it as a plain file instead of embedding it as a video with a preview frame and duration. Every other video container the app recognises (`.mov`, `.avi`, `.mp4`, `.mpg`, `.mpeg`, `.mkv`) embeds. The same omission hides `.webm` from the "Attach → video" file picker, so the only way to send one is "Choose file", which sends it as a document.
|
||||
|
||||
## Cause
|
||||
|
||||
`isVideoUri` (`apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt:298`) classifies attachments by file extension and does not list `.webm`; the desktop picker filter `isVideo` (`apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Videos.desktop.kt:5`) repeats the same list with the same omission. `onFilesAttached` groups the dropped URIs by `isImage(it) || isVideoUri(it)`, so a `.webm` fails both predicates, falls into the files group and reaches `processPickedFile`, which builds a `ComposePreview.FilePreview`.
|
||||
|
||||
Adding the extension to both lists is not sufficient on its own. Unlike the other containers, `.webm` is used about as often for audio alone as for video — it is `MediaRecorder`'s default audio container, and Opus/Vorbis in WebM is widespread on the web. An audio-only file classified as video reaches the video branch of `processPickedMedia`, where `getBitmapFromVideo` finds no video track, returns a null preview and raises the "video decoding" alert; the item is then skipped and nothing is attached at all (`ComposeView.kt:366-376`). That is strictly worse than the file attachment the same drop produced before.
|
||||
|
||||
## Fix
|
||||
|
||||
Add `.webm` to both extension lists, and for `.webm` alone decide from the file's content rather than its name. A new `expect suspend fun hasVideoTrack(uri)` (`views/helpers/Utils.kt`) reports whether the container declares a video track, reading metadata only and never decoding a frame. On desktop it is implemented with libvlc's media parse (`platform/VideoPlayer.desktop.kt`), which signals completion with an event rather than a poll, so no frame-decoding budget is needed; measured at 12-346 ms across VP8, VP9, AV1, alpha and a 42 MB file, with a 3 s timeout as a guard against a stuck parse. On Android it uses `MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO`. Either implementation answering "no", or failing, attaches the file as a file, which is always safe.
|
||||
|
||||
`onFilesAttached` consults it only when a `.webm` is actually among the dropped URIs; every other attachment keeps the original synchronous code path on the caller thread, so the change adds no latency and no threading difference to images, documents or the other video containers. Files with a video track are sent as video, the rest as files.
|
||||
|
||||
The content check is applied only where the user has not said how the file should be sent — drag & drop and paste. An explicitly picked video is still trusted: selecting an audio-only `.webm` through "Attach → video" raises the existing decoding error, which matches how the other containers already behave.
|
||||
@@ -23,5 +23,8 @@ unzip -o "$tmp/libsimplex.zip" -d "$tmp/simplex-chat/apps/multiplatform/common/s
|
||||
curl -sSf "$libsup" -o "$tmp/libsupport.zip"
|
||||
unzip -o "$tmp/libsupport.zip" -d "$tmp/simplex-chat/apps/multiplatform/common/src/commonMain/cpp/android/libs/arm64-v8a"
|
||||
|
||||
gradle -p "$tmp/simplex-chat/apps/multiplatform/" -Psimplex.assets.dir=../../assets clean build
|
||||
cp "$tmp/simplex-chat/apps/multiplatform/android/build/outputs/apk/release/android-release-unsigned.apk" "$PWD/simplex-chat.apk"
|
||||
# Build only the arch the libs were downloaded for
|
||||
sed -i.bak 's/include(.*/include("arm64-v8a")/' "$tmp/simplex-chat/apps/multiplatform/android/build.gradle.kts"
|
||||
|
||||
gradle -p "$tmp/simplex-chat/apps/multiplatform/" -Psimplex.assets.dir=../../assets clean :android:assembleFossRelease
|
||||
cp "$tmp/simplex-chat/apps/multiplatform/android/build/outputs/apk/foss/release/android-foss-arm64-v8a-release-unsigned.apk" "$PWD/simplex-chat.apk"
|
||||
|
||||
@@ -101,7 +101,7 @@ build() {
|
||||
sed -i.bak 's/${extract_native_libs}/true/' "$folder/apps/multiplatform/android/src/main/AndroidManifest.xml"
|
||||
sed -i.bak 's/jniLibs.useLegacyPackaging =.*/jniLibs.useLegacyPackaging = true/' "$folder/apps/multiplatform/android/build.gradle.kts"
|
||||
sed -i.bak '/android {/a lint {abortOnError = false}' "$folder/apps/multiplatform/android/build.gradle.kts"
|
||||
sed -i.bak '/tasks/Q' "$folder/apps/multiplatform/android/build.gradle.kts"
|
||||
sed -i.bak '/^tasks {/Q' "$folder/apps/multiplatform/android/build.gradle.kts"
|
||||
sed -i.bak "s/android.version_code=.*/android.version_code=${vercode}/" "$folder/apps/multiplatform/gradle.properties"
|
||||
|
||||
for arch in $arches; do
|
||||
@@ -119,7 +119,7 @@ build() {
|
||||
arch_map "$arch"
|
||||
|
||||
android_tmp_folder="${tmp}/android-${arch}"
|
||||
android_apk_output="${folder}/apps/multiplatform/android/build/outputs/apk/release/android-${android_arch}-release-unsigned.apk"
|
||||
android_apk_output="${folder}/apps/multiplatform/android/build/outputs/apk/foss/release/android-foss-${android_arch}-release-unsigned.apk"
|
||||
android_apk_output_final="simplex-chat-${android_arch}.apk"
|
||||
libs_folder="${folder}/apps/multiplatform/common/src/commonMain/cpp/android/libs"
|
||||
|
||||
@@ -134,7 +134,7 @@ build() {
|
||||
|
||||
# Build only one arch
|
||||
sed -i.bak "s/include(.*/include(\"${android_arch}\")/" "$folder/apps/multiplatform/android/build.gradle.kts"
|
||||
gradle -p "$folder/apps/multiplatform/" -Psimplex.assets.dir=../../assets clean :android:assembleRelease
|
||||
gradle -p "$folder/apps/multiplatform/" -Psimplex.assets.dir=../../assets clean :android:assembleFossRelease
|
||||
|
||||
mkdir -p "$android_tmp_folder"
|
||||
unzip -oqd "$android_tmp_folder" "$android_apk_output"
|
||||
|
||||
@@ -118,7 +118,7 @@ check_apk() {
|
||||
verify_apk() {
|
||||
apk_name="$1"
|
||||
|
||||
# Release APKs are packaged by AGP (gradle :android:assembleRelease; AGP version is
|
||||
# Release APKs are packaged by AGP (gradle :android:assembleFossRelease; AGP version is
|
||||
# gradle.plugin.version in apps/multiplatform/gradle.properties), which zero-pads ZIP
|
||||
# alignment. Do NOT add --pad-like-apksigner (standalone apksigner >= 35.0.0-rc1 uses
|
||||
# the 0xd935 extra-field padding) unless AGP is bumped to a packager that uses it —
|
||||
|
||||
Reference in New Issue
Block a user