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:
2026-09-27 00:17:14 +00:00
parent 6aa4be38ee
commit 1dda20d1b2
16 changed files with 878 additions and 96 deletions

View File

@@ -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()
}
}

View 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()
}
}

View File

@@ -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)
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<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()
}

View File

@@ -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),
)
}

View File

@@ -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"

View File

@@ -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)
}

View File

@@ -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;

View File

@@ -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;
}

View 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. */

View File

@@ -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 "/&lt;channelId&gt;"; 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);

View File

@@ -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.

View File

@@ -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();

View File

@@ -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());
}
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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() {