Sign in to myTeamSpeak and import what the account synchronises
The myTeamSpeak client protocol and its end-to-end encryption are reverse-engineered (docs/myteamspeak): an HTTP POST of a framed protobuf, a PBKDF2-HMAC-SHA512 login token and account key, an AES-GCM wrapped item key and AES-CTR items with a SHA-512 trailer. Options on desktop and Settings on Android gain a myTeamSpeak page: sign in and out, and the account's bookmarks and identities, each picked for import and marked when already here. The client stays signed in the way the official one does, keeping the derived token and key rather than the password; each read signs in afresh, and a sign-in the server no longer takes signs out. Nothing is written to the account. Items are the same Item_Data as the local TeamSpeak store, so they go through the existing decoder and importer; a bookmark chosen alone brings the identity it connects with, found by UUID or, as some name it, by the identity's name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,10 +12,14 @@ import com.ts3client.config.Bookmarks
|
||||
import com.ts3client.config.IdentityStore
|
||||
import com.ts3client.config.Settings
|
||||
import com.ts3client.contacts.ContactStore
|
||||
import com.ts3client.myts.MyTeamSpeak
|
||||
import com.ts3client.myts.MyTeamSpeakLogin
|
||||
import com.ts3client.net.ChannelNode
|
||||
import com.ts3client.net.ConnectionListener.ChatScope
|
||||
import com.ts3client.session.ServerSession
|
||||
import com.ts3client.sound.SoundNotifier
|
||||
import com.ts3client.teamspeak.SyncItem
|
||||
import com.ts3client.teamspeak.TeamSpeakImporter
|
||||
import com.ts3client.text.ChatHtml
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -27,6 +31,7 @@ import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
@@ -60,11 +65,12 @@ class SessionController(private val context: Context) {
|
||||
|
||||
val settings: Settings = Settings.load()
|
||||
private val bookmarkStore = Bookmarks.load()
|
||||
private val identities = IdentityStore.load(settings)
|
||||
private val audio = AndroidAudioBackend(context)
|
||||
private val soundPlayer = audio.createSoundPlayer(settings)
|
||||
private val sounds = SoundNotifier(settings).also { it.setPlayer(soundPlayer) }
|
||||
private val session = ServerSession(
|
||||
settings, IdentityStore.load(settings), audio, sounds, ContactStore.load(), Listener()
|
||||
settings, identities, audio, sounds, ContactStore.load(), Listener()
|
||||
)
|
||||
private val conn get() = session.connection()
|
||||
private val network = NetworkFollower(context, session, ::refresh, ::system)
|
||||
@@ -188,6 +194,24 @@ class SessionController(private val context: Context) {
|
||||
_bookmarks.value = bookmarkStore.all().toList()
|
||||
}
|
||||
|
||||
// ---- 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)
|
||||
|
||||
suspend fun signInMyTeamSpeak(email: String, password: String): MyTeamSpeak.Account =
|
||||
withContext(Dispatchers.IO) { myTeamSpeak.signIn(email, password) }
|
||||
|
||||
suspend fun fetchMyTeamSpeak(): MyTeamSpeak.Account = withContext(Dispatchers.IO) { myTeamSpeak.fetch() }
|
||||
|
||||
fun isImported(item: SyncItem): Boolean = importer.isPresent(item)
|
||||
|
||||
/** 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() }
|
||||
|
||||
// ---- the local client ----
|
||||
|
||||
fun hasMicPermission() =
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
package com.ts3client.android.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
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.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
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.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ts3client.android.SessionController
|
||||
import com.ts3client.myts.MyTeamSpeak
|
||||
import com.ts3client.myts.MyTsException
|
||||
import com.ts3client.teamspeak.SyncItem
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
|
||||
private data class ItemRow(val item: SyncItem, val imported: Boolean, val selected: Boolean)
|
||||
|
||||
/**
|
||||
* The myTeamSpeak account: sign in and out, and pick what the account synchronises to
|
||||
* import. Signing in lasts until signing out; the password itself is not kept.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MyTeamSpeakScreen(vm: SessionController, onBack: () -> Unit, onImported: () -> Unit) {
|
||||
val login = vm.myTeamSpeak
|
||||
val scope = rememberCoroutineScope()
|
||||
var signedIn by remember { mutableStateOf(login.isSignedIn) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var message by remember { mutableStateOf<String?>(null) }
|
||||
var error by remember { mutableStateOf(false) }
|
||||
var email by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var all by remember { mutableStateOf(emptyList<SyncItem>()) }
|
||||
var rows by remember { mutableStateOf(emptyList<ItemRow>()) }
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
fun show(account: MyTeamSpeak.Account) {
|
||||
all = account.items()
|
||||
rows = all.filter { it !is SyncItem.Folder }.map {
|
||||
val imported = vm.isImported(it)
|
||||
ItemRow(it, imported, !imported)
|
||||
}
|
||||
message = if (rows.isEmpty()) "The account holds no bookmarks or identities." else null
|
||||
error = false
|
||||
}
|
||||
|
||||
fun work(block: suspend () -> Unit) {
|
||||
busy = true
|
||||
message = null
|
||||
scope.launch {
|
||||
try {
|
||||
block()
|
||||
} catch (e: MyTsException) {
|
||||
message = e.message
|
||||
error = true
|
||||
} catch (e: IOException) {
|
||||
message = "Could not reach myTeamSpeak: ${e.message}"
|
||||
error = true
|
||||
}
|
||||
// A sign-in the server no longer takes has signed out.
|
||||
signedIn = login.isSignedIn
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (signedIn) work { show(vm.fetchMyTeamSpeak()) }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("myTeamSpeak") },
|
||||
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } },
|
||||
actions = {
|
||||
if (signedIn) {
|
||||
IconButton(onClick = { work { show(vm.fetchMyTeamSpeak()) } }, enabled = !busy) {
|
||||
Icon(Icons.Filled.Refresh, "Refresh")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
Modifier.padding(padding).imePadding(),
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (busy) item { LinearProgressIndicator(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) }
|
||||
if (!signedIn) {
|
||||
item {
|
||||
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Sign in to bring over the bookmarks and identities your account synchronises. " +
|
||||
"The app stays signed in; your password itself is not kept.")
|
||||
OutlinedTextField(email, { email = it }, Modifier.fillMaxWidth(), label = { Text("Email") },
|
||||
singleLine = true, enabled = !busy,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email))
|
||||
OutlinedTextField(password, { password = it }, Modifier.fillMaxWidth(), label = { Text("Password") },
|
||||
singleLine = true, enabled = !busy, visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password))
|
||||
Button(
|
||||
onClick = {
|
||||
work {
|
||||
show(vm.signInMyTeamSpeak(email.trim(), password))
|
||||
password = ""
|
||||
}
|
||||
},
|
||||
enabled = !busy && email.isNotBlank() && password.isNotEmpty(),
|
||||
) { Text("Sign in") }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
Row(Modifier.padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Signed in as ${login.username()}", style = MaterialTheme.typography.titleMedium)
|
||||
Text(login.email(), style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
OutlinedButton(onClick = {
|
||||
login.signOut()
|
||||
signedIn = false
|
||||
rows = emptyList()
|
||||
all = emptyList()
|
||||
message = null
|
||||
}) { Text("Sign out") }
|
||||
}
|
||||
}
|
||||
if (rows.isNotEmpty()) {
|
||||
item {
|
||||
Text("Synchronised by the account", Modifier.padding(start = 16.dp, top = 16.dp),
|
||||
style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
items(rows, key = { it.item.uuid() }) { row ->
|
||||
val toggle = { rows = rows.map { if (it === row) it.copy(selected = !it.selected) else it } }
|
||||
ListItem(
|
||||
headlineContent = { Text(name(row.item), maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||
supportingContent = { Text(details(row.item), maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||
leadingContent = { Checkbox(row.selected, { toggle() }) },
|
||||
trailingContent = if (row.imported) ({ Text("Imported", style = MaterialTheme.typography.labelMedium) }) else null,
|
||||
modifier = Modifier.clickable(onClick = toggle),
|
||||
)
|
||||
}
|
||||
if (rows.isNotEmpty()) {
|
||||
item {
|
||||
Button(
|
||||
onClick = {
|
||||
work {
|
||||
val r = vm.importMyTeamSpeak(all, rows.filter { it.selected }.map { it.item })
|
||||
rows = rows.map { it.copy(imported = vm.isImported(it.item), selected = false) }
|
||||
message = "Imported ${count(r.identitiesAdded(), "identity", "identities")} and " +
|
||||
"${count(r.bookmarksAdded(), "bookmark", "bookmarks")}."
|
||||
onImported()
|
||||
}
|
||||
},
|
||||
enabled = !busy && rows.any { it.selected },
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
) { Text("Import selected") }
|
||||
}
|
||||
}
|
||||
}
|
||||
message?.let { text ->
|
||||
item {
|
||||
Text(text, Modifier.padding(horizontal = 16.dp),
|
||||
color = if (error) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun name(item: SyncItem) = when (item) {
|
||||
is SyncItem.Bookmark -> item.name().ifBlank { item.address() }
|
||||
is SyncItem.Identity -> "Identity: ${item.name()}"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
private fun details(item: SyncItem) = when (item) {
|
||||
is SyncItem.Bookmark -> "${item.address()}:${item.port()}"
|
||||
is SyncItem.Identity -> item.nickname()
|
||||
else -> ""
|
||||
}
|
||||
|
||||
private fun count(n: Int, one: String, many: String) = "$n ${if (n == 1) one else many}"
|
||||
@@ -31,6 +31,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -78,6 +79,12 @@ fun SettingsScreen(vm: SessionController, onBack: () -> Unit) {
|
||||
vm.watchLevel(true)
|
||||
onStopOrDispose { vm.watchLevel(false) }
|
||||
}
|
||||
var myTeamSpeak by rememberSaveable { mutableStateOf(false) }
|
||||
if (myTeamSpeak) {
|
||||
// The default identity's nickname may come along with an import.
|
||||
MyTeamSpeakScreen(vm, onBack = { myTeamSpeak = false }, onImported = { nickname = settings.nickname })
|
||||
return
|
||||
}
|
||||
val leave = {
|
||||
if (nickname.isNotBlank() && nickname.trim() != settings.nickname) change { this.nickname = nickname.trim() }
|
||||
onBack()
|
||||
@@ -178,6 +185,14 @@ fun SettingsScreen(vm: SessionController, onBack: () -> Unit) {
|
||||
Level("Volume", settings.outputVolume.toFloat(), 0f..2f, { "${(it * 100).roundToInt()} %" }) {
|
||||
change { outputVolume = it.toDouble() }
|
||||
}
|
||||
|
||||
Section("Account")
|
||||
Column(Modifier.fillMaxWidth().clickable { myTeamSpeak = true }.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Text("myTeamSpeak")
|
||||
Text(if (vm.myTeamSpeak.isSignedIn) "Signed in as ${vm.myTeamSpeak.username()}"
|
||||
else "Sign in to bring over your synchronised bookmarks and identities",
|
||||
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user