Manage identities on Android, pick one per bookmark, and give identities their own nicknames
Settings -> Identities lists them and creates, imports (an identity file or a TeamSpeak 3 settings.db), exports, renames and removes them, picks the default and improves an identity's security level; the search runs on until reached or stopped, whichever page is on screen. The Connect screen and each bookmark choose the identity to connect with. An identity keeps a nickname and phonetic nickname, in TeamSpeak's own INI keys, on both clients. A connect goes by the bookmark's nickname, else the identity's, else the global one; the phonetic one is sent in clientinit. Imported bookmarks no longer copy their identity's nickname. IdentityStore is now safe to share with the thread a connect resolves its identity on, and imports and exports through streams for Android's document picker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
package com.ts3client.android
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.ts3client.config.Bookmarks
|
||||
import com.ts3client.config.IdentityEntry
|
||||
import com.ts3client.config.IdentityStore
|
||||
import com.ts3client.config.Settings
|
||||
import com.ts3client.teamspeak.TeamSpeakImporter
|
||||
import com.ts3client.teamspeak.TeamSpeakSettingsDb
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.IOException
|
||||
|
||||
/** What the identity manager shows of one identity. */
|
||||
data class IdentityInfo(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val uniqueId: String,
|
||||
val securityLevel: Int,
|
||||
val isDefault: Boolean,
|
||||
val nickname: String,
|
||||
val phoneticNickname: String,
|
||||
)
|
||||
|
||||
/** A search for a better key offset under way. */
|
||||
data class Improvement(val id: String, val target: Int, val reached: Int)
|
||||
|
||||
/**
|
||||
* The identities, as TeamSpeak's Tools → Identities manages them: create, import, export,
|
||||
* rename, remove, pick the default and raise the security level. Raising the level runs
|
||||
* on until it is reached or stopped, whichever page is on screen.
|
||||
*/
|
||||
class IdentityManager(
|
||||
private val context: Context,
|
||||
private val store: IdentityStore,
|
||||
private val settings: Settings,
|
||||
private val bookmarks: Bookmarks,
|
||||
private val onBookmarksChanged: () -> Unit,
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
private val _all = MutableStateFlow(snapshot())
|
||||
val all: StateFlow<List<IdentityInfo>> = _all
|
||||
|
||||
private val _improving = MutableStateFlow<Improvement?>(null)
|
||||
val improving: StateFlow<Improvement?> = _improving
|
||||
private var improvement: Job? = null
|
||||
|
||||
/** Picks up identities added elsewhere, such as by an import or a first connect. */
|
||||
fun refresh() {
|
||||
_all.value = snapshot()
|
||||
}
|
||||
|
||||
private fun snapshot(): List<IdentityInfo> = store.all().map {
|
||||
IdentityInfo(it.id, it.name, it.uniqueId, it.securityLevel, it.id == settings.defaultIdentityId,
|
||||
it.nickname, it.phoneticNickname)
|
||||
}
|
||||
|
||||
/** Generates an identity at TeamSpeak's default security level; the first one becomes the default. */
|
||||
suspend fun generate(name: String): IdentityInfo = added {
|
||||
store.generate(name, IdentityStore.DEFAULT_SECURITY_LEVEL)
|
||||
}
|
||||
|
||||
/** Imports a TeamSpeak identity file, under the name it carries. */
|
||||
suspend fun importFile(uri: Uri): IdentityInfo = added {
|
||||
context.contentResolver.openInputStream(uri)?.use { store.importStream(it, null) }
|
||||
?: throw IOException("Cannot open the file")
|
||||
}
|
||||
|
||||
/** Imports the identities of a TeamSpeak 3 `settings.db`. */
|
||||
suspend fun importFromTeamSpeak(uri: Uri): TeamSpeakImporter.Result = withContext(Dispatchers.IO) {
|
||||
withLocalCopy(context, uri) { TeamSpeakImporter(store, bookmarks, settings).importIdentities(it) }
|
||||
}.also {
|
||||
adoptDefault(null)
|
||||
refresh()
|
||||
}
|
||||
|
||||
private suspend fun added(create: () -> IdentityEntry): IdentityInfo {
|
||||
val entry = withContext(Dispatchers.IO) { create() }
|
||||
adoptDefault(entry)
|
||||
refresh()
|
||||
return _all.value.first { it.id == entry.id }
|
||||
}
|
||||
|
||||
/** Makes [entry], or else the first identity, the default while there is none. */
|
||||
private fun adoptDefault(entry: IdentityEntry?) {
|
||||
if (store.byId(settings.defaultIdentityId) != null) return
|
||||
val fallback = entry ?: store.all().firstOrNull() ?: return
|
||||
settings.defaultIdentityId = fallback.id
|
||||
settings.save()
|
||||
}
|
||||
|
||||
/** Writes the identity out in TeamSpeak's format, for the official client or another device. */
|
||||
suspend fun export(id: String, uri: Uri) = withContext(Dispatchers.IO) {
|
||||
val entry = store.byId(id) ?: return@withContext
|
||||
val out = context.contentResolver.openOutputStream(uri, "wt") ?: throw IOException("Cannot write the file")
|
||||
store.exportTo(entry, out)
|
||||
}
|
||||
|
||||
fun rename(id: String, name: String) {
|
||||
val entry = store.byId(id) ?: return
|
||||
scope.launch(Dispatchers.IO) {
|
||||
store.rename(entry, name)
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun setNicknames(id: String, nickname: String, phoneticNickname: String) {
|
||||
val entry = store.byId(id) ?: return
|
||||
scope.launch(Dispatchers.IO) {
|
||||
store.setNicknames(entry, nickname, phoneticNickname)
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
/** The nickname of the identity a connect with [id] uses; empty when it has none. */
|
||||
fun nicknameOf(id: String): String =
|
||||
(store.byId(id) ?: store.byId(settings.defaultIdentityId))?.nickname.orEmpty()
|
||||
|
||||
fun setDefault(id: String) {
|
||||
settings.defaultIdentityId = id
|
||||
settings.save()
|
||||
refresh()
|
||||
}
|
||||
|
||||
/** Deletes the identity; bookmarks that connected with it fall back to the default. */
|
||||
fun remove(id: String) {
|
||||
val entry = store.byId(id) ?: return
|
||||
scope.launch(Dispatchers.IO) {
|
||||
// Else the search would write the file back once it ends.
|
||||
if (_improving.value?.id == id) improvement?.cancelAndJoin()
|
||||
store.remove(entry, settings, bookmarks)
|
||||
onBookmarksChanged()
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
/** Searches for a key offset reaching [target], keeping the best found when stopped. */
|
||||
fun improve(id: String, target: Int) {
|
||||
if (improvement?.isActive == true) return
|
||||
val entry = store.byId(id) ?: return
|
||||
_improving.value = Improvement(id, target, entry.securityLevel)
|
||||
improvement = scope.launch {
|
||||
try {
|
||||
store.improveSecurity(entry, target) { reached, _ ->
|
||||
_improving.value = Improvement(id, target, reached)
|
||||
isActive
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
// The level reached stays in memory for this run.
|
||||
} finally {
|
||||
_improving.value = null
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopImproving() {
|
||||
improvement?.cancel()
|
||||
}
|
||||
}
|
||||
21
android/app/src/main/java/com/ts3client/android/LocalCopy.kt
Normal file
21
android/app/src/main/java/com/ts3client/android/LocalCopy.kt
Normal file
@@ -0,0 +1,21 @@
|
||||
package com.ts3client.android
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Hands [read] a temporary copy of the document at [uri], for readers that need a file they
|
||||
* can seek in, which a content URI is not.
|
||||
*/
|
||||
internal fun <T> withLocalCopy(context: Context, uri: Uri, read: (File) -> T): T {
|
||||
val copy = File.createTempFile("import", null, context.cacheDir)
|
||||
try {
|
||||
val input = context.contentResolver.openInputStream(uri) ?: throw IOException("Cannot open the file")
|
||||
input.use { copy.outputStream().use { out -> it.copyTo(out) } }
|
||||
return read(copy)
|
||||
} finally {
|
||||
copy.delete()
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,6 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
@@ -73,14 +72,17 @@ class SessionController(private val context: Context) {
|
||||
val settings: Settings = Settings.load()
|
||||
val icons = IconTheme(context, settings)
|
||||
private val bookmarkStore = Bookmarks.load()
|
||||
private val identities = IdentityStore.load(settings)
|
||||
private val identityStore = IdentityStore.load(settings)
|
||||
val identities = IdentityManager(context, identityStore, settings, bookmarkStore) {
|
||||
_bookmarks.value = bookmarkStore.all().toList()
|
||||
}
|
||||
private val audio = AndroidAudioBackend(context)
|
||||
private val soundPlayer = SpeakingSoundPlayer(context, audio.createSoundPlayer(settings))
|
||||
private val sounds = SoundNotifier(settings).also { it.setPlayer(soundPlayer) }
|
||||
private val notifier = MessageNotifier(context)
|
||||
private val contactStore = ContactStore.load()
|
||||
private val session = ServerSession(
|
||||
settings, identities, audio, sounds, contactStore, Listener()
|
||||
settings, identityStore, audio, sounds, contactStore, Listener()
|
||||
)
|
||||
private val conn get() = session.connection()
|
||||
private val network = NetworkFollower(context, session, ::refresh, ::system)
|
||||
@@ -172,23 +174,23 @@ class SessionController(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
val nickname: String get() = settings.nickname
|
||||
/** The nickname a connect with [identityId] goes by: the identity's own, else the global one. */
|
||||
fun nicknameFor(identityId: String): String = identities.nicknameOf(identityId).ifBlank { settings.nickname }
|
||||
val lastAddress: String get() = settings.lastAddress
|
||||
|
||||
// ---- connection ----
|
||||
|
||||
/** Connects to `host[:port]`. */
|
||||
fun connect(address: String, nickname: String, password: String = "") {
|
||||
/** Connects to `host[:port]`; an empty [identityId] means the default identity. */
|
||||
fun connect(address: String, nickname: String, password: String = "", identityId: String = "") {
|
||||
val (host, port) = parseAddress(address)
|
||||
settings.lastAddress = address
|
||||
settings.nickname = nickname
|
||||
if (identities.nicknameOf(identityId).isBlank()) settings.nickname = nickname
|
||||
settings.save()
|
||||
start(host, port, nickname, password, "", "", "")
|
||||
start(host, port, nickname, password, identityId, "", "")
|
||||
}
|
||||
|
||||
fun connect(bookmark: Bookmark) {
|
||||
val nickname = bookmark.nickname?.takeIf { it.isNotBlank() } ?: settings.nickname
|
||||
start(bookmark.address, bookmark.port, nickname, bookmark.password, bookmark.identityId,
|
||||
start(bookmark.address, bookmark.port, bookmark.nickname.orEmpty(), bookmark.password, bookmark.identityId,
|
||||
bookmark.channel, bookmark.channelPassword)
|
||||
}
|
||||
|
||||
@@ -214,9 +216,22 @@ class SessionController(private val context: Context) {
|
||||
|
||||
// ---- bookmarks ----
|
||||
|
||||
fun addBookmark(label: String, address: String, nickname: String, password: String) {
|
||||
fun addBookmark(label: String, address: String, nickname: String, password: String, identityId: String) {
|
||||
val (host, port) = parseAddress(address)
|
||||
bookmarkStore.add(Bookmark(label.ifBlank { address }, host, port, nickname, password))
|
||||
// Only a nickname other than the identity's is the server's own.
|
||||
val own = nickname.takeIf { it != nicknameFor(identityId) }.orEmpty()
|
||||
bookmarkStore.add(Bookmark(label.ifBlank { address }, host, port, own, password).also {
|
||||
it.identityId = identityId
|
||||
})
|
||||
saveBookmarks()
|
||||
}
|
||||
|
||||
/** Connects the bookmark with another identity; empty for the default one. */
|
||||
fun setBookmarkIdentity(bookmark: Bookmark, identityId: String) {
|
||||
val index = bookmarkStore.all().indexOf(bookmark)
|
||||
if (index < 0) return
|
||||
// A new entry, so the list the UI holds changes along with it.
|
||||
bookmarkStore.all()[index] = bookmark.copy().also { it.identityId = identityId }
|
||||
saveBookmarks()
|
||||
}
|
||||
|
||||
@@ -255,22 +270,15 @@ class SessionController(private val context: Context) {
|
||||
* @return how many contacts the file held and how many of them were new
|
||||
*/
|
||||
suspend fun importContacts(uri: Uri): Pair<Int, Int> = withContext(Dispatchers.IO) {
|
||||
// The database reader needs a file it can seek in, which a content URI is not.
|
||||
val copy = File.createTempFile("settings", ".db", context.cacheDir)
|
||||
try {
|
||||
context.contentResolver.openInputStream(uri)!!.use { input -> copy.outputStream().use { input.copyTo(it) } }
|
||||
val found = TeamSpeakSettingsDb.readContacts(copy)
|
||||
val found = withLocalCopy(context, uri, TeamSpeakSettingsDb::readContacts)
|
||||
found.size to if (found.isEmpty()) 0 else contactStore.merge(found)
|
||||
} finally {
|
||||
copy.delete()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- myTeamSpeak ----
|
||||
|
||||
/** The account the app stays signed in to; its reads block, so they run on [Dispatchers.IO]. */
|
||||
val myTeamSpeak: MyTeamSpeakLogin = MyTeamSpeakLogin.load()
|
||||
private val importer = TeamSpeakImporter(identities, bookmarkStore, settings)
|
||||
private val importer = TeamSpeakImporter(identityStore, bookmarkStore, settings)
|
||||
|
||||
suspend fun signInMyTeamSpeak(email: String, password: String): MyTeamSpeak.Account =
|
||||
withContext(Dispatchers.IO) { myTeamSpeak.signIn(email, password) }
|
||||
@@ -282,7 +290,10 @@ class SessionController(private val context: Context) {
|
||||
/** Imports [chosen] out of the account's [all] items, with the identities the chosen bookmarks use. */
|
||||
suspend fun importMyTeamSpeak(all: List<SyncItem>, chosen: List<SyncItem>): TeamSpeakImporter.Result =
|
||||
withContext(Dispatchers.IO) { importer.importSelected(all, chosen) }
|
||||
.also { _bookmarks.value = bookmarkStore.all().toList() }
|
||||
.also {
|
||||
_bookmarks.value = bookmarkStore.all().toList()
|
||||
identities.refresh()
|
||||
}
|
||||
|
||||
// ---- sound packs ----
|
||||
|
||||
@@ -501,7 +512,8 @@ class SessionController(private val context: Context) {
|
||||
override fun onSystemMessage(text: String) = system(text)
|
||||
|
||||
override fun onOwnChat(scope: ChatScope, peerClientId: Int, peerName: String, text: String) {
|
||||
val html = ChatHtml.message(conn.selfClientId, conn.selfUniqueId, "${settings.nickname} (you)", text)
|
||||
val self = conn.model.getClient(conn.selfClientId)?.nickname ?: settings.nickname
|
||||
val html = ChatHtml.message(conn.selfClientId, conn.selfUniqueId, "$self (you)", text)
|
||||
append(key(scope, peerClientId), peerName, html)
|
||||
if (scope == ChatScope.PRIVATE) notifier.sent(peerClientId, BBCode.toPlain(text))
|
||||
}
|
||||
@@ -509,6 +521,8 @@ class SessionController(private val context: Context) {
|
||||
override fun onStatus(status: String) {}
|
||||
|
||||
override fun onConnected() {
|
||||
// The first connect generates an identity when there was none.
|
||||
identities.refresh()
|
||||
network.onConnected()
|
||||
refresh()
|
||||
}
|
||||
|
||||
@@ -11,7 +11,19 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
@@ -26,6 +38,7 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -33,6 +46,7 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.ts3client.android.IdentityInfo
|
||||
import com.ts3client.android.SessionController
|
||||
import com.ts3client.config.Bookmark
|
||||
|
||||
@@ -48,10 +62,13 @@ fun ConnectScreen(
|
||||
) {
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val bookmarks by vm.bookmarks.collectAsStateWithLifecycle()
|
||||
val identities by vm.identities.all.collectAsStateWithLifecycle()
|
||||
var address by rememberSaveable { mutableStateOf(vm.lastAddress) }
|
||||
var nickname by rememberSaveable { mutableStateOf(vm.nickname) }
|
||||
var identityId by rememberSaveable { mutableStateOf("") }
|
||||
var nickname by rememberSaveable { mutableStateOf(vm.nicknameFor("")) }
|
||||
var password by rememberSaveable { mutableStateOf("") }
|
||||
var naming by rememberSaveable { mutableStateOf(false) }
|
||||
var choosingFor by remember { mutableStateOf<Bookmark?>(null) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -80,12 +97,16 @@ fun ConnectScreen(
|
||||
label = { Text("Server password") }, singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password))
|
||||
IdentityField(identities, identityId) {
|
||||
identityId = it
|
||||
nickname = vm.nicknameFor(it)
|
||||
}
|
||||
if (state.status != "Not connected") {
|
||||
Text(state.status, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = { onConnect { vm.connect(address.trim(), nickname.trim(), password) } },
|
||||
onClick = { onConnect { vm.connect(address.trim(), nickname.trim(), password, identityId) } },
|
||||
enabled = address.isNotBlank() && nickname.isNotBlank(),
|
||||
) { Text("Connect") }
|
||||
TextButton(onClick = { naming = true }, enabled = address.isNotBlank()) { Text("Add bookmark") }
|
||||
@@ -98,7 +119,10 @@ fun ConnectScreen(
|
||||
color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
items(bookmarks) { bookmark -> BookmarkRow(bookmark, { onConnect { vm.connect(bookmark) } }, { vm.removeBookmark(bookmark) }) }
|
||||
items(bookmarks) { bookmark ->
|
||||
BookmarkRow(bookmark, identities, { onConnect { vm.connect(bookmark) } },
|
||||
onChooseIdentity = { choosingFor = bookmark }, onRemove = { vm.removeBookmark(bookmark) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,19 +135,92 @@ fun ConnectScreen(
|
||||
onDismiss = { naming = false },
|
||||
onConfirm = {
|
||||
naming = false
|
||||
vm.addBookmark(it, address.trim(), nickname.trim(), password)
|
||||
vm.addBookmark(it, address.trim(), nickname.trim(), password, identityId)
|
||||
},
|
||||
)
|
||||
}
|
||||
choosingFor?.let { bookmark ->
|
||||
IdentityDialog(identities, bookmark.identityId, onDismiss = { choosingFor = null }) {
|
||||
choosingFor = null
|
||||
vm.setBookmarkIdentity(bookmark, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The identities to connect with, the default one first as an empty id. */
|
||||
private fun identityChoices(identities: List<IdentityInfo>): List<Pair<String, String>> {
|
||||
val default = identities.firstOrNull { it.isDefault }?.name
|
||||
return listOf("" to (default?.let { "Default ($it)" } ?: "Default")) + identities.map { it.id to it.name }
|
||||
}
|
||||
|
||||
/** An id no longer among the identities connects with the default, and shows as such. */
|
||||
private fun shownId(identities: List<IdentityInfo>, id: String) = id.takeIf { i -> identities.any { it.id == i } } ?: ""
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun IdentityField(identities: List<IdentityInfo>, selected: String, onSelect: (String) -> Unit) {
|
||||
val choices = identityChoices(identities)
|
||||
val shown = shownId(identities, selected)
|
||||
var open by remember { mutableStateOf(false) }
|
||||
ExposedDropdownMenuBox(open, { open = it }) {
|
||||
OutlinedTextField(
|
||||
choices.first { it.first == shown }.second, {}, readOnly = true, singleLine = true,
|
||||
label = { Text("Identity") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(open) },
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
||||
)
|
||||
ExposedDropdownMenu(open, { open = false }) {
|
||||
for ((id, name) in choices) {
|
||||
DropdownMenuItem({ Text(name) }, onClick = {
|
||||
open = false
|
||||
onSelect(id)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BookmarkRow(bookmark: Bookmark, onConnect: () -> Unit, onRemove: () -> Unit) {
|
||||
private fun IdentityDialog(identities: List<IdentityInfo>, selected: String, onDismiss: () -> Unit, onSelect: (String) -> Unit) {
|
||||
val shown = shownId(identities, selected)
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Connect with") },
|
||||
text = {
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
for ((id, name) in identityChoices(identities)) Choice(name, id == shown) { onSelect(id) }
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BookmarkRow(
|
||||
bookmark: Bookmark,
|
||||
identities: List<IdentityInfo>,
|
||||
onConnect: () -> Unit,
|
||||
onChooseIdentity: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
val identity = identities.firstOrNull { it.id == bookmark.identityId }
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
ListItem(
|
||||
headlineContent = { Text(bookmark.displayName()) },
|
||||
supportingContent = { Text("${bookmark.address}:${bookmark.port}") },
|
||||
supportingContent = {
|
||||
Text("${bookmark.address}:${bookmark.port}" + (identity?.let { " · ${it.name}" } ?: ""))
|
||||
},
|
||||
leadingContent = { PackIcon("BOOKMARK_MANAGER", null) },
|
||||
trailingContent = { IconButton(onClick = onRemove) { PackIcon("DELETE", "Remove bookmark") } },
|
||||
trailingContent = {
|
||||
Box {
|
||||
IconButton(onClick = { menu = true }) { Icon(Icons.Default.MoreVert, "Bookmark options") }
|
||||
DropdownMenu(menu, { menu = false }) {
|
||||
DropdownMenuItem({ Text("Identity") }, onClick = { menu = false; onChooseIdentity() })
|
||||
DropdownMenuItem({ Text("Remove") }, onClick = { menu = false; onRemove() })
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.clickable(onClick = onConnect),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
package com.ts3client.android.ui
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.ts3client.android.IdentityInfo
|
||||
import com.ts3client.android.IdentityManager
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Beyond this a search takes years; TeamSpeak's own dialog stops there too. */
|
||||
private const val MAX_SECURITY_LEVEL = 40
|
||||
|
||||
/**
|
||||
* The identities a server knows this client by, as TeamSpeak's Tools → Identities
|
||||
* manages them, with each identity's own page.
|
||||
*/
|
||||
@Composable
|
||||
fun IdentitiesScreen(manager: IdentityManager, onBack: () -> Unit) {
|
||||
var editing by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
editing?.let { id -> return IdentityScreen(manager, id, onBack = { editing = null }) }
|
||||
|
||||
val identities by manager.all.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
var creating by remember { mutableStateOf(false) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var message by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
fun work(failure: String, block: suspend () -> String?) {
|
||||
busy = true
|
||||
scope.launch {
|
||||
message = try {
|
||||
block()
|
||||
} catch (e: Exception) {
|
||||
"$failure: ${e.message ?: e}"
|
||||
}
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
val fileImporter = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
if (uri != null) work("That file is not a TeamSpeak identity") {
|
||||
editing = manager.importFile(uri).id
|
||||
null
|
||||
}
|
||||
}
|
||||
val dbImporter = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
if (uri != null) work("That file is not a TeamSpeak 3 settings.db") {
|
||||
val r = manager.importFromTeamSpeak(uri)
|
||||
if (r.identities() == 0) "That file holds no identities."
|
||||
else "Imported ${r.identitiesAdded()} of ${r.identities()} identities."
|
||||
}
|
||||
}
|
||||
|
||||
SubScreen("Identities", onBack, actions = {
|
||||
IconButton(onClick = { creating = true }, enabled = !busy) { PackIcon("IDENTITY_ADD", "New identity") }
|
||||
Box {
|
||||
IconButton(onClick = { menu = true }) { Icon(Icons.Default.MoreVert, "More") }
|
||||
DropdownMenu(menu, { menu = false }) {
|
||||
DropdownMenuItem({ Text("Import identity file") }, onClick = {
|
||||
menu = false
|
||||
fileImporter.launch(arrayOf("*/*"))
|
||||
})
|
||||
DropdownMenuItem({ Text("Import from TeamSpeak 3") }, onClick = {
|
||||
menu = false
|
||||
dbImporter.launch(arrayOf("*/*"))
|
||||
})
|
||||
}
|
||||
}
|
||||
}) { padding ->
|
||||
LazyColumn(Modifier.padding(padding)) {
|
||||
if (busy) item { LinearProgressIndicator(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) }
|
||||
message?.let { item { Hint(it) } }
|
||||
if (identities.isEmpty()) {
|
||||
item {
|
||||
Hint("No identities. One is created on the first connect.")
|
||||
}
|
||||
}
|
||||
items(identities, key = { it.id }) { identity ->
|
||||
ListItem(
|
||||
headlineContent = { Text(identity.name, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||
supportingContent = {
|
||||
Text("Security level ${identity.securityLevel}", maxLines = 1)
|
||||
},
|
||||
trailingContent = if (identity.isDefault) ({ PackIcon("IDENTITY_DEFAULT", "Default") }) else null,
|
||||
modifier = Modifier.clickable { editing = identity.id },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (creating) {
|
||||
NewIdentityDialog(onDismiss = { creating = false }) { name ->
|
||||
creating = false
|
||||
work("Could not create the identity") {
|
||||
editing = manager.generate(name).id
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One identity: its name, whether it is the default, its security level and unique ID. */
|
||||
@Composable
|
||||
private fun IdentityScreen(manager: IdentityManager, id: String, onBack: () -> Unit) {
|
||||
val identities by manager.all.collectAsStateWithLifecycle()
|
||||
val improving by manager.improving.collectAsStateWithLifecycle()
|
||||
val identity = identities.firstOrNull { it.id == id }
|
||||
if (identity == null) {
|
||||
LaunchedEffect(Unit) { onBack() }
|
||||
return
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
var name by rememberSaveable(id) { mutableStateOf(identity.name) }
|
||||
var nickname by rememberSaveable(id) { mutableStateOf(identity.nickname) }
|
||||
var phonetic by rememberSaveable(id) { mutableStateOf(identity.phoneticNickname) }
|
||||
var removing by remember { mutableStateOf(false) }
|
||||
var raising by remember { mutableStateOf(false) }
|
||||
var message by remember { mutableStateOf<String?>(null) }
|
||||
val leave = {
|
||||
val trimmed = name.trim()
|
||||
if (trimmed.isNotEmpty() && trimmed != identity.name) manager.rename(id, trimmed)
|
||||
if (nickname.trim() != identity.nickname || phonetic.trim() != identity.phoneticNickname) {
|
||||
manager.setNicknames(id, nickname.trim(), phonetic.trim())
|
||||
}
|
||||
onBack()
|
||||
}
|
||||
val exporter = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri ->
|
||||
if (uri != null) scope.launch {
|
||||
message = runCatching { manager.export(id, uri) }.fold(
|
||||
{ "Exported." },
|
||||
{ "Could not export: ${it.message ?: it}" },
|
||||
)
|
||||
}
|
||||
}
|
||||
val mine = improving?.takeIf { it.id == id }
|
||||
|
||||
SubScreen(identity.name, leave, actions = {
|
||||
IconButton(onClick = { exporter.launch(fileName(name.ifBlank { identity.name })) }) {
|
||||
PackIcon("IDENTITY_EXPORT", "Export")
|
||||
}
|
||||
IconButton(onClick = { removing = true }) { PackIcon("DELETE", "Remove") }
|
||||
}) { padding ->
|
||||
Column(Modifier.padding(padding).imePadding().verticalScroll(rememberScrollState()).padding(vertical = 8.dp)) {
|
||||
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
label = { Text("Name") }, singleLine = true, isError = name.isBlank())
|
||||
OutlinedTextField(nickname, { nickname = it }, Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(top = 8.dp),
|
||||
label = { Text("Nickname") }, placeholder = { Text("Global nickname") }, singleLine = true)
|
||||
OutlinedTextField(phonetic, { phonetic = it }, Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
label = { Text("Phonetic nickname") }, singleLine = true)
|
||||
message?.let { Hint(it) }
|
||||
|
||||
Section("Use")
|
||||
if (identity.isDefault) {
|
||||
Hint("Default identity")
|
||||
} else {
|
||||
LinkRow("Make default", null, "IDENTITY_DEFAULT") {
|
||||
manager.setDefault(id)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Security level")
|
||||
if (mine != null) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Improving to ${mine.target}: reached ${mine.reached}") },
|
||||
supportingContent = { LinearProgressIndicator(Modifier.fillMaxWidth().padding(top = 8.dp)) },
|
||||
trailingContent = { TextButton(onClick = manager::stopImproving) { Text("Stop") } },
|
||||
)
|
||||
} else {
|
||||
LinkRow("Level ${identity.securityLevel}",
|
||||
if (improving != null) "Another identity is being improved" else "Improve",
|
||||
"SECURITY") { if (improving == null) raising = true }
|
||||
}
|
||||
|
||||
Section("Details")
|
||||
val context = LocalContext.current
|
||||
LinkRow("Unique ID", identity.uniqueId) {
|
||||
// The system confirms the copy itself.
|
||||
context.getSystemService(ClipboardManager::class.java)
|
||||
.setPrimaryClip(ClipData.newPlainText("Unique ID", identity.uniqueId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (raising) {
|
||||
LevelDialog(identity, onDismiss = { raising = false }) { target ->
|
||||
raising = false
|
||||
manager.improve(id, target)
|
||||
}
|
||||
}
|
||||
if (removing) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { removing = false },
|
||||
title = { Text("Remove ${identity.name}?") },
|
||||
text = {
|
||||
Text("Server groups and permissions tied to it are lost. Export it first to keep it.")
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
removing = false
|
||||
manager.remove(id)
|
||||
}) { Text("Remove") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { removing = false }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NewIdentityDialog(onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("New identity") },
|
||||
text = {
|
||||
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text("Name") }, singleLine = true)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onConfirm(name.trim()) }, enabled = name.isNotBlank()) { Text("Create") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LevelDialog(identity: IdentityInfo, onDismiss: () -> Unit, onConfirm: (Int) -> Unit) {
|
||||
var text by remember { mutableStateOf((identity.securityLevel + 1).toString()) }
|
||||
val level = text.trim().toIntOrNull()?.takeIf { it in identity.securityLevel + 1..MAX_SECURITY_LEVEL }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Improve security level") },
|
||||
text = {
|
||||
Column {
|
||||
Text("Current level: ${identity.securityLevel}", style = MaterialTheme.typography.bodyMedium)
|
||||
OutlinedTextField(text, { text = it }, Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
label = { Text("Target level") }, singleLine = true, isError = level == null,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { level?.let(onConfirm) }, enabled = level != null) { Text("Start") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
private fun fileName(name: String) = name.replace(Regex("[^\\w.-]+"), "_") + ".ini"
|
||||
@@ -21,7 +21,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private enum class SettingsPage { SOUND, MY_TEAMSPEAK }
|
||||
private enum class SettingsPage { SOUND, IDENTITIES, MY_TEAMSPEAK }
|
||||
|
||||
/**
|
||||
* The settings, by category. Every change is saved and put into effect at once,
|
||||
@@ -32,6 +32,7 @@ fun SettingsScreen(vm: SessionController, onBack: () -> Unit) {
|
||||
var page by rememberSaveable { mutableStateOf<SettingsPage?>(null) }
|
||||
when (page) {
|
||||
SettingsPage.SOUND -> return SoundSettingsScreen(vm, onBack = { page = null })
|
||||
SettingsPage.IDENTITIES -> return IdentitiesScreen(vm.identities, onBack = { page = null })
|
||||
SettingsPage.MY_TEAMSPEAK -> return MyTeamSpeakScreen(vm, onBack = { page = null })
|
||||
null -> {}
|
||||
}
|
||||
@@ -41,10 +42,12 @@ fun SettingsScreen(vm: SessionController, onBack: () -> Unit) {
|
||||
LinkRow("Sound Settings", "Devices, transmission, voice activation, processing, volume", "PLAYBACK") {
|
||||
page = SettingsPage.SOUND
|
||||
}
|
||||
LinkRow("Identities", "Create, import, export, security level",
|
||||
"IDENTITY_MANAGER") { page = SettingsPage.IDENTITIES }
|
||||
LinkRow("myTeamSpeak",
|
||||
if (vm.myTeamSpeak.isSignedIn) "Signed in as ${vm.myTeamSpeak.username()}"
|
||||
else "Sign in to bring over your synchronised bookmarks and identities",
|
||||
"IDENTITY_MANAGER") { page = SettingsPage.MY_TEAMSPEAK }
|
||||
"MYTS_ACCOUNT") { page = SettingsPage.MY_TEAMSPEAK }
|
||||
Section("Appearance")
|
||||
IconPackPicker(vm.icons)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,16 @@ public final class Bookmark {
|
||||
this.password = password == null ? "" : password;
|
||||
}
|
||||
|
||||
/** A bookmark of its own with the same fields, to change without touching this one. */
|
||||
public Bookmark copy() {
|
||||
Bookmark b = new Bookmark(label, address, port, nickname, password);
|
||||
b.identityId = identityId;
|
||||
b.channel = channel;
|
||||
b.channelPassword = channelPassword;
|
||||
b.connectOnStartup = connectOnStartup;
|
||||
return b;
|
||||
}
|
||||
|
||||
/** Display name, falling back to "address:port" when no label is set. */
|
||||
public String displayName() {
|
||||
if (label != null && !label.isBlank()) return label;
|
||||
|
||||
@@ -14,6 +14,8 @@ public final class IdentityEntry {
|
||||
private final File file;
|
||||
private final LocalIdentity identity;
|
||||
private String name;
|
||||
private String nickname = "";
|
||||
private String phoneticNickname = "";
|
||||
|
||||
IdentityEntry(String id, String name, File file, LocalIdentity identity) {
|
||||
this.id = id;
|
||||
@@ -35,6 +37,20 @@ public final class IdentityEntry {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
/** Nickname to connect with, unless a bookmark names its own; empty for the global one. */
|
||||
public String getNickname() {
|
||||
return nickname;
|
||||
}
|
||||
|
||||
public String getPhoneticNickname() {
|
||||
return phoneticNickname;
|
||||
}
|
||||
|
||||
void setNicknames(String nickname, String phoneticNickname) {
|
||||
this.nickname = nickname == null ? "" : nickname.trim();
|
||||
this.phoneticNickname = phoneticNickname == null ? "" : phoneticNickname.trim();
|
||||
}
|
||||
|
||||
public File getFile() {
|
||||
return file;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import org.ini4j.Ini;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
@@ -26,12 +28,20 @@ import java.util.Map;
|
||||
* file in TeamSpeak's own format, so files can be exchanged with the official
|
||||
* client verbatim. The display name is kept in the INI's {@code id} key, again
|
||||
* as TeamSpeak does.
|
||||
*
|
||||
* <p>Safe to share between threads: a connection resolves its identity on its own thread
|
||||
* while the UI may be managing them.
|
||||
*/
|
||||
public final class IdentityStore {
|
||||
|
||||
/** Security level new identities are generated with, matching the TS3 client's default. */
|
||||
public static final int DEFAULT_SECURITY_LEVEL = 8;
|
||||
|
||||
/** Keys TeamSpeak keeps next to the key in an identity INI. */
|
||||
private static final String KEY_NAME = "id";
|
||||
private static final String KEY_NICKNAME = "nickname";
|
||||
private static final String KEY_PHONETIC_NICKNAME = "phonetic_nickname";
|
||||
|
||||
|
||||
private final List<IdentityEntry> entries = new ArrayList<>();
|
||||
|
||||
@@ -73,15 +83,16 @@ public final class IdentityStore {
|
||||
}
|
||||
}
|
||||
|
||||
public List<IdentityEntry> all() {
|
||||
return entries;
|
||||
/** A snapshot of the identities, sorted by name. */
|
||||
public synchronized List<IdentityEntry> all() {
|
||||
return new ArrayList<>(entries);
|
||||
}
|
||||
|
||||
public boolean isEmpty() {
|
||||
public synchronized boolean isEmpty() {
|
||||
return entries.isEmpty();
|
||||
}
|
||||
|
||||
public IdentityEntry byId(String id) {
|
||||
public synchronized IdentityEntry byId(String id) {
|
||||
if (id == null || id.isBlank()) return null;
|
||||
for (IdentityEntry e : entries) {
|
||||
if (e.getId().equals(id)) return e;
|
||||
@@ -90,7 +101,7 @@ public final class IdentityStore {
|
||||
}
|
||||
|
||||
/** The identity with this TeamSpeak unique ID, or {@code null}. */
|
||||
public IdentityEntry byUniqueId(String uniqueId) {
|
||||
public synchronized IdentityEntry byUniqueId(String uniqueId) {
|
||||
if (uniqueId == null || uniqueId.isBlank()) return null;
|
||||
for (IdentityEntry e : entries) {
|
||||
if (e.getUniqueId().equals(uniqueId)) return e;
|
||||
@@ -102,7 +113,7 @@ public final class IdentityStore {
|
||||
* Resolves the identity to connect with: the requested one, else the configured
|
||||
* default, else the first available — generating one when the store is empty.
|
||||
*/
|
||||
public IdentityEntry resolve(Settings settings, String preferredId) throws Exception {
|
||||
public synchronized IdentityEntry resolve(Settings settings, String preferredId) throws Exception {
|
||||
IdentityEntry e = byId(preferredId);
|
||||
if (e != null) return e;
|
||||
|
||||
@@ -122,16 +133,23 @@ public final class IdentityStore {
|
||||
|
||||
/** Imports a TeamSpeak identity INI file, keeping its own name unless one is given. */
|
||||
public IdentityEntry importFile(File source, String name) throws IOException {
|
||||
LocalIdentity identity = LocalIdentity.read(source);
|
||||
String label = (name == null || name.isBlank()) ? readName(source, null) : name;
|
||||
if (label == null || label.isBlank()) label = stripExtension(source.getName());
|
||||
return create(label, identity);
|
||||
try (InputStream in = new FileInputStream(source)) {
|
||||
return importStream(in, name, stripExtension(source.getName()));
|
||||
}
|
||||
}
|
||||
|
||||
/** Imports an identity from a raw TeamSpeak identity INI stream. */
|
||||
/** Imports a TeamSpeak identity INI from a stream, keeping its own name unless one is given. */
|
||||
public IdentityEntry importStream(InputStream in, String name) throws IOException {
|
||||
LocalIdentity identity = LocalIdentity.read(in);
|
||||
return create((name == null || name.isBlank()) ? "Imported identity" : name, identity);
|
||||
return importStream(in, name, "Imported identity");
|
||||
}
|
||||
|
||||
private IdentityEntry importStream(InputStream in, String name, String fallbackName) throws IOException {
|
||||
byte[] ini = in.readAllBytes();
|
||||
LocalIdentity identity = LocalIdentity.read(new ByteArrayInputStream(ini));
|
||||
Map<String, String> props = readProperties(new ByteArrayInputStream(ini));
|
||||
String label = (name == null || name.isBlank()) ? props.getOrDefault(KEY_NAME, "") : name;
|
||||
return create(label.isBlank() ? fallbackName : label, identity,
|
||||
props.getOrDefault(KEY_NICKNAME, ""), props.getOrDefault(KEY_PHONETIC_NICKNAME, ""));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,24 +162,37 @@ public final class IdentityStore {
|
||||
}
|
||||
|
||||
/** Files an existing key pair as a managed identity under the given name. */
|
||||
public IdentityEntry add(String name, LocalIdentity identity) throws IOException {
|
||||
return create(name, identity);
|
||||
public IdentityEntry add(String name, LocalIdentity identity, String nickname, String phoneticNickname)
|
||||
throws IOException {
|
||||
return create(name, identity, nickname, phoneticNickname);
|
||||
}
|
||||
|
||||
/** Writes an identity out in TeamSpeak's own format, ready to import elsewhere. */
|
||||
public void exportTo(IdentityEntry entry, File target) throws IOException {
|
||||
write(entry.getIdentity(), entry.getName(), target);
|
||||
write(entry, target);
|
||||
}
|
||||
|
||||
public void rename(IdentityEntry entry, String name) throws IOException {
|
||||
/** Writes an identity out in TeamSpeak's own format; closes {@code out}. */
|
||||
public void exportTo(IdentityEntry entry, OutputStream out) throws IOException {
|
||||
entry.getIdentity().save(out, properties(entry));
|
||||
}
|
||||
|
||||
public synchronized void rename(IdentityEntry entry, String name) throws IOException {
|
||||
if (name == null || name.isBlank()) return;
|
||||
entry.setName(name.trim());
|
||||
write(entry.getIdentity(), entry.getName(), entry.getFile());
|
||||
write(entry, entry.getFile());
|
||||
sort();
|
||||
}
|
||||
|
||||
/** Sets the nicknames the identity connects with; empty ones fall back to the global nickname. */
|
||||
public synchronized void setNicknames(IdentityEntry entry, String nickname, String phoneticNickname)
|
||||
throws IOException {
|
||||
entry.setNicknames(nickname, phoneticNickname);
|
||||
write(entry, entry.getFile());
|
||||
}
|
||||
|
||||
/** Deletes an identity, clearing any settings or bookmarks that referenced it. */
|
||||
public void remove(IdentityEntry entry, Settings settings, Bookmarks bookmarks) {
|
||||
public synchronized void remove(IdentityEntry entry, Settings settings, Bookmarks bookmarks) {
|
||||
entries.remove(entry);
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
entry.getFile().delete();
|
||||
@@ -207,7 +238,7 @@ public final class IdentityStore {
|
||||
}
|
||||
} finally {
|
||||
identity.setLastCheckedKeyOffset(offset);
|
||||
write(identity, entry.getName(), entry.getFile());
|
||||
write(entry, entry.getFile());
|
||||
}
|
||||
return best;
|
||||
}
|
||||
@@ -215,45 +246,65 @@ public final class IdentityStore {
|
||||
// ---- persistence ----
|
||||
|
||||
private IdentityEntry create(String name, LocalIdentity identity) throws IOException {
|
||||
return create(name, identity, "", "");
|
||||
}
|
||||
|
||||
private synchronized IdentityEntry create(String name, LocalIdentity identity, String nickname,
|
||||
String phoneticNickname) throws IOException {
|
||||
String id = uniqueId(name);
|
||||
File file = new File(directory(), id + ".ini");
|
||||
write(identity, name, file);
|
||||
IdentityEntry entry = new IdentityEntry(id, name, file, identity);
|
||||
IdentityEntry entry = new IdentityEntry(id, name, new File(directory(), id + ".ini"), identity);
|
||||
entry.setNicknames(nickname, phoneticNickname);
|
||||
write(entry, entry.getFile());
|
||||
entries.add(entry);
|
||||
sort();
|
||||
return entry;
|
||||
}
|
||||
|
||||
private static void write(LocalIdentity identity, String name, File file) throws IOException {
|
||||
private static void write(IdentityEntry entry, File file) throws IOException {
|
||||
File dir = file.getParentFile();
|
||||
if (dir != null && !dir.isDirectory() && !dir.mkdirs()) {
|
||||
throw new IOException("Cannot create " + dir);
|
||||
}
|
||||
ProfileFiles.write(file, out -> entry.getIdentity().save(out, properties(entry)));
|
||||
}
|
||||
|
||||
private static Map<String, String> properties(IdentityEntry entry) {
|
||||
Map<String, String> props = new HashMap<>();
|
||||
props.put("id", name == null ? "" : name);
|
||||
ProfileFiles.write(file, out -> identity.save(out, props));
|
||||
props.put(KEY_NAME, entry.getName() == null ? "" : entry.getName());
|
||||
props.put(KEY_NICKNAME, entry.getNickname());
|
||||
props.put(KEY_PHONETIC_NICKNAME, entry.getPhoneticNickname());
|
||||
return props;
|
||||
}
|
||||
|
||||
private static IdentityEntry read(File file) {
|
||||
try {
|
||||
LocalIdentity identity = LocalIdentity.read(file);
|
||||
String id = stripExtension(file.getName());
|
||||
String name = readName(file, id);
|
||||
return new IdentityEntry(id, name, file, identity);
|
||||
Map<String, String> props;
|
||||
try (InputStream in = new FileInputStream(file)) {
|
||||
props = readProperties(in);
|
||||
}
|
||||
String name = props.getOrDefault(KEY_NAME, "");
|
||||
IdentityEntry entry = new IdentityEntry(id, name.isBlank() ? id : name, file, identity);
|
||||
entry.setNicknames(props.get(KEY_NICKNAME), props.get(KEY_PHONETIC_NICKNAME));
|
||||
return entry;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads the display name from an identity INI's {@code id} key. */
|
||||
private static String readName(File file, String fallback) {
|
||||
/** The name and nicknames an identity INI carries besides the key; missing ones are left out. */
|
||||
private static Map<String, String> readProperties(InputStream in) {
|
||||
Map<String, String> props = new HashMap<>();
|
||||
try {
|
||||
Ini ini = new Ini(file);
|
||||
String name = unquote(ini.get("Identity", "id"));
|
||||
if (name != null && !name.isBlank()) return name;
|
||||
Ini ini = new Ini(in);
|
||||
for (String key : new String[]{KEY_NAME, KEY_NICKNAME, KEY_PHONETIC_NICKNAME}) {
|
||||
String value = unquote(ini.get("Identity", key));
|
||||
if (value != null) props.put(key, value);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
return fallback;
|
||||
return props;
|
||||
}
|
||||
|
||||
/** TeamSpeak quotes INI values; ini4j hands them back verbatim. */
|
||||
|
||||
@@ -240,31 +240,23 @@ public final class TeamspeakConnection implements TS3Listener {
|
||||
|
||||
// ---- connection lifecycle ----
|
||||
|
||||
/**
|
||||
* Connects in the background.
|
||||
*
|
||||
* @param identity identity to authenticate with; see {@link com.ts3client.config.IdentityStore}
|
||||
*/
|
||||
public void connect(String address, int port, String nickname, String password, LocalIdentity identity) {
|
||||
connect(address, port, nickname, password, identity, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects in the background, joining a specific channel instead of the server's
|
||||
* default one.
|
||||
*
|
||||
* @param phoneticNickname how text-to-speech should say the nickname; may be empty
|
||||
* @param identity identity to authenticate with; see {@link com.ts3client.config.IdentityStore}
|
||||
* @param channel channel path ("Lobby/Games") or "/<channelId>"; null/empty for the default channel
|
||||
* @param channelPassword password for that channel, or null if it has none
|
||||
*/
|
||||
public void connect(String address, int port, String nickname, String password, LocalIdentity identity,
|
||||
String channel, String channelPassword) {
|
||||
new Thread(() -> doConnect(address, port, nickname, password, identity, channel, channelPassword),
|
||||
"ts3j-connect").start();
|
||||
public void connect(String address, int port, String nickname, String phoneticNickname, String password,
|
||||
LocalIdentity identity, String channel, String channelPassword) {
|
||||
new Thread(() -> doConnect(address, port, nickname, phoneticNickname, password, identity, channel,
|
||||
channelPassword), "ts3j-connect").start();
|
||||
}
|
||||
|
||||
private void doConnect(String address, int port, String nickname, String password, LocalIdentity withIdentity,
|
||||
String channel, String channelPassword) {
|
||||
private void doConnect(String address, int port, String nickname, String phoneticNickname, String password,
|
||||
LocalIdentity withIdentity, String channel, String channelPassword) {
|
||||
Object attempt = new Object();
|
||||
connectAttempt = attempt;
|
||||
connectErrorId = 0;
|
||||
@@ -315,6 +307,7 @@ public final class TeamspeakConnection implements TS3Listener {
|
||||
});
|
||||
client.setIdentity(identity);
|
||||
client.setNickname(nickname);
|
||||
setIfPresent("client.nickname_phonetic", phoneticNickname);
|
||||
client.setHWID("ts3jswing-" + Integer.toHexString(nickname.hashCode()));
|
||||
client.addListener(this);
|
||||
client.addListener(events);
|
||||
|
||||
@@ -117,6 +117,8 @@ public final class ServerSession {
|
||||
// ---- connection lifecycle ----
|
||||
|
||||
/**
|
||||
* @param nickname this server's own nickname, as a bookmark may name; empty for the
|
||||
* identity's, else the global one
|
||||
* @param identityId identity to use, or empty for the default one
|
||||
* @param channel channel path to join on connect, or empty for the default channel
|
||||
* @param channelPassword password for that channel, if any
|
||||
@@ -146,10 +148,17 @@ public final class ServerSession {
|
||||
}
|
||||
this.identityId = entry.getId();
|
||||
listener.onSystemMessage("Using identity \"" + entry.getName() + "\".");
|
||||
conn.connect(address, port, nickname, password, entry.getIdentity(), channel, channelPassword);
|
||||
conn.connect(address, port, nicknameFor(nickname, entry), entry.getPhoneticNickname(), password,
|
||||
entry.getIdentity(), channel, channelPassword);
|
||||
}, "identity-resolve").start();
|
||||
}
|
||||
|
||||
private String nicknameFor(String serverNickname, IdentityEntry identity) {
|
||||
if (serverNickname != null && !serverNickname.isBlank()) return serverNickname.trim();
|
||||
if (!identity.getNickname().isBlank()) return identity.getNickname();
|
||||
return settings.nickname;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces this session's connection: leaves the server it is on, then connects to the
|
||||
* new one in its place — what left-clicking a bookmark does.
|
||||
|
||||
@@ -144,7 +144,7 @@ public final class TeamSpeakImporter {
|
||||
if (entry != null) {
|
||||
identitiesKnown++;
|
||||
} else {
|
||||
entry = identities.add(nameOf(identity), key);
|
||||
entry = identities.add(nameOf(identity), key, identity.nickname(), identity.phoneticNickname());
|
||||
identitiesAdded++;
|
||||
}
|
||||
imported.put(identity.uuid(), entry);
|
||||
@@ -168,19 +168,16 @@ public final class TeamSpeakImporter {
|
||||
SyncItem.Identity syncIdentity = identityOf(bookmark, syncIdentities);
|
||||
IdentityEntry identity = bookmark.identityUuid().isEmpty() || syncIdentity == null
|
||||
? null : imported.get(syncIdentity.uuid());
|
||||
bookmarks.add(toBookmark(bookmark, syncIdentity, identity));
|
||||
bookmarks.add(toBookmark(bookmark, identity));
|
||||
bookmarksAdded++;
|
||||
}
|
||||
if (bookmarksAdded > 0) bookmarks.save();
|
||||
return new Result(identitiesAdded, identitiesKnown, bookmarksAdded, bookmarksKnown);
|
||||
}
|
||||
|
||||
private Bookmark toBookmark(SyncItem.Bookmark sync, SyncItem.Identity syncIdentity, IdentityEntry identity) {
|
||||
String nickname = sync.nickname();
|
||||
if (nickname.isBlank() && syncIdentity != null) nickname = syncIdentity.nickname();
|
||||
if (nickname.isBlank()) nickname = settings.nickname;
|
||||
|
||||
Bookmark b = new Bookmark(sync.name(), sync.address(), sync.port(), nickname, sync.serverPassword());
|
||||
private Bookmark toBookmark(SyncItem.Bookmark sync, IdentityEntry identity) {
|
||||
// Without a nickname of its own, the bookmark takes the identity's as it connects.
|
||||
Bookmark b = new Bookmark(sync.name(), sync.address(), sync.port(), sync.nickname(), sync.serverPassword());
|
||||
b.identityId = identity == null ? "" : identity.getId();
|
||||
b.channel = sync.defaultChannel();
|
||||
if (b.channel.isBlank() && sync.defaultChannelId() != 0) b.channel = "/" + sync.defaultChannelId();
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.ts3client.config;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
|
||||
class IdentityStoreTest {
|
||||
|
||||
@TempDir
|
||||
Path dir;
|
||||
|
||||
@BeforeEach
|
||||
void freshProfile() {
|
||||
AppDirs.init(dir.toFile(), dir.resolve("chats"));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void defaultProfile() {
|
||||
AppDirs.init(null, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void exportedIdentityImportsWithItsName() throws Exception {
|
||||
IdentityStore store = IdentityStore.load(new Settings());
|
||||
IdentityEntry original = store.generate("Žofia's key", 0);
|
||||
|
||||
ByteArrayOutputStream exported = new ByteArrayOutputStream();
|
||||
store.exportTo(original, exported);
|
||||
IdentityEntry imported = store.importStream(new ByteArrayInputStream(exported.toByteArray()), null);
|
||||
|
||||
assertEquals("Žofia's key", imported.getName());
|
||||
assertEquals(original.getUniqueId(), imported.getUniqueId());
|
||||
assertNotEquals(original.getId(), imported.getId());
|
||||
assertEquals(2, IdentityStore.load(new Settings()).all().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nicknamesTravelWithTheIdentity() throws Exception {
|
||||
IdentityStore store = IdentityStore.load(new Settings());
|
||||
IdentityEntry original = store.generate("Work", 0);
|
||||
store.setNicknames(original, " Žofia ", "Zofia");
|
||||
|
||||
IdentityEntry reloaded = IdentityStore.load(new Settings()).byId(original.getId());
|
||||
assertEquals("Žofia", reloaded.getNickname());
|
||||
assertEquals("Zofia", reloaded.getPhoneticNickname());
|
||||
|
||||
ByteArrayOutputStream exported = new ByteArrayOutputStream();
|
||||
store.exportTo(original, exported);
|
||||
IdentityEntry imported = store.importStream(new ByteArrayInputStream(exported.toByteArray()), null);
|
||||
assertEquals("Žofia", imported.getNickname());
|
||||
assertEquals("Zofia", imported.getPhoneticNickname());
|
||||
}
|
||||
|
||||
@Test
|
||||
void givenNameWinsOverTheFilesOwn() throws Exception {
|
||||
IdentityStore store = IdentityStore.load(new Settings());
|
||||
ByteArrayOutputStream exported = new ByteArrayOutputStream();
|
||||
store.exportTo(store.generate("First", 0), exported);
|
||||
|
||||
IdentityEntry imported = store.importStream(new ByteArrayInputStream(exported.toByteArray()), "Second");
|
||||
|
||||
assertEquals("Second", imported.getName());
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,8 @@ class TeamSpeakImporterTest {
|
||||
|
||||
Bookmark byId = all.get(2);
|
||||
assertEquals("/42", byId.channel);
|
||||
assertEquals("SyncTester", byId.nickname, "the default identity's nickname");
|
||||
assertEquals("", byId.nickname, "takes the identity's nickname as it connects");
|
||||
assertEquals("SyncTester", imported.getNickname());
|
||||
assertEquals("", byId.identityId, "connects with whatever the default identity is");
|
||||
assertEquals("SyncTester", settings.nickname);
|
||||
|
||||
@@ -88,7 +89,9 @@ class TeamSpeakImporterTest {
|
||||
|
||||
new TeamSpeakImporter(identities, bookmarks, settings).importAll(List.of(bookmark, identity));
|
||||
assertEquals(identities.all().get(0).getId(), bookmarks.all().get(0).identityId);
|
||||
assertEquals(identity.nickname(), bookmarks.all().get(0).nickname);
|
||||
assertEquals("", bookmarks.all().get(0).nickname);
|
||||
assertEquals(identity.nickname(), identities.all().get(0).getNickname());
|
||||
assertEquals(identity.phoneticNickname(), identities.all().get(0).getPhoneticNickname());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -31,6 +31,8 @@ import java.awt.Frame;
|
||||
import java.awt.GridBagConstraints;
|
||||
import java.awt.GridBagLayout;
|
||||
import java.awt.Insets;
|
||||
import java.awt.event.FocusAdapter;
|
||||
import java.awt.event.FocusEvent;
|
||||
import java.io.File;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Consumer;
|
||||
@@ -53,6 +55,10 @@ public final class IdentitiesDialog extends JDialog {
|
||||
private final JTextField nameField = new JTextField();
|
||||
private final JTextField uidField = new JTextField();
|
||||
private final JTextField levelField = new JTextField();
|
||||
private final JTextField nicknameField = new JTextField();
|
||||
private final JTextField phoneticField = new JTextField();
|
||||
/** The identity the nickname fields belong to, which the list may already have moved off. */
|
||||
private IdentityEntry shown;
|
||||
|
||||
public IdentitiesDialog(Frame owner, IdentityStore identities, Settings settings,
|
||||
Bookmarks bookmarks, Runnable onChanged) {
|
||||
@@ -124,6 +130,19 @@ public final class IdentitiesDialog extends JDialog {
|
||||
addRow(form, c, row++, "Name:", nameField);
|
||||
addRow(form, c, row++, "Unique ID:", uidField);
|
||||
addRow(form, c, row++, "Security level:", levelField);
|
||||
addRow(form, c, row++, "Nickname:", nicknameField);
|
||||
addRow(form, c, row++, "Phonetic nickname:", phoneticField);
|
||||
|
||||
nicknameField.setToolTipText("Empty: the global nickname");
|
||||
for (JTextField field : new JTextField[]{nicknameField, phoneticField}) {
|
||||
field.addActionListener(e -> applyNicknames());
|
||||
field.addFocusListener(new FocusAdapter() {
|
||||
@Override
|
||||
public void focusLost(FocusEvent e) {
|
||||
applyNicknames();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
c.gridx = 0;
|
||||
c.gridy = row;
|
||||
@@ -172,13 +191,39 @@ public final class IdentitiesDialog extends JDialog {
|
||||
}
|
||||
|
||||
private void showDetails(IdentityEntry e) {
|
||||
applyNicknames();
|
||||
shown = e;
|
||||
nicknameField.setEnabled(e != null);
|
||||
phoneticField.setEnabled(e != null);
|
||||
nicknameField.setText(e == null ? "" : e.getNickname());
|
||||
phoneticField.setText(e == null ? "" : e.getPhoneticNickname());
|
||||
nameField.setText(e == null ? "" : e.getName());
|
||||
uidField.setText(e == null ? "" : e.getUniqueId());
|
||||
levelField.setText(e == null ? "" : Integer.toString(e.getSecurityLevel()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
// Escape closes without the field losing focus first.
|
||||
applyNicknames();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ---- actions ----
|
||||
|
||||
private void applyNicknames() {
|
||||
IdentityEntry e = shown;
|
||||
if (e == null || identities.byId(e.getId()) == null) return;
|
||||
String nickname = nicknameField.getText().trim();
|
||||
String phonetic = phoneticField.getText().trim();
|
||||
if (nickname.equals(e.getNickname()) && phonetic.equals(e.getPhoneticNickname())) return;
|
||||
try {
|
||||
identities.setNicknames(e, nickname, phonetic);
|
||||
} catch (Exception ex) {
|
||||
error("Could not save the nicknames", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void newIdentity() {
|
||||
String name = JOptionPane.showInputDialog(this, "Name for the new identity:", "New identity");
|
||||
if (name == null || name.trim().isEmpty()) return;
|
||||
|
||||
@@ -640,7 +640,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
||||
private void startConnection(String address, int port, String nickname, String password, String identityId,
|
||||
String channel, String channelPassword, boolean replaceCurrent) {
|
||||
settings.lastAddress = address + ":" + port;
|
||||
settings.nickname = nickname;
|
||||
if (!nickname.isBlank()) settings.nickname = nickname;
|
||||
settings.serverPassword = password;
|
||||
settings.save();
|
||||
|
||||
@@ -653,8 +653,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
||||
|
||||
/** Connects on the current tab, leaving whatever server it is on. */
|
||||
private void connectToBookmark(Bookmark b) {
|
||||
String nick = (b.nickname != null && !b.nickname.isBlank()) ? b.nickname : settings.nickname;
|
||||
startConnection(b.address, b.port, nick, b.password, b.identityId, b.channel, b.channelPassword, true);
|
||||
startConnection(b.address, b.port, b.nickname == null ? "" : b.nickname, b.password, b.identityId, b.channel, b.channelPassword, true);
|
||||
}
|
||||
|
||||
private void addCurrentServerBookmark() {
|
||||
|
||||
Reference in New Issue
Block a user