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