wip: wiring network client

This commit is contained in:
2026-05-29 00:33:12 +02:00
parent c68787cd01
commit e4405c8b9d
12 changed files with 132 additions and 40 deletions
+7
View File
@@ -20,6 +20,8 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.compose)
// necessary for using T.serializer() on a data class
alias(libs.plugins.kotlin.serialization)
}
android {
@@ -96,6 +98,7 @@ dependencies {
implementation(libs.androidx.glance.material3)
implementation(libs.kotlin.stdlib)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json)
implementation(libs.androidx.activity.compose)
@@ -115,6 +118,10 @@ dependencies {
implementation(libs.androidx.compose.ui.viewbinding)
implementation(libs.androidx.compose.ui.googlefonts)
implementation(libs.ktor.client.auth)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
debugImplementation(libs.androidx.compose.ui.test.manifest)
androidTestImplementation(libs.junit)
+2
View File
@@ -9,6 +9,8 @@
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.provider.Telephony.SMS_RECEIVED" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
@@ -32,19 +32,24 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.viewinterop.AndroidViewBinding
import androidx.core.os.bundleOf
import androidx.core.view.ViewCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavController
import androidx.navigation.fragment.NavHostFragment
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
import xyz.magicalbits.smsremote.components.JetchatDrawer
import xyz.magicalbits.smsremote.databinding.ContentMainBinding
import xyz.magicalbits.smsremote.network.NetworkClient
/**
* Main activity for the app.
*/
class NavActivity : AppCompatActivity() {
private val viewModel: MainViewModel by viewModels()
@OptIn(ExperimentalMaterial3Api::class)
@@ -53,6 +58,12 @@ class NavActivity : AppCompatActivity() {
super.onCreate(savedInstanceState)
ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { _, insets -> insets }
var deviceDtoList: List<NetworkClient.DeviceDto> = listOf()
viewModel.viewModelScope.launch {
val networkClient = NetworkClient()
deviceDtoList = networkClient.getDevices()
}
setContentView(
ComposeView(this).apply {
consumeWindowInsets = false
@@ -79,15 +90,18 @@ class NavActivity : AppCompatActivity() {
JetchatDrawer(
drawerState = drawerState,
selectedMenu = selectedMenu,
deviceDtoList = deviceDtoList,
onChatClicked = {
findNavController().popBackStack(R.id.nav_device, false)
val args = Bundle(1)
args.putString("deviceName", it)
args.putString("deviceId", it.access_key)
args.putString("name", it.name)
args.putString("type", it.type)
findNavController().navigate(R.id.nav_device, args)
scope.launch {
drawerState.close()
}
selectedMenu = it
selectedMenu = it.access_key
},
) {
AndroidViewBinding(ContentMainBinding::inflate)
@@ -56,11 +56,12 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import xyz.magicalbits.smsremote.R
import xyz.magicalbits.smsremote.network.NetworkClient
import xyz.magicalbits.smsremote.theme.JetchatTheme
import xyz.magicalbits.smsremote.widget.WidgetReceiver
@Composable
fun JetchatDrawerContent(onChatClicked: (String) -> Unit, selectedMenu: String = "iPhone XYZ") {
fun JetchatDrawerContent(onChatClicked: (NetworkClient.DeviceDto) -> Unit, selectedMenu: String = "", deviceDtoList: List<NetworkClient.DeviceDto> = listOf()) {
// Use windowInsetsTopHeight() to add a spacer which pushes the drawer content
// below the status bar (y-axis)
Column {
@@ -68,12 +69,13 @@ fun JetchatDrawerContent(onChatClicked: (String) -> Unit, selectedMenu: String =
DrawerHeader()
DividerItem()
DrawerItemHeader("Devices")
DeviceItem("Samsung A14", selectedMenu == "Samsung A14") {
onChatClicked("Samsung A14")
}
DeviceItem("iPhone XYZ", selectedMenu == "iPhone XYZ") {
onChatClicked("iPhone XYZ")
for (deviceDto in deviceDtoList) {
DeviceItem(deviceDto.name, selectedMenu == deviceDto.access_key) {
onChatClicked(deviceDto)
}
}
// DividerItem(modifier = Modifier.padding(horizontal = 28.dp))
if (widgetAddingIsSupported(LocalContext.current)) {
DividerItem(modifier = Modifier.padding(horizontal = 28.dp))
@@ -23,13 +23,15 @@ import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import xyz.magicalbits.smsremote.network.NetworkClient
import xyz.magicalbits.smsremote.theme.JetchatTheme
@Composable
fun JetchatDrawer(
drawerState: DrawerState = rememberDrawerState(initialValue = Closed),
selectedMenu: String,
onChatClicked: (String) -> Unit,
deviceDtoList: List<NetworkClient.DeviceDto>,
onChatClicked: (NetworkClient.DeviceDto) -> Unit,
content: @Composable () -> Unit,
) {
JetchatTheme {
@@ -44,6 +46,7 @@ fun JetchatDrawer(
JetchatDrawerContent(
onChatClicked = onChatClicked,
selectedMenu = selectedMenu,
deviceDtoList = deviceDtoList,
)
}
},
@@ -87,17 +87,3 @@ val meProfile =
timeZone = "In your timezone",
commonChannels = null,
)
val a14Device =
DeviceScreenState(
deviceId = "012345",
name = "Samsung A14",
phoneNumbers = listOf("+420123456789", "+420777444111")
)
val iPhoneDevice =
DeviceScreenState(
deviceId = "012345",
name = "iPhone XYZ",
phoneNumbers = listOf("+15558881111")
)
@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
@@ -24,7 +23,6 @@ import xyz.magicalbits.smsremote.components.JetchatAppBar
import xyz.magicalbits.smsremote.theme.JetchatTheme
import kotlin.getValue
import androidx.navigation.findNavController
import kotlinx.coroutines.launch
class DeviceFragment : Fragment() {
private val viewModel: DeviceViewModel by viewModels()
@@ -33,8 +31,10 @@ class DeviceFragment : Fragment() {
override fun onAttach(context: Context) {
super.onAttach(context)
// Consider using safe args plugin
val deviceName = arguments?.getString("deviceName")
viewModel.setDeviceId(deviceName)
val deviceId = arguments?.getString("deviceId")
val name = arguments?.getString("name")
val type = arguments?.getString("type")
viewModel.setDeviceData(deviceId, name, type)
}
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
@@ -65,6 +65,7 @@ class DeviceFragment : Fragment() {
JetchatTheme {
if (deviceData == null) {
println("calling device error")
DeviceError()
} else {
val navController: NavController = rootView.findNavController()
@@ -4,26 +4,39 @@ import androidx.compose.runtime.Immutable
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import xyz.magicalbits.smsremote.data.a14Device
import xyz.magicalbits.smsremote.data.iPhoneDevice
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import xyz.magicalbits.smsremote.network.NetworkClient
class DeviceViewModel : ViewModel() {
private var deviceId: String = ""
private val _deviceData = MutableLiveData<DeviceScreenState>()
val deviceData: LiveData<DeviceScreenState> = _deviceData
fun setDeviceId(newDeviceId: String?) {
if (newDeviceId != null) {
fun setDeviceData(newDeviceId: String?, name: String?, type: String?) {
if (newDeviceId != null && name != null) {
deviceId = newDeviceId
}
// placeholder since there's no API reading logic yet
_deviceData.value =
if (deviceId == "Samsung A14") {
a14Device
} else {
iPhoneDevice
var simCardDtoList: List<NetworkClient.SimCardDto> = listOf()
viewModelScope.launch {
val networkClient = NetworkClient()
simCardDtoList = networkClient.getSimCardsByAccessKey(deviceId)
println("sims: $simCardDtoList")
}.invokeOnCompletion {
// FIXME waiting for the response causes brief moment of DeviceError() before _deviceData is updated ...
// a solution: caching SIM phone numbers of all discovered devices locally on startup and updating them
// only on startup (implicit behavior) or with a pull-down refresh action (not done yet)
// placeholder since there's no API reading logic yet
_deviceData.value =
DeviceScreenState(
deviceId = deviceId,
name = name,
phoneNumbers = simCardDtoList.map { it.phone_number },
)
println("sims live: ${_deviceData.value!!.phoneNumbers}")
}
}
}
}
@@ -0,0 +1,54 @@
package xyz.magicalbits.smsremote.network
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
class NetworkClient {
// TODO make apiBaseUrl configurable with fallback to my real domain
private val apiBaseUrl = "http://192.168.1.116:5000"
// TODO ask user for creds, call login endpoint, store tokens in local encrypted storage
private val networkClient = HttpClient(CIO) {
install(Auth) {
bearer {
// TODO configure refresh token so it refreshes the access token
loadTokens {
// TODO load from local encrypted storage
BearerTokens(
"test",
"test"
)
}
}
}
}
@Serializable()
data class DeviceDto(val access_key: String, val name: String, val type: String)
@Serializable
data class SimCardDto(val device_access_key: String, val phone_number: String)
// GET /api/v1/devices
suspend fun getDevices(): List<DeviceDto> {
// TODO handle non-200 status codes
// TODO handle '{"msg":"Token has expired"}' messages
val data = networkClient.get("$apiBaseUrl/api/v1/devices").bodyAsText()
return Json.decodeFromString(data)
}
// GET /api/v1/sim-cards
suspend fun getSimCardsByAccessKey(accessKey: String): List<SimCardDto> {
// TODO handle non-200 status codes
val data = networkClient.get("$apiBaseUrl/api/v1/sim-cards?access_key=$accessKey").bodyAsText()
return Json.decodeFromString(data)
}
}
@@ -43,6 +43,12 @@
<argument
android:name="deviceId"
app:argType="string" />
<argument
android:name="name"
app:argType="string" />
<argument
android:name="type"
app:argType="string" />
<action
android:id="@+id/action_device_to_conversation"
app:destination="@id/nav_conversation"