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:
2026-09-26 01:07:17 +00:00
parent 73a15a8cfc
commit a7aa122728
25 changed files with 3425 additions and 32 deletions

View File

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

View File

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

View File

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