From 1dda20d1b2951802a8c8ec641c4f75fea6d6432f Mon Sep 17 00:00:00 2001 From: ericek111 Date: Sun, 27 Sep 2026 00:17:14 +0000 Subject: [PATCH] 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 --- .../com/ts3client/android/IdentityManager.kt | 170 +++++++++++ .../java/com/ts3client/android/LocalCopy.kt | 21 ++ .../ts3client/android/SessionController.kt | 62 ++-- .../com/ts3client/android/ui/ConnectScreen.kt | 111 ++++++- .../ts3client/android/ui/IdentitiesScreen.kt | 282 ++++++++++++++++++ .../ts3client/android/ui/SettingsScreen.kt | 7 +- .../java/com/ts3client/config/Bookmark.java | 10 + .../com/ts3client/config/IdentityEntry.java | 16 + .../com/ts3client/config/IdentityStore.java | 119 +++++--- .../ts3client/net/TeamspeakConnection.java | 23 +- .../com/ts3client/session/ServerSession.java | 11 +- .../teamspeak/TeamSpeakImporter.java | 13 +- .../ts3client/config/IdentityStoreTest.java | 72 +++++ .../teamspeak/TeamSpeakImporterTest.java | 7 +- .../com/ts3client/ui/IdentitiesDialog.java | 45 +++ .../main/java/com/ts3client/ui/MainFrame.java | 5 +- 16 files changed, 878 insertions(+), 96 deletions(-) create mode 100644 android/app/src/main/java/com/ts3client/android/IdentityManager.kt create mode 100644 android/app/src/main/java/com/ts3client/android/LocalCopy.kt create mode 100644 android/app/src/main/java/com/ts3client/android/ui/IdentitiesScreen.kt create mode 100644 ts3-client/core/src/test/java/com/ts3client/config/IdentityStoreTest.java diff --git a/android/app/src/main/java/com/ts3client/android/IdentityManager.kt b/android/app/src/main/java/com/ts3client/android/IdentityManager.kt new file mode 100644 index 0000000..90e8ffe --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/IdentityManager.kt @@ -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> = _all + + private val _improving = MutableStateFlow(null) + val improving: StateFlow = _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 = 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() + } +} diff --git a/android/app/src/main/java/com/ts3client/android/LocalCopy.kt b/android/app/src/main/java/com/ts3client/android/LocalCopy.kt new file mode 100644 index 0000000..ae6fd21 --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/LocalCopy.kt @@ -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 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() + } +} diff --git a/android/app/src/main/java/com/ts3client/android/SessionController.kt b/android/app/src/main/java/com/ts3client/android/SessionController.kt index cb8ebfd..f6d6573 100644 --- a/android/app/src/main/java/com/ts3client/android/SessionController.kt +++ b/android/app/src/main/java/com/ts3client/android/SessionController.kt @@ -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 = 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) - found.size to if (found.isEmpty()) 0 else contactStore.merge(found) - } finally { - copy.delete() - } + val found = withLocalCopy(context, uri, TeamSpeakSettingsDb::readContacts) + found.size to if (found.isEmpty()) 0 else contactStore.merge(found) } // ---- 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, chosen: List): 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() } diff --git a/android/app/src/main/java/com/ts3client/android/ui/ConnectScreen.kt b/android/app/src/main/java/com/ts3client/android/ui/ConnectScreen.kt index 6a0f82a..8e27f54 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/ConnectScreen.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/ConnectScreen.kt @@ -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(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): List> { + 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, id: String) = id.takeIf { i -> identities.any { it.id == i } } ?: "" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun IdentityField(identities: List, 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, 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, + 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), ) } diff --git a/android/app/src/main/java/com/ts3client/android/ui/IdentitiesScreen.kt b/android/app/src/main/java/com/ts3client/android/ui/IdentitiesScreen.kt new file mode 100644 index 0000000..600d093 --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/ui/IdentitiesScreen.kt @@ -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(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(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(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" diff --git a/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt b/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt index b0785a8..b60baec 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt @@ -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(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) } diff --git a/ts3-client/core/src/main/java/com/ts3client/config/Bookmark.java b/ts3-client/core/src/main/java/com/ts3client/config/Bookmark.java index cceef19..c4ad559 100644 --- a/ts3-client/core/src/main/java/com/ts3client/config/Bookmark.java +++ b/ts3-client/core/src/main/java/com/ts3client/config/Bookmark.java @@ -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; diff --git a/ts3-client/core/src/main/java/com/ts3client/config/IdentityEntry.java b/ts3-client/core/src/main/java/com/ts3client/config/IdentityEntry.java index a4a42ca..e4b34d7 100644 --- a/ts3-client/core/src/main/java/com/ts3client/config/IdentityEntry.java +++ b/ts3-client/core/src/main/java/com/ts3client/config/IdentityEntry.java @@ -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; } diff --git a/ts3-client/core/src/main/java/com/ts3client/config/IdentityStore.java b/ts3-client/core/src/main/java/com/ts3client/config/IdentityStore.java index deb0c99..921c0d9 100644 --- a/ts3-client/core/src/main/java/com/ts3client/config/IdentityStore.java +++ b/ts3-client/core/src/main/java/com/ts3client/config/IdentityStore.java @@ -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. + * + *

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 entries = new ArrayList<>(); @@ -73,15 +83,16 @@ public final class IdentityStore { } } - public List all() { - return entries; + /** A snapshot of the identities, sorted by name. */ + public synchronized List 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 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 properties(IdentityEntry entry) { Map 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 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 readProperties(InputStream in) { + Map 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. */ diff --git a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java index fb819f0..d7fb929 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java @@ -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); diff --git a/ts3-client/core/src/main/java/com/ts3client/session/ServerSession.java b/ts3-client/core/src/main/java/com/ts3client/session/ServerSession.java index 94883d5..a50e9e5 100644 --- a/ts3-client/core/src/main/java/com/ts3client/session/ServerSession.java +++ b/ts3-client/core/src/main/java/com/ts3client/session/ServerSession.java @@ -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. diff --git a/ts3-client/core/src/main/java/com/ts3client/teamspeak/TeamSpeakImporter.java b/ts3-client/core/src/main/java/com/ts3client/teamspeak/TeamSpeakImporter.java index a97e923..0f76749 100644 --- a/ts3-client/core/src/main/java/com/ts3client/teamspeak/TeamSpeakImporter.java +++ b/ts3-client/core/src/main/java/com/ts3client/teamspeak/TeamSpeakImporter.java @@ -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(); diff --git a/ts3-client/core/src/test/java/com/ts3client/config/IdentityStoreTest.java b/ts3-client/core/src/test/java/com/ts3client/config/IdentityStoreTest.java new file mode 100644 index 0000000..8f7b2c7 --- /dev/null +++ b/ts3-client/core/src/test/java/com/ts3client/config/IdentityStoreTest.java @@ -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()); + } +} diff --git a/ts3-client/core/src/test/java/com/ts3client/teamspeak/TeamSpeakImporterTest.java b/ts3-client/core/src/test/java/com/ts3client/teamspeak/TeamSpeakImporterTest.java index 91e99a0..526a445 100644 --- a/ts3-client/core/src/test/java/com/ts3client/teamspeak/TeamSpeakImporterTest.java +++ b/ts3-client/core/src/test/java/com/ts3client/teamspeak/TeamSpeakImporterTest.java @@ -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 diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/IdentitiesDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/IdentitiesDialog.java index 75b7ea1..53087f5 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/IdentitiesDialog.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/IdentitiesDialog.java @@ -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; diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java index ea6b9ec..e3e061b 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java @@ -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() {