Manage identities on Android, pick one per bookmark, and give identities their own nicknames
Settings -> Identities lists them and creates, imports (an identity file or a TeamSpeak 3 settings.db), exports, renames and removes them, picks the default and improves an identity's security level; the search runs on until reached or stopped, whichever page is on screen. The Connect screen and each bookmark choose the identity to connect with. An identity keeps a nickname and phonetic nickname, in TeamSpeak's own INI keys, on both clients. A connect goes by the bookmark's nickname, else the identity's, else the global one; the phonetic one is sent in clientinit. Imported bookmarks no longer copy their identity's nickname. IdentityStore is now safe to share with the thread a connect resolves its identity on, and imports and exports through streams for Android's document picker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
package com.ts3client.android
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.ts3client.config.Bookmarks
|
||||
import com.ts3client.config.IdentityEntry
|
||||
import com.ts3client.config.IdentityStore
|
||||
import com.ts3client.config.Settings
|
||||
import com.ts3client.teamspeak.TeamSpeakImporter
|
||||
import com.ts3client.teamspeak.TeamSpeakSettingsDb
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.IOException
|
||||
|
||||
/** What the identity manager shows of one identity. */
|
||||
data class IdentityInfo(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val uniqueId: String,
|
||||
val securityLevel: Int,
|
||||
val isDefault: Boolean,
|
||||
val nickname: String,
|
||||
val phoneticNickname: String,
|
||||
)
|
||||
|
||||
/** A search for a better key offset under way. */
|
||||
data class Improvement(val id: String, val target: Int, val reached: Int)
|
||||
|
||||
/**
|
||||
* The identities, as TeamSpeak's Tools → Identities manages them: create, import, export,
|
||||
* rename, remove, pick the default and raise the security level. Raising the level runs
|
||||
* on until it is reached or stopped, whichever page is on screen.
|
||||
*/
|
||||
class IdentityManager(
|
||||
private val context: Context,
|
||||
private val store: IdentityStore,
|
||||
private val settings: Settings,
|
||||
private val bookmarks: Bookmarks,
|
||||
private val onBookmarksChanged: () -> Unit,
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
private val _all = MutableStateFlow(snapshot())
|
||||
val all: StateFlow<List<IdentityInfo>> = _all
|
||||
|
||||
private val _improving = MutableStateFlow<Improvement?>(null)
|
||||
val improving: StateFlow<Improvement?> = _improving
|
||||
private var improvement: Job? = null
|
||||
|
||||
/** Picks up identities added elsewhere, such as by an import or a first connect. */
|
||||
fun refresh() {
|
||||
_all.value = snapshot()
|
||||
}
|
||||
|
||||
private fun snapshot(): List<IdentityInfo> = store.all().map {
|
||||
IdentityInfo(it.id, it.name, it.uniqueId, it.securityLevel, it.id == settings.defaultIdentityId,
|
||||
it.nickname, it.phoneticNickname)
|
||||
}
|
||||
|
||||
/** Generates an identity at TeamSpeak's default security level; the first one becomes the default. */
|
||||
suspend fun generate(name: String): IdentityInfo = added {
|
||||
store.generate(name, IdentityStore.DEFAULT_SECURITY_LEVEL)
|
||||
}
|
||||
|
||||
/** Imports a TeamSpeak identity file, under the name it carries. */
|
||||
suspend fun importFile(uri: Uri): IdentityInfo = added {
|
||||
context.contentResolver.openInputStream(uri)?.use { store.importStream(it, null) }
|
||||
?: throw IOException("Cannot open the file")
|
||||
}
|
||||
|
||||
/** Imports the identities of a TeamSpeak 3 `settings.db`. */
|
||||
suspend fun importFromTeamSpeak(uri: Uri): TeamSpeakImporter.Result = withContext(Dispatchers.IO) {
|
||||
withLocalCopy(context, uri) { TeamSpeakImporter(store, bookmarks, settings).importIdentities(it) }
|
||||
}.also {
|
||||
adoptDefault(null)
|
||||
refresh()
|
||||
}
|
||||
|
||||
private suspend fun added(create: () -> IdentityEntry): IdentityInfo {
|
||||
val entry = withContext(Dispatchers.IO) { create() }
|
||||
adoptDefault(entry)
|
||||
refresh()
|
||||
return _all.value.first { it.id == entry.id }
|
||||
}
|
||||
|
||||
/** Makes [entry], or else the first identity, the default while there is none. */
|
||||
private fun adoptDefault(entry: IdentityEntry?) {
|
||||
if (store.byId(settings.defaultIdentityId) != null) return
|
||||
val fallback = entry ?: store.all().firstOrNull() ?: return
|
||||
settings.defaultIdentityId = fallback.id
|
||||
settings.save()
|
||||
}
|
||||
|
||||
/** Writes the identity out in TeamSpeak's format, for the official client or another device. */
|
||||
suspend fun export(id: String, uri: Uri) = withContext(Dispatchers.IO) {
|
||||
val entry = store.byId(id) ?: return@withContext
|
||||
val out = context.contentResolver.openOutputStream(uri, "wt") ?: throw IOException("Cannot write the file")
|
||||
store.exportTo(entry, out)
|
||||
}
|
||||
|
||||
fun rename(id: String, name: String) {
|
||||
val entry = store.byId(id) ?: return
|
||||
scope.launch(Dispatchers.IO) {
|
||||
store.rename(entry, name)
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun setNicknames(id: String, nickname: String, phoneticNickname: String) {
|
||||
val entry = store.byId(id) ?: return
|
||||
scope.launch(Dispatchers.IO) {
|
||||
store.setNicknames(entry, nickname, phoneticNickname)
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
/** The nickname of the identity a connect with [id] uses; empty when it has none. */
|
||||
fun nicknameOf(id: String): String =
|
||||
(store.byId(id) ?: store.byId(settings.defaultIdentityId))?.nickname.orEmpty()
|
||||
|
||||
fun setDefault(id: String) {
|
||||
settings.defaultIdentityId = id
|
||||
settings.save()
|
||||
refresh()
|
||||
}
|
||||
|
||||
/** Deletes the identity; bookmarks that connected with it fall back to the default. */
|
||||
fun remove(id: String) {
|
||||
val entry = store.byId(id) ?: return
|
||||
scope.launch(Dispatchers.IO) {
|
||||
// Else the search would write the file back once it ends.
|
||||
if (_improving.value?.id == id) improvement?.cancelAndJoin()
|
||||
store.remove(entry, settings, bookmarks)
|
||||
onBookmarksChanged()
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
/** Searches for a key offset reaching [target], keeping the best found when stopped. */
|
||||
fun improve(id: String, target: Int) {
|
||||
if (improvement?.isActive == true) return
|
||||
val entry = store.byId(id) ?: return
|
||||
_improving.value = Improvement(id, target, entry.securityLevel)
|
||||
improvement = scope.launch {
|
||||
try {
|
||||
store.improveSecurity(entry, target) { reached, _ ->
|
||||
_improving.value = Improvement(id, target, reached)
|
||||
isActive
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
// The level reached stays in memory for this run.
|
||||
} finally {
|
||||
_improving.value = null
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopImproving() {
|
||||
improvement?.cancel()
|
||||
}
|
||||
}
|
||||
21
android/app/src/main/java/com/ts3client/android/LocalCopy.kt
Normal file
21
android/app/src/main/java/com/ts3client/android/LocalCopy.kt
Normal file
@@ -0,0 +1,21 @@
|
||||
package com.ts3client.android
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Hands [read] a temporary copy of the document at [uri], for readers that need a file they
|
||||
* can seek in, which a content URI is not.
|
||||
*/
|
||||
internal fun <T> withLocalCopy(context: Context, uri: Uri, read: (File) -> T): T {
|
||||
val copy = File.createTempFile("import", null, context.cacheDir)
|
||||
try {
|
||||
val input = context.contentResolver.openInputStream(uri) ?: throw IOException("Cannot open the file")
|
||||
input.use { copy.outputStream().use { out -> it.copyTo(out) } }
|
||||
return read(copy)
|
||||
} finally {
|
||||
copy.delete()
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,6 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
@@ -73,14 +72,17 @@ class SessionController(private val context: Context) {
|
||||
val settings: Settings = Settings.load()
|
||||
val icons = IconTheme(context, settings)
|
||||
private val bookmarkStore = Bookmarks.load()
|
||||
private val identities = IdentityStore.load(settings)
|
||||
private val identityStore = IdentityStore.load(settings)
|
||||
val identities = IdentityManager(context, identityStore, settings, bookmarkStore) {
|
||||
_bookmarks.value = bookmarkStore.all().toList()
|
||||
}
|
||||
private val audio = AndroidAudioBackend(context)
|
||||
private val soundPlayer = SpeakingSoundPlayer(context, audio.createSoundPlayer(settings))
|
||||
private val sounds = SoundNotifier(settings).also { it.setPlayer(soundPlayer) }
|
||||
private val notifier = MessageNotifier(context)
|
||||
private val contactStore = ContactStore.load()
|
||||
private val session = ServerSession(
|
||||
settings, identities, audio, sounds, contactStore, Listener()
|
||||
settings, identityStore, audio, sounds, contactStore, Listener()
|
||||
)
|
||||
private val conn get() = session.connection()
|
||||
private val network = NetworkFollower(context, session, ::refresh, ::system)
|
||||
@@ -172,23 +174,23 @@ class SessionController(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
val nickname: String get() = settings.nickname
|
||||
/** The nickname a connect with [identityId] goes by: the identity's own, else the global one. */
|
||||
fun nicknameFor(identityId: String): String = identities.nicknameOf(identityId).ifBlank { settings.nickname }
|
||||
val lastAddress: String get() = settings.lastAddress
|
||||
|
||||
// ---- connection ----
|
||||
|
||||
/** Connects to `host[:port]`. */
|
||||
fun connect(address: String, nickname: String, password: String = "") {
|
||||
/** Connects to `host[:port]`; an empty [identityId] means the default identity. */
|
||||
fun connect(address: String, nickname: String, password: String = "", identityId: String = "") {
|
||||
val (host, port) = parseAddress(address)
|
||||
settings.lastAddress = address
|
||||
settings.nickname = nickname
|
||||
if (identities.nicknameOf(identityId).isBlank()) settings.nickname = nickname
|
||||
settings.save()
|
||||
start(host, port, nickname, password, "", "", "")
|
||||
start(host, port, nickname, password, identityId, "", "")
|
||||
}
|
||||
|
||||
fun connect(bookmark: Bookmark) {
|
||||
val nickname = bookmark.nickname?.takeIf { it.isNotBlank() } ?: settings.nickname
|
||||
start(bookmark.address, bookmark.port, nickname, bookmark.password, bookmark.identityId,
|
||||
start(bookmark.address, bookmark.port, bookmark.nickname.orEmpty(), bookmark.password, bookmark.identityId,
|
||||
bookmark.channel, bookmark.channelPassword)
|
||||
}
|
||||
|
||||
@@ -214,9 +216,22 @@ class SessionController(private val context: Context) {
|
||||
|
||||
// ---- bookmarks ----
|
||||
|
||||
fun addBookmark(label: String, address: String, nickname: String, password: String) {
|
||||
fun addBookmark(label: String, address: String, nickname: String, password: String, identityId: String) {
|
||||
val (host, port) = parseAddress(address)
|
||||
bookmarkStore.add(Bookmark(label.ifBlank { address }, host, port, nickname, password))
|
||||
// Only a nickname other than the identity's is the server's own.
|
||||
val own = nickname.takeIf { it != nicknameFor(identityId) }.orEmpty()
|
||||
bookmarkStore.add(Bookmark(label.ifBlank { address }, host, port, own, password).also {
|
||||
it.identityId = identityId
|
||||
})
|
||||
saveBookmarks()
|
||||
}
|
||||
|
||||
/** Connects the bookmark with another identity; empty for the default one. */
|
||||
fun setBookmarkIdentity(bookmark: Bookmark, identityId: String) {
|
||||
val index = bookmarkStore.all().indexOf(bookmark)
|
||||
if (index < 0) return
|
||||
// A new entry, so the list the UI holds changes along with it.
|
||||
bookmarkStore.all()[index] = bookmark.copy().also { it.identityId = identityId }
|
||||
saveBookmarks()
|
||||
}
|
||||
|
||||
@@ -255,22 +270,15 @@ class SessionController(private val context: Context) {
|
||||
* @return how many contacts the file held and how many of them were new
|
||||
*/
|
||||
suspend fun importContacts(uri: Uri): Pair<Int, Int> = withContext(Dispatchers.IO) {
|
||||
// The database reader needs a file it can seek in, which a content URI is not.
|
||||
val copy = File.createTempFile("settings", ".db", context.cacheDir)
|
||||
try {
|
||||
context.contentResolver.openInputStream(uri)!!.use { input -> copy.outputStream().use { input.copyTo(it) } }
|
||||
val found = TeamSpeakSettingsDb.readContacts(copy)
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -11,7 +11,19 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
@@ -26,6 +38,7 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -33,6 +46,7 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.ts3client.android.IdentityInfo
|
||||
import com.ts3client.android.SessionController
|
||||
import com.ts3client.config.Bookmark
|
||||
|
||||
@@ -48,10 +62,13 @@ fun ConnectScreen(
|
||||
) {
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val bookmarks by vm.bookmarks.collectAsStateWithLifecycle()
|
||||
val identities by vm.identities.all.collectAsStateWithLifecycle()
|
||||
var address by rememberSaveable { mutableStateOf(vm.lastAddress) }
|
||||
var nickname by rememberSaveable { mutableStateOf(vm.nickname) }
|
||||
var identityId by rememberSaveable { mutableStateOf("") }
|
||||
var nickname by rememberSaveable { mutableStateOf(vm.nicknameFor("")) }
|
||||
var password by rememberSaveable { mutableStateOf("") }
|
||||
var naming by rememberSaveable { mutableStateOf(false) }
|
||||
var choosingFor by remember { mutableStateOf<Bookmark?>(null) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -80,12 +97,16 @@ fun ConnectScreen(
|
||||
label = { Text("Server password") }, singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password))
|
||||
IdentityField(identities, identityId) {
|
||||
identityId = it
|
||||
nickname = vm.nicknameFor(it)
|
||||
}
|
||||
if (state.status != "Not connected") {
|
||||
Text(state.status, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = { onConnect { vm.connect(address.trim(), nickname.trim(), password) } },
|
||||
onClick = { onConnect { vm.connect(address.trim(), nickname.trim(), password, identityId) } },
|
||||
enabled = address.isNotBlank() && nickname.isNotBlank(),
|
||||
) { Text("Connect") }
|
||||
TextButton(onClick = { naming = true }, enabled = address.isNotBlank()) { Text("Add bookmark") }
|
||||
@@ -98,7 +119,10 @@ fun ConnectScreen(
|
||||
color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
items(bookmarks) { bookmark -> BookmarkRow(bookmark, { onConnect { vm.connect(bookmark) } }, { vm.removeBookmark(bookmark) }) }
|
||||
items(bookmarks) { bookmark ->
|
||||
BookmarkRow(bookmark, identities, { onConnect { vm.connect(bookmark) } },
|
||||
onChooseIdentity = { choosingFor = bookmark }, onRemove = { vm.removeBookmark(bookmark) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,19 +135,92 @@ fun ConnectScreen(
|
||||
onDismiss = { naming = false },
|
||||
onConfirm = {
|
||||
naming = false
|
||||
vm.addBookmark(it, address.trim(), nickname.trim(), password)
|
||||
vm.addBookmark(it, address.trim(), nickname.trim(), password, identityId)
|
||||
},
|
||||
)
|
||||
}
|
||||
choosingFor?.let { bookmark ->
|
||||
IdentityDialog(identities, bookmark.identityId, onDismiss = { choosingFor = null }) {
|
||||
choosingFor = null
|
||||
vm.setBookmarkIdentity(bookmark, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The identities to connect with, the default one first as an empty id. */
|
||||
private fun identityChoices(identities: List<IdentityInfo>): List<Pair<String, String>> {
|
||||
val default = identities.firstOrNull { it.isDefault }?.name
|
||||
return listOf("" to (default?.let { "Default ($it)" } ?: "Default")) + identities.map { it.id to it.name }
|
||||
}
|
||||
|
||||
/** An id no longer among the identities connects with the default, and shows as such. */
|
||||
private fun shownId(identities: List<IdentityInfo>, id: String) = id.takeIf { i -> identities.any { it.id == i } } ?: ""
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun IdentityField(identities: List<IdentityInfo>, selected: String, onSelect: (String) -> Unit) {
|
||||
val choices = identityChoices(identities)
|
||||
val shown = shownId(identities, selected)
|
||||
var open by remember { mutableStateOf(false) }
|
||||
ExposedDropdownMenuBox(open, { open = it }) {
|
||||
OutlinedTextField(
|
||||
choices.first { it.first == shown }.second, {}, readOnly = true, singleLine = true,
|
||||
label = { Text("Identity") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(open) },
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
||||
)
|
||||
ExposedDropdownMenu(open, { open = false }) {
|
||||
for ((id, name) in choices) {
|
||||
DropdownMenuItem({ Text(name) }, onClick = {
|
||||
open = false
|
||||
onSelect(id)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BookmarkRow(bookmark: Bookmark, onConnect: () -> Unit, onRemove: () -> Unit) {
|
||||
private fun IdentityDialog(identities: List<IdentityInfo>, selected: String, onDismiss: () -> Unit, onSelect: (String) -> Unit) {
|
||||
val shown = shownId(identities, selected)
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Connect with") },
|
||||
text = {
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
for ((id, name) in identityChoices(identities)) Choice(name, id == shown) { onSelect(id) }
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BookmarkRow(
|
||||
bookmark: Bookmark,
|
||||
identities: List<IdentityInfo>,
|
||||
onConnect: () -> Unit,
|
||||
onChooseIdentity: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
val identity = identities.firstOrNull { it.id == bookmark.identityId }
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
ListItem(
|
||||
headlineContent = { Text(bookmark.displayName()) },
|
||||
supportingContent = { Text("${bookmark.address}:${bookmark.port}") },
|
||||
supportingContent = {
|
||||
Text("${bookmark.address}:${bookmark.port}" + (identity?.let { " · ${it.name}" } ?: ""))
|
||||
},
|
||||
leadingContent = { PackIcon("BOOKMARK_MANAGER", null) },
|
||||
trailingContent = { IconButton(onClick = onRemove) { PackIcon("DELETE", "Remove bookmark") } },
|
||||
trailingContent = {
|
||||
Box {
|
||||
IconButton(onClick = { menu = true }) { Icon(Icons.Default.MoreVert, "Bookmark options") }
|
||||
DropdownMenu(menu, { menu = false }) {
|
||||
DropdownMenuItem({ Text("Identity") }, onClick = { menu = false; onChooseIdentity() })
|
||||
DropdownMenuItem({ Text("Remove") }, onClick = { menu = false; onRemove() })
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.clickable(onClick = onConnect),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
package com.ts3client.android.ui
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.ts3client.android.IdentityInfo
|
||||
import com.ts3client.android.IdentityManager
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Beyond this a search takes years; TeamSpeak's own dialog stops there too. */
|
||||
private const val MAX_SECURITY_LEVEL = 40
|
||||
|
||||
/**
|
||||
* The identities a server knows this client by, as TeamSpeak's Tools → Identities
|
||||
* manages them, with each identity's own page.
|
||||
*/
|
||||
@Composable
|
||||
fun IdentitiesScreen(manager: IdentityManager, onBack: () -> Unit) {
|
||||
var editing by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
editing?.let { id -> return IdentityScreen(manager, id, onBack = { editing = null }) }
|
||||
|
||||
val identities by manager.all.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
var creating by remember { mutableStateOf(false) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var message by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
fun work(failure: String, block: suspend () -> String?) {
|
||||
busy = true
|
||||
scope.launch {
|
||||
message = try {
|
||||
block()
|
||||
} catch (e: Exception) {
|
||||
"$failure: ${e.message ?: e}"
|
||||
}
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
val fileImporter = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
if (uri != null) work("That file is not a TeamSpeak identity") {
|
||||
editing = manager.importFile(uri).id
|
||||
null
|
||||
}
|
||||
}
|
||||
val dbImporter = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
if (uri != null) work("That file is not a TeamSpeak 3 settings.db") {
|
||||
val r = manager.importFromTeamSpeak(uri)
|
||||
if (r.identities() == 0) "That file holds no identities."
|
||||
else "Imported ${r.identitiesAdded()} of ${r.identities()} identities."
|
||||
}
|
||||
}
|
||||
|
||||
SubScreen("Identities", onBack, actions = {
|
||||
IconButton(onClick = { creating = true }, enabled = !busy) { PackIcon("IDENTITY_ADD", "New identity") }
|
||||
Box {
|
||||
IconButton(onClick = { menu = true }) { Icon(Icons.Default.MoreVert, "More") }
|
||||
DropdownMenu(menu, { menu = false }) {
|
||||
DropdownMenuItem({ Text("Import identity file") }, onClick = {
|
||||
menu = false
|
||||
fileImporter.launch(arrayOf("*/*"))
|
||||
})
|
||||
DropdownMenuItem({ Text("Import from TeamSpeak 3") }, onClick = {
|
||||
menu = false
|
||||
dbImporter.launch(arrayOf("*/*"))
|
||||
})
|
||||
}
|
||||
}
|
||||
}) { padding ->
|
||||
LazyColumn(Modifier.padding(padding)) {
|
||||
if (busy) item { LinearProgressIndicator(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) }
|
||||
message?.let { item { Hint(it) } }
|
||||
if (identities.isEmpty()) {
|
||||
item {
|
||||
Hint("No identities. One is created on the first connect.")
|
||||
}
|
||||
}
|
||||
items(identities, key = { it.id }) { identity ->
|
||||
ListItem(
|
||||
headlineContent = { Text(identity.name, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||
supportingContent = {
|
||||
Text("Security level ${identity.securityLevel}", maxLines = 1)
|
||||
},
|
||||
trailingContent = if (identity.isDefault) ({ PackIcon("IDENTITY_DEFAULT", "Default") }) else null,
|
||||
modifier = Modifier.clickable { editing = identity.id },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (creating) {
|
||||
NewIdentityDialog(onDismiss = { creating = false }) { name ->
|
||||
creating = false
|
||||
work("Could not create the identity") {
|
||||
editing = manager.generate(name).id
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One identity: its name, whether it is the default, its security level and unique ID. */
|
||||
@Composable
|
||||
private fun IdentityScreen(manager: IdentityManager, id: String, onBack: () -> Unit) {
|
||||
val identities by manager.all.collectAsStateWithLifecycle()
|
||||
val improving by manager.improving.collectAsStateWithLifecycle()
|
||||
val identity = identities.firstOrNull { it.id == id }
|
||||
if (identity == null) {
|
||||
LaunchedEffect(Unit) { onBack() }
|
||||
return
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
var name by rememberSaveable(id) { mutableStateOf(identity.name) }
|
||||
var nickname by rememberSaveable(id) { mutableStateOf(identity.nickname) }
|
||||
var phonetic by rememberSaveable(id) { mutableStateOf(identity.phoneticNickname) }
|
||||
var removing by remember { mutableStateOf(false) }
|
||||
var raising by remember { mutableStateOf(false) }
|
||||
var message by remember { mutableStateOf<String?>(null) }
|
||||
val leave = {
|
||||
val trimmed = name.trim()
|
||||
if (trimmed.isNotEmpty() && trimmed != identity.name) manager.rename(id, trimmed)
|
||||
if (nickname.trim() != identity.nickname || phonetic.trim() != identity.phoneticNickname) {
|
||||
manager.setNicknames(id, nickname.trim(), phonetic.trim())
|
||||
}
|
||||
onBack()
|
||||
}
|
||||
val exporter = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri ->
|
||||
if (uri != null) scope.launch {
|
||||
message = runCatching { manager.export(id, uri) }.fold(
|
||||
{ "Exported." },
|
||||
{ "Could not export: ${it.message ?: it}" },
|
||||
)
|
||||
}
|
||||
}
|
||||
val mine = improving?.takeIf { it.id == id }
|
||||
|
||||
SubScreen(identity.name, leave, actions = {
|
||||
IconButton(onClick = { exporter.launch(fileName(name.ifBlank { identity.name })) }) {
|
||||
PackIcon("IDENTITY_EXPORT", "Export")
|
||||
}
|
||||
IconButton(onClick = { removing = true }) { PackIcon("DELETE", "Remove") }
|
||||
}) { padding ->
|
||||
Column(Modifier.padding(padding).imePadding().verticalScroll(rememberScrollState()).padding(vertical = 8.dp)) {
|
||||
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
label = { Text("Name") }, singleLine = true, isError = name.isBlank())
|
||||
OutlinedTextField(nickname, { nickname = it }, Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(top = 8.dp),
|
||||
label = { Text("Nickname") }, placeholder = { Text("Global nickname") }, singleLine = true)
|
||||
OutlinedTextField(phonetic, { phonetic = it }, Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
label = { Text("Phonetic nickname") }, singleLine = true)
|
||||
message?.let { Hint(it) }
|
||||
|
||||
Section("Use")
|
||||
if (identity.isDefault) {
|
||||
Hint("Default identity")
|
||||
} else {
|
||||
LinkRow("Make default", null, "IDENTITY_DEFAULT") {
|
||||
manager.setDefault(id)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Security level")
|
||||
if (mine != null) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Improving to ${mine.target}: reached ${mine.reached}") },
|
||||
supportingContent = { LinearProgressIndicator(Modifier.fillMaxWidth().padding(top = 8.dp)) },
|
||||
trailingContent = { TextButton(onClick = manager::stopImproving) { Text("Stop") } },
|
||||
)
|
||||
} else {
|
||||
LinkRow("Level ${identity.securityLevel}",
|
||||
if (improving != null) "Another identity is being improved" else "Improve",
|
||||
"SECURITY") { if (improving == null) raising = true }
|
||||
}
|
||||
|
||||
Section("Details")
|
||||
val context = LocalContext.current
|
||||
LinkRow("Unique ID", identity.uniqueId) {
|
||||
// The system confirms the copy itself.
|
||||
context.getSystemService(ClipboardManager::class.java)
|
||||
.setPrimaryClip(ClipData.newPlainText("Unique ID", identity.uniqueId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (raising) {
|
||||
LevelDialog(identity, onDismiss = { raising = false }) { target ->
|
||||
raising = false
|
||||
manager.improve(id, target)
|
||||
}
|
||||
}
|
||||
if (removing) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { removing = false },
|
||||
title = { Text("Remove ${identity.name}?") },
|
||||
text = {
|
||||
Text("Server groups and permissions tied to it are lost. Export it first to keep it.")
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
removing = false
|
||||
manager.remove(id)
|
||||
}) { Text("Remove") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { removing = false }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NewIdentityDialog(onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("New identity") },
|
||||
text = {
|
||||
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text("Name") }, singleLine = true)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onConfirm(name.trim()) }, enabled = name.isNotBlank()) { Text("Create") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LevelDialog(identity: IdentityInfo, onDismiss: () -> Unit, onConfirm: (Int) -> Unit) {
|
||||
var text by remember { mutableStateOf((identity.securityLevel + 1).toString()) }
|
||||
val level = text.trim().toIntOrNull()?.takeIf { it in identity.securityLevel + 1..MAX_SECURITY_LEVEL }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Improve security level") },
|
||||
text = {
|
||||
Column {
|
||||
Text("Current level: ${identity.securityLevel}", style = MaterialTheme.typography.bodyMedium)
|
||||
OutlinedTextField(text, { text = it }, Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
label = { Text("Target level") }, singleLine = true, isError = level == null,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { level?.let(onConfirm) }, enabled = level != null) { Text("Start") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
private fun fileName(name: String) = name.replace(Regex("[^\\w.-]+"), "_") + ".ini"
|
||||
@@ -21,7 +21,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private enum class SettingsPage { SOUND, MY_TEAMSPEAK }
|
||||
private enum class SettingsPage { SOUND, IDENTITIES, MY_TEAMSPEAK }
|
||||
|
||||
/**
|
||||
* The settings, by category. Every change is saved and put into effect at once,
|
||||
@@ -32,6 +32,7 @@ fun SettingsScreen(vm: SessionController, onBack: () -> Unit) {
|
||||
var page by rememberSaveable { mutableStateOf<SettingsPage?>(null) }
|
||||
when (page) {
|
||||
SettingsPage.SOUND -> return SoundSettingsScreen(vm, onBack = { page = null })
|
||||
SettingsPage.IDENTITIES -> return IdentitiesScreen(vm.identities, onBack = { page = null })
|
||||
SettingsPage.MY_TEAMSPEAK -> return MyTeamSpeakScreen(vm, onBack = { page = null })
|
||||
null -> {}
|
||||
}
|
||||
@@ -41,10 +42,12 @@ fun SettingsScreen(vm: SessionController, onBack: () -> Unit) {
|
||||
LinkRow("Sound Settings", "Devices, transmission, voice activation, processing, volume", "PLAYBACK") {
|
||||
page = SettingsPage.SOUND
|
||||
}
|
||||
LinkRow("Identities", "Create, import, export, security level",
|
||||
"IDENTITY_MANAGER") { page = SettingsPage.IDENTITIES }
|
||||
LinkRow("myTeamSpeak",
|
||||
if (vm.myTeamSpeak.isSignedIn) "Signed in as ${vm.myTeamSpeak.username()}"
|
||||
else "Sign in to bring over your synchronised bookmarks and identities",
|
||||
"IDENTITY_MANAGER") { page = SettingsPage.MY_TEAMSPEAK }
|
||||
"MYTS_ACCOUNT") { page = SettingsPage.MY_TEAMSPEAK }
|
||||
Section("Appearance")
|
||||
IconPackPicker(vm.icons)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user