forked from Lonami/Talaria
Compare commits
32 Commits
2fa-suppor
...
master
Author | SHA1 | Date |
---|---|---|
Lonami Exo | 49fa3c4d5a | |
Lonami Exo | d350d1a048 | |
Lonami Exo | 43421f6e54 | |
Lonami Exo | 912949a079 | |
Lonami Exo | 1574ce683c | |
Lonami Exo | d11a00d062 | |
Lonami Exo | 004a921299 | |
Lonami Exo | 1a56b03614 | |
Lonami Exo | 812597f027 | |
Lonami Exo | fd1dac1045 | |
Lonami Exo | b5dddcef02 | |
Lonami Exo | ea53d3cb1d | |
Lonami Exo | da136a1990 | |
Lonami Exo | 35ed8e12c6 | |
Lonami Exo | 0db1599cf7 | |
Lonami Exo | 50a2403a85 | |
Lonami Exo | 07d0e5e505 | |
Lonami Exo | 442b019134 | |
Lonami Exo | 51ef704cf7 | |
Lonami Exo | 17d7805654 | |
Lonami Exo | 6c652e3e1b | |
Lonami Exo | 999c59e9ec | |
Lonami Exo | c627db973c | |
Lonami Exo | fc7dc68b62 | |
Lonami Exo | 189c1e8db8 | |
Lonami Exo | 7bfbcc955c | |
Lonami Exo | 0f412f7334 | |
Lonami Exo | a341466749 | |
Lonami Exo | cd37c5aa14 | |
Lonami Exo | 0919c4a13c | |
Lonami Exo | 5e1f253dd3 | |
Lonami Exo | f2cd0eb69a |
|
@ -1,6 +1,7 @@
|
|||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<JetCodeStyleSettings>
|
||||
<option name="ALLOW_TRAILING_COMMA" value="true" />
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="XML">
|
||||
|
|
|
@ -10,7 +10,7 @@ android {
|
|||
|
||||
defaultConfig {
|
||||
applicationId "dev.lonami.talaria"
|
||||
minSdk 21
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 1
|
||||
versionName "0.1.0"
|
||||
|
@ -57,6 +57,7 @@ dependencies {
|
|||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
|
||||
implementation 'androidx.compose.material:material:1.2.1'
|
||||
implementation "androidx.navigation:navigation-compose:2.5.2"
|
||||
implementation "net.java.dev.jna:jna:5.12.0@aar"
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
||||
|
@ -68,8 +69,8 @@ dependencies {
|
|||
// See https://github.com/mozilla/rust-android-gradle for other targets and required toolchains
|
||||
cargo {
|
||||
module = "../native"
|
||||
libname = "talaria"
|
||||
targets = ["arm64", "arm", "x86"]
|
||||
libname = "uniffi_talaria"
|
||||
targets = ["arm64"]
|
||||
profile = 'release'
|
||||
}
|
||||
|
||||
|
@ -78,3 +79,14 @@ tasks.whenTaskAdded { task ->
|
|||
task.dependsOn 'cargoBuild'
|
||||
}
|
||||
}
|
||||
|
||||
// See https://mozilla.github.io/uniffi-rs/kotlin/gradle.html for more details on this snippet
|
||||
android.applicationVariants.all { variant ->
|
||||
def t = tasks.register("generate${variant.name.capitalize()}UniFFIBindings", Exec) {
|
||||
workingDir "${project.projectDir}"
|
||||
commandLine 'uniffi-bindgen', 'generate', '../native/src/talaria.udl', '--language', 'kotlin', '--no-format', '--out-dir', "${buildDir}/generated/source/uniffi/${variant.name}/java"
|
||||
}
|
||||
variant.javaCompileProvider.get().dependsOn(t)
|
||||
def sourceSet = variant.sourceSets.find { it.name == variant.name }
|
||||
sourceSet.java.srcDir new File(buildDir, "generated/source/uniffi/${variant.name}/java")
|
||||
}
|
||||
|
|
|
@ -7,15 +7,15 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import dev.lonami.talaria.bindings.Native
|
||||
import dev.lonami.talaria.ui.theme.TalariaTheme
|
||||
import uniffi.talaria.initClient
|
||||
import uniffi.talaria.initDatabase
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
TalariaTheme {
|
||||
// A surface container using the 'background' color from the theme
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colors.background
|
||||
|
@ -25,6 +25,7 @@ class MainActivity : ComponentActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
Native.initClient()
|
||||
initDatabase(getDatabasePath("talaria.db").path)
|
||||
initClient()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,20 +1,29 @@
|
|||
package dev.lonami.talaria
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import dev.lonami.talaria.ui.ChatScreen
|
||||
import dev.lonami.talaria.ui.DialogScreen
|
||||
import dev.lonami.talaria.ui.LoginScreen
|
||||
import dev.lonami.talaria.ui.screens.ChatScreen
|
||||
import dev.lonami.talaria.ui.screens.DialogScreen
|
||||
import dev.lonami.talaria.ui.screens.LoginScreen
|
||||
import dev.lonami.talaria.ui.state.ChatViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import uniffi.talaria.needLogin
|
||||
|
||||
enum class TalariaScreen(@StringRes val title: Int) {
|
||||
Login(title = R.string.app_name),
|
||||
|
@ -22,9 +31,96 @@ enum class TalariaScreen(@StringRes val title: Int) {
|
|||
Chat(title = R.string.chat),
|
||||
}
|
||||
|
||||
enum class DrawerAction {
|
||||
SelectAccount,
|
||||
AddAccount,
|
||||
SavedMessages,
|
||||
Contacts,
|
||||
NewChat,
|
||||
Settings,
|
||||
Help,
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TalariaAppBar(currentScreen: TalariaScreen, canNavigateBack: Boolean, navigateUp: () -> Unit) {
|
||||
fun DrawerAction(
|
||||
icon: ImageVector,
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clickable(onClick = onClick)
|
||||
.fillMaxWidth()
|
||||
.padding(0.dp, 8.dp)
|
||||
) {
|
||||
Icon(imageVector = icon, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Drawer(onSelect: (DrawerAction) -> Unit, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp, 48.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||
contentDescription = "App Icon"
|
||||
)
|
||||
DrawerAction(
|
||||
icon = Icons.Filled.AccountBox,
|
||||
text = "Main Account",
|
||||
onClick = { onSelect(DrawerAction.SelectAccount) }
|
||||
)
|
||||
DrawerAction(
|
||||
icon = Icons.Filled.Add,
|
||||
text = "Add Account",
|
||||
onClick = { onSelect(DrawerAction.AddAccount) }
|
||||
)
|
||||
Divider()
|
||||
DrawerAction(
|
||||
icon = Icons.Filled.Star,
|
||||
text = "Saved Messages",
|
||||
onClick = { onSelect(DrawerAction.SavedMessages) }
|
||||
)
|
||||
DrawerAction(
|
||||
icon = Icons.Filled.Call,
|
||||
text = "Contacts",
|
||||
onClick = { onSelect(DrawerAction.Contacts) }
|
||||
)
|
||||
DrawerAction(
|
||||
icon = Icons.Filled.Create,
|
||||
text = "New Chat",
|
||||
onClick = { onSelect(DrawerAction.NewChat) }
|
||||
)
|
||||
Divider()
|
||||
DrawerAction(
|
||||
icon = Icons.Filled.Settings,
|
||||
text = "Settings",
|
||||
onClick = { onSelect(DrawerAction.Settings) }
|
||||
)
|
||||
DrawerAction(
|
||||
icon = Icons.Filled.Info,
|
||||
text = "Help",
|
||||
onClick = { onSelect(DrawerAction.Help) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TalariaAppBar(
|
||||
currentScreen: TalariaScreen,
|
||||
canNavigateBack: Boolean,
|
||||
navigateUp: () -> Unit,
|
||||
openDrawer: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
TopAppBar(
|
||||
modifier = modifier,
|
||||
title = { Text(stringResource(currentScreen.title)) },
|
||||
navigationIcon = {
|
||||
if (canNavigateBack) {
|
||||
|
@ -34,42 +130,78 @@ fun TalariaAppBar(currentScreen: TalariaScreen, canNavigateBack: Boolean, naviga
|
|||
contentDescription = stringResource(R.string.back_button)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = openDrawer) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Menu,
|
||||
contentDescription = stringResource(androidx.compose.ui.R.string.navigation_menu)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TalariaApp() {
|
||||
fun TalariaApp(modifier: Modifier = Modifier) {
|
||||
val navController = rememberNavController()
|
||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentScreen =
|
||||
TalariaScreen.valueOf(backStackEntry?.destination?.route ?: TalariaScreen.Login.name)
|
||||
|
||||
val loggedIn by remember { mutableStateOf(!needLogin()) }
|
||||
var selectedDialog by remember { mutableStateOf("") }
|
||||
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
val toggleDrawer = { open: Boolean ->
|
||||
scope.launch {
|
||||
if (open) {
|
||||
drawerState.open()
|
||||
} else {
|
||||
drawerState.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val chatViewModel = remember { ChatViewModel() }
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TalariaAppBar(
|
||||
currentScreen,
|
||||
canNavigateBack = navController.previousBackStackEntry != null,
|
||||
navigateUp = { navController.navigateUp() }
|
||||
navigateUp = { navController.navigateUp() },
|
||||
openDrawer = { toggleDrawer(drawerState.isClosed) }
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
ModalDrawer(
|
||||
drawerState = drawerState,
|
||||
gesturesEnabled = drawerState.isOpen,
|
||||
drawerContent = {
|
||||
Drawer(onSelect = {})
|
||||
}) {
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = TalariaScreen.Login.name,
|
||||
startDestination = if (loggedIn) {
|
||||
TalariaScreen.Login.name
|
||||
} else {
|
||||
TalariaScreen.Dialog.name
|
||||
},
|
||||
Modifier.padding(innerPadding)
|
||||
) {
|
||||
composable(route = TalariaScreen.Dialog.name) {
|
||||
DialogScreen(onDialogSelected = {
|
||||
selectedDialog = it
|
||||
chatViewModel.loadMessages(it)
|
||||
navController.navigate(TalariaScreen.Chat.name)
|
||||
})
|
||||
}
|
||||
composable(route = TalariaScreen.Chat.name) {
|
||||
ChatScreen(selectedDialog)
|
||||
ChatScreen(selectedDialog, chatViewModel = chatViewModel)
|
||||
}
|
||||
composable(route = TalariaScreen.Login.name) {
|
||||
LoginScreen(onConfirmOtp = {
|
||||
|
@ -79,3 +211,23 @@ fun TalariaApp() {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun TopBarPreview() {
|
||||
TalariaAppBar(
|
||||
currentScreen = TalariaScreen.Dialog,
|
||||
canNavigateBack = false,
|
||||
navigateUp = {},
|
||||
openDrawer = {},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun NavDrawerPreview() {
|
||||
Surface {
|
||||
Drawer(onSelect = {})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,18 +0,0 @@
|
|||
package dev.lonami.talaria.bindings
|
||||
|
||||
object Native {
|
||||
init {
|
||||
System.loadLibrary("talaria")
|
||||
}
|
||||
|
||||
external fun initClient()
|
||||
external fun needLogin(): Boolean
|
||||
external fun requestLoginCode(phone: String): Long
|
||||
external fun signIn(tokenPtr: Long, code: String)
|
||||
external fun getDialogs(): Long
|
||||
external fun dialogCount(dialogsPtr: Long): Int
|
||||
external fun dialogPacked(dialogsPtr: Long, index: Int): String
|
||||
external fun dialogTitle(dialogsPtr: Long, index: Int): String
|
||||
external fun freeDialogs(dialogsPtr: Long)
|
||||
external fun sendMessage(packed: String, text: String)
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
package dev.lonami.talaria.data
|
||||
|
||||
import uniffi.talaria.Dialog
|
||||
import uniffi.talaria.MessageAck
|
||||
import uniffi.talaria.MessagePreview
|
||||
import uniffi.talaria.getDialogs
|
||||
import java.time.Instant
|
||||
|
||||
interface DialogRepository {
|
||||
fun loadDialogs(): List<Dialog>
|
||||
}
|
||||
|
||||
class NativeDialogRepository : DialogRepository {
|
||||
override fun loadDialogs(): List<Dialog> {
|
||||
return getDialogs()
|
||||
}
|
||||
}
|
||||
|
||||
class MockDialogRepository : DialogRepository {
|
||||
override fun loadDialogs(): List<Dialog> {
|
||||
val dialogs = mutableListOf<Dialog>()
|
||||
for (i in 0 until 10) {
|
||||
dialogs.add(
|
||||
Dialog(
|
||||
id = "$i",
|
||||
title = "Sample Dialog $i",
|
||||
lastMessage = if (i % 4 == 3) {
|
||||
null
|
||||
} else {
|
||||
MessagePreview(
|
||||
sender = if (i % 2 == 0) {
|
||||
"Sender A"
|
||||
} else {
|
||||
"Sender B"
|
||||
},
|
||||
text = if (i % 3 == 2) {
|
||||
"Very Long Sample Message $i, with a Lot of Text, which makes it hard to Preview"
|
||||
} else {
|
||||
"Sample Message $i"
|
||||
},
|
||||
date = Instant.now(),
|
||||
ack = when (i % 3) {
|
||||
0 -> MessageAck.RECEIVED
|
||||
1 -> MessageAck.SENT
|
||||
2 -> MessageAck.SEEN
|
||||
else -> throw RuntimeException()
|
||||
}
|
||||
)
|
||||
},
|
||||
pinned = i < 4
|
||||
)
|
||||
)
|
||||
}
|
||||
return dialogs
|
||||
}
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
package dev.lonami.talaria.data
|
||||
|
||||
import dev.lonami.talaria.bindings.Native
|
||||
import dev.lonami.talaria.model.Dialog
|
||||
|
||||
object DialogSource {
|
||||
fun loadDialogs(): List<Dialog> {
|
||||
val dialogs = mutableListOf<Dialog>()
|
||||
|
||||
val dialogPtr = Native.getDialogs()
|
||||
val dialogCount = Native.dialogCount(dialogPtr)
|
||||
for (i in 0 until dialogCount) {
|
||||
dialogs.add(
|
||||
Dialog(
|
||||
Native.dialogTitle(dialogPtr, i),
|
||||
Native.dialogPacked(dialogPtr, i),
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
Native.freeDialogs(dialogPtr)
|
||||
|
||||
return dialogs
|
||||
}
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
package dev.lonami.talaria.data
|
||||
|
||||
import uniffi.talaria.Message
|
||||
import uniffi.talaria.getMessages
|
||||
import java.time.Instant
|
||||
|
||||
interface MessageRepository {
|
||||
fun loadMessages(chat: String): List<Message>
|
||||
fun sendMessage(chat: String, message: String): Message
|
||||
}
|
||||
|
||||
class NativeMessageRepository : MessageRepository {
|
||||
override fun loadMessages(chat: String): List<Message> {
|
||||
return getMessages(chat)
|
||||
}
|
||||
|
||||
override fun sendMessage(chat: String, message: String): Message {
|
||||
return uniffi.talaria.sendMessage(chat, message)
|
||||
}
|
||||
}
|
||||
|
||||
class MockMessageRepository(private var msgCounter: Int = 50) : MessageRepository {
|
||||
override fun loadMessages(chat: String): List<Message> {
|
||||
return (0 until 50).map {
|
||||
Message(
|
||||
id = it,
|
||||
sender = "Alice",
|
||||
text = "Testing",
|
||||
date = Instant.now(),
|
||||
editDate = null,
|
||||
formatting = listOf(),
|
||||
)
|
||||
}.toList()
|
||||
}
|
||||
|
||||
override fun sendMessage(chat: String, message: String): Message {
|
||||
return Message(
|
||||
id = msgCounter++,
|
||||
sender = "You",
|
||||
text = message,
|
||||
date = Instant.now(),
|
||||
editDate = null,
|
||||
formatting = listOf(),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
package dev.lonami.talaria.data
|
||||
|
||||
import dev.lonami.talaria.model.Message
|
||||
|
||||
object MessageSource {
|
||||
fun loadMessages(): List<Message> {
|
||||
return generateSequence {
|
||||
Message("Alice", "Testing")
|
||||
}.take(50).toList()
|
||||
}
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
package dev.lonami.talaria.model
|
||||
|
||||
data class Dialog(val title: String, val lastMessage: String, val pinned: Boolean)
|
|
@ -1,3 +0,0 @@
|
|||
package dev.lonami.talaria.model
|
||||
|
||||
data class Message(val sender: String, val text: String)
|
|
@ -1,110 +0,0 @@
|
|||
package dev.lonami.talaria.ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.Card
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextField
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import dev.lonami.talaria.R
|
||||
import dev.lonami.talaria.model.Message
|
||||
import dev.lonami.talaria.ui.theme.TalariaTheme
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun MessageCard(message: Message) {
|
||||
Card(
|
||||
elevation = 4.dp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Text(message.sender, fontWeight = FontWeight.Bold)
|
||||
Text(message.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageList(messages: List<Message>, modifier: Modifier, listState: LazyListState) {
|
||||
LazyColumn(modifier, state = listState) {
|
||||
items(messages.size) { MessageCard(messages[it]) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageInputField(
|
||||
messageText: String,
|
||||
onMessageChanged: (String) -> Unit,
|
||||
onSendMessage: () -> Unit
|
||||
) {
|
||||
Row {
|
||||
TextField(
|
||||
messageText,
|
||||
placeholder = { Text(stringResource(R.string.write_message)) },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Send
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = { onSendMessage() }
|
||||
),
|
||||
modifier = Modifier.weight(1.0f),
|
||||
onValueChange = onMessageChanged
|
||||
)
|
||||
Button(
|
||||
onClick = onSendMessage
|
||||
) {
|
||||
Text(stringResource(R.string.send_message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatScreen(selectedDialog: String, chatViewModel: ChatViewModel = viewModel()) {
|
||||
val chatUiState by chatViewModel.uiState.collectAsState()
|
||||
var messageText by remember { mutableStateOf("") }
|
||||
val messageListState = rememberLazyListState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
MessageList(
|
||||
chatUiState.messages,
|
||||
modifier = Modifier.weight(1.0f),
|
||||
listState = messageListState
|
||||
)
|
||||
MessageInputField(messageText, onMessageChanged = {
|
||||
messageText = it
|
||||
}, onSendMessage = {
|
||||
chatViewModel.sendMessage(selectedDialog, messageText)
|
||||
messageText = ""
|
||||
coroutineScope.launch {
|
||||
messageListState.animateScrollToItem(chatUiState.messages.size - 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ChatPreview() {
|
||||
TalariaTheme {
|
||||
ChatScreen("")
|
||||
}
|
||||
}
|
|
@ -1,31 +0,0 @@
|
|||
package dev.lonami.talaria.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dev.lonami.talaria.bindings.Native
|
||||
import dev.lonami.talaria.data.MessageSource
|
||||
import dev.lonami.talaria.model.Message
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
class ChatViewModel : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(ChatUiState())
|
||||
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
|
||||
|
||||
private fun loadMessages() {
|
||||
_uiState.value = ChatUiState(MessageSource.loadMessages().toMutableList())
|
||||
}
|
||||
|
||||
fun sendMessage(dialog: String, message: String) {
|
||||
Native.sendMessage(dialog, message)
|
||||
_uiState.update { state ->
|
||||
state.messages.add(Message("You", message))
|
||||
state
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
loadMessages()
|
||||
}
|
||||
}
|
|
@ -1,75 +0,0 @@
|
|||
package dev.lonami.talaria.ui
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.Card
|
||||
import androidx.compose.material.Switch
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import dev.lonami.talaria.R
|
||||
import dev.lonami.talaria.model.Dialog
|
||||
import dev.lonami.talaria.ui.theme.TalariaTheme
|
||||
|
||||
@Composable
|
||||
fun DialogCard(dialog: Dialog, onDialogSelected: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp, 16.dp)
|
||||
.clickable(onClick = onDialogSelected)
|
||||
) {
|
||||
Row {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||
contentDescription = stringResource(R.string.profile_photo),
|
||||
)
|
||||
Column(modifier = Modifier.weight(1.0f)) {
|
||||
Text(dialog.title, fontWeight = FontWeight.Bold)
|
||||
Text(dialog.lastMessage)
|
||||
}
|
||||
Switch(dialog.pinned, enabled = false, onCheckedChange = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DialogList(dialogs: List<Dialog>, onDialogSelected: (String) -> Unit) {
|
||||
LazyColumn {
|
||||
items(dialogs.size) {
|
||||
DialogCard(dialogs[it], onDialogSelected = {
|
||||
onDialogSelected(dialogs[it].lastMessage)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DialogScreen(
|
||||
onDialogSelected: (String) -> Unit,
|
||||
dialogViewModel: DialogViewModel = viewModel()
|
||||
) {
|
||||
val dialogUiState by dialogViewModel.uiState.collectAsState()
|
||||
DialogList(dialogUiState.dialogs, onDialogSelected = onDialogSelected)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun DialogPreview() {
|
||||
TalariaTheme {
|
||||
DialogScreen(onDialogSelected = { })
|
||||
}
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
package dev.lonami.talaria.ui
|
||||
|
||||
import dev.lonami.talaria.model.Dialog
|
||||
|
||||
data class DialogUiState(val dialogs: List<Dialog> = listOf())
|
|
@ -0,0 +1,181 @@
|
|||
package dev.lonami.talaria.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.Card
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextField
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import dev.lonami.talaria.R
|
||||
import dev.lonami.talaria.data.MockMessageRepository
|
||||
import dev.lonami.talaria.ui.state.ChatViewModel
|
||||
import dev.lonami.talaria.ui.theme.TalariaTheme
|
||||
import kotlinx.coroutines.launch
|
||||
import uniffi.talaria.Formatting
|
||||
import uniffi.talaria.Message
|
||||
import uniffi.talaria.TextFormat
|
||||
|
||||
@Composable
|
||||
fun FormattedText(
|
||||
text: String,
|
||||
formatting: List<TextFormat>,
|
||||
onFormatClicked: (TextFormat) -> Unit,
|
||||
) {
|
||||
val anno = AnnotatedString(text, spanStyles = formatting.map {
|
||||
AnnotatedString.Range(SpanStyle(
|
||||
fontWeight = when (it.format) {
|
||||
Formatting.BOLD -> FontWeight.Bold
|
||||
else -> FontWeight.Normal
|
||||
},
|
||||
fontStyle = when (it.format) {
|
||||
Formatting.ITALIC -> FontStyle.Italic
|
||||
else -> FontStyle.Normal
|
||||
},
|
||||
fontFamily = when (it.format) {
|
||||
Formatting.CODE,
|
||||
Formatting.PRE,
|
||||
-> FontFamily.Monospace
|
||||
else -> FontFamily.Default
|
||||
},
|
||||
textDecoration = when (it.format) {
|
||||
Formatting.UNDERLINE -> TextDecoration.Underline
|
||||
Formatting.STRIKE -> TextDecoration.LineThrough
|
||||
else -> TextDecoration.None
|
||||
},
|
||||
color = when (it.format) {
|
||||
Formatting.MENTION,
|
||||
Formatting.HASH_TAG,
|
||||
Formatting.BOT_COMMAND,
|
||||
Formatting.URL,
|
||||
Formatting.EMAIL,
|
||||
Formatting.TEXT_URL,
|
||||
Formatting.MENTION_NAME,
|
||||
Formatting.PHONE,
|
||||
Formatting.CASH_TAG,
|
||||
-> Color(0xff0000ff)
|
||||
else -> Color.Black
|
||||
}), it.offset, it.offset + it.length)
|
||||
})
|
||||
ClickableText(
|
||||
anno,
|
||||
onClick = { offset ->
|
||||
anno.spanStyles.indexOfFirst {
|
||||
it.start <= offset && offset <= it.end
|
||||
}.takeIf { it != -1 }?.also {
|
||||
onFormatClicked(formatting[it])
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageCard(message: Message, modifier: Modifier = Modifier) {
|
||||
Card(
|
||||
elevation = 4.dp,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Text(message.sender, fontWeight = FontWeight.Bold)
|
||||
FormattedText(message.text, message.formatting, onFormatClicked = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageList(messages: List<Message>, listState: LazyListState, modifier: Modifier = Modifier) {
|
||||
LazyColumn(modifier = modifier, state = listState) {
|
||||
items(messages.size) { MessageCard(messages[it]) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageInputField(
|
||||
messageText: String,
|
||||
onMessageChanged: (String) -> Unit,
|
||||
onSendMessage: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(modifier = modifier) {
|
||||
TextField(
|
||||
messageText,
|
||||
placeholder = { Text(stringResource(R.string.write_message)) },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Send
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = { onSendMessage() }
|
||||
),
|
||||
modifier = Modifier.weight(1.0f),
|
||||
onValueChange = onMessageChanged
|
||||
)
|
||||
Button(
|
||||
onClick = onSendMessage
|
||||
) {
|
||||
Text(stringResource(R.string.send_message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
selectedDialog: String,
|
||||
modifier: Modifier = Modifier,
|
||||
chatViewModel: ChatViewModel = viewModel(),
|
||||
) {
|
||||
val chatUiState by chatViewModel.uiState.collectAsState()
|
||||
var messageText by remember { mutableStateOf("") }
|
||||
val messageListState = rememberLazyListState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
MessageList(
|
||||
chatUiState.messages,
|
||||
modifier = Modifier.weight(1.0f),
|
||||
listState = messageListState
|
||||
)
|
||||
MessageInputField(messageText, onMessageChanged = {
|
||||
messageText = it
|
||||
}, onSendMessage = {
|
||||
chatViewModel.sendMessage(selectedDialog, messageText)
|
||||
messageText = ""
|
||||
coroutineScope.launch {
|
||||
messageListState.animateScrollToItem(chatUiState.messages.size - 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ChatPreview() {
|
||||
val viewModel = remember { ChatViewModel(MockMessageRepository()).apply { loadMessages("") } }
|
||||
|
||||
TalariaTheme {
|
||||
ChatScreen("", chatViewModel = viewModel)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,137 @@
|
|||
package dev.lonami.talaria.ui.screens
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import dev.lonami.talaria.R
|
||||
import dev.lonami.talaria.data.MockDialogRepository
|
||||
import dev.lonami.talaria.ui.state.DialogViewModel
|
||||
import dev.lonami.talaria.ui.theme.TalariaTheme
|
||||
import uniffi.talaria.Dialog
|
||||
import uniffi.talaria.MessageAck
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
@Composable
|
||||
fun Dialog(dialog: Dialog, onDialogSelected: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.padding(4.dp)
|
||||
.clickable(onClick = onDialogSelected)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||
contentDescription = stringResource(R.string.profile_photo),
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.height(48.dp),
|
||||
)
|
||||
Column(modifier = Modifier.weight(1.0f)) {
|
||||
Text(
|
||||
dialog.title,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (dialog.lastMessage != null) {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.message_preview,
|
||||
dialog.lastMessage!!.sender,
|
||||
dialog.lastMessage!!.text
|
||||
),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Column {
|
||||
if (dialog.lastMessage != null) {
|
||||
Row {
|
||||
when (dialog.lastMessage!!.ack) {
|
||||
MessageAck.RECEIVED -> {}
|
||||
MessageAck.SENT -> Icon(
|
||||
painterResource(R.drawable.sent),
|
||||
stringResource(R.string.sent)
|
||||
)
|
||||
MessageAck.SEEN -> Icon(
|
||||
painterResource(R.drawable.seen),
|
||||
stringResource(R.string.seen)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
LocalDateTime.ofInstant(dialog.lastMessage!!.date, ZoneOffset.UTC).format(
|
||||
DateTimeFormatter.ofLocalizedTime(
|
||||
FormatStyle.SHORT
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (dialog.pinned) {
|
||||
Icon(
|
||||
painterResource(R.drawable.tack),
|
||||
stringResource(R.string.pinned),
|
||||
modifier = Modifier.align(Alignment.End)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DialogList(
|
||||
dialogs: List<Dialog>,
|
||||
onDialogSelected: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(modifier = modifier) {
|
||||
items(dialogs.size) {
|
||||
Dialog(dialogs[it], onDialogSelected = {
|
||||
onDialogSelected(dialogs[it].id)
|
||||
})
|
||||
Divider(startIndent = 52.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DialogScreen(
|
||||
onDialogSelected: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
dialogViewModel: DialogViewModel = viewModel(),
|
||||
) {
|
||||
val dialogUiState by dialogViewModel.uiState.collectAsState()
|
||||
Surface(modifier = modifier) {
|
||||
DialogList(dialogUiState.dialogs, onDialogSelected = onDialogSelected)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun DialogPreview() {
|
||||
val viewModel = DialogViewModel(MockDialogRepository())
|
||||
TalariaTheme {
|
||||
DialogScreen(onDialogSelected = { }, dialogViewModel = viewModel)
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package dev.lonami.talaria.ui
|
||||
package dev.lonami.talaria.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
|
@ -18,8 +18,8 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.lonami.talaria.R
|
||||
import dev.lonami.talaria.bindings.Native
|
||||
import dev.lonami.talaria.ui.theme.TalariaTheme
|
||||
import uniffi.talaria.LoginProcedure
|
||||
|
||||
enum class LoginStage {
|
||||
ASK_PHONE,
|
||||
|
@ -30,9 +30,15 @@ fun isPhoneValid(phone: String): Boolean = phone.trim('+', ' ').isNotEmpty()
|
|||
fun isLoginCodeValid(code: String): Boolean = code.trim().count { it.isDigit() } == 5
|
||||
|
||||
@Composable
|
||||
fun PhoneInput(phone: String, onPhoneChanged: (String) -> Unit, onSendCode: () -> Unit) {
|
||||
fun PhoneInput(
|
||||
phone: String,
|
||||
onPhoneChanged: (String) -> Unit,
|
||||
onSendCode: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Text(stringResource(R.string.enter_phone))
|
||||
TextField(
|
||||
phone,
|
||||
|
@ -56,11 +62,18 @@ fun PhoneInput(phone: String, onPhoneChanged: (String) -> Unit, onSendCode: () -
|
|||
Text(stringResource(R.string.send_otp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OtpInput(otp: String, onOtpChanged: (String) -> Unit, onConfirmOtp: () -> Unit) {
|
||||
fun OtpInput(
|
||||
otp: String,
|
||||
onOtpChanged: (String) -> Unit,
|
||||
onConfirmOtp: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Text(stringResource(R.string.enter_otp))
|
||||
TextField(
|
||||
otp,
|
||||
|
@ -84,17 +97,18 @@ fun OtpInput(otp: String, onOtpChanged: (String) -> Unit, onConfirmOtp: () -> Un
|
|||
Text(stringResource(R.string.do_login))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(onConfirmOtp: () -> Unit) {
|
||||
fun LoginScreen(onConfirmOtp: () -> Unit, modifier: Modifier = Modifier) {
|
||||
var stage by remember { mutableStateOf(LoginStage.ASK_PHONE) }
|
||||
var phone by remember { mutableStateOf("") }
|
||||
var otp by remember { mutableStateOf("") }
|
||||
|
||||
var tokenPtr by remember { mutableStateOf(0L) }
|
||||
val loginProcedure by remember { mutableStateOf(LoginProcedure()) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.Center
|
||||
|
@ -113,7 +127,7 @@ fun LoginScreen(onConfirmOtp: () -> Unit) {
|
|||
phone,
|
||||
onPhoneChanged = { phone = it },
|
||||
onSendCode = {
|
||||
tokenPtr = Native.requestLoginCode(phone)
|
||||
loginProcedure.requestLoginCode(phone)
|
||||
stage = LoginStage.ASK_CODE
|
||||
}
|
||||
)
|
||||
|
@ -121,7 +135,7 @@ fun LoginScreen(onConfirmOtp: () -> Unit) {
|
|||
otp,
|
||||
onOtpChanged = { otp = it },
|
||||
onConfirmOtp = {
|
||||
Native.signIn(tokenPtr, otp)
|
||||
loginProcedure.signIn(otp)
|
||||
onConfirmOtp()
|
||||
}
|
||||
)
|
|
@ -1,5 +1,5 @@
|
|||
package dev.lonami.talaria.ui
|
||||
package dev.lonami.talaria.ui.state
|
||||
|
||||
import dev.lonami.talaria.model.Message
|
||||
import uniffi.talaria.Message
|
||||
|
||||
data class ChatUiState(val messages: MutableList<Message> = mutableListOf())
|
|
@ -0,0 +1,31 @@
|
|||
package dev.lonami.talaria.ui.state
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dev.lonami.talaria.data.MessageRepository
|
||||
import dev.lonami.talaria.data.NativeMessageRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
class ChatViewModel(private val repository: MessageRepository = NativeMessageRepository()) :
|
||||
ViewModel() {
|
||||
private val _uiState = MutableStateFlow(ChatUiState())
|
||||
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun loadMessages(chat: String) {
|
||||
_uiState.update {
|
||||
it.messages.clear()
|
||||
it.messages.addAll(repository.loadMessages(chat))
|
||||
it
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(dialog: String, message: String) {
|
||||
val sent = repository.sendMessage(dialog, message)
|
||||
_uiState.update {
|
||||
it.messages.add(sent)
|
||||
it
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
package dev.lonami.talaria.ui.state
|
||||
|
||||
import uniffi.talaria.Dialog
|
||||
|
||||
data class DialogUiState(val dialogs: List<Dialog> = listOf())
|
|
@ -1,17 +1,19 @@
|
|||
package dev.lonami.talaria.ui
|
||||
package dev.lonami.talaria.ui.state
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dev.lonami.talaria.data.DialogSource
|
||||
import dev.lonami.talaria.data.DialogRepository
|
||||
import dev.lonami.talaria.data.NativeDialogRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
class DialogViewModel : ViewModel() {
|
||||
class DialogViewModel(private val repository: DialogRepository = NativeDialogRepository()) :
|
||||
ViewModel() {
|
||||
private val _uiState = MutableStateFlow(DialogUiState())
|
||||
val uiState: StateFlow<DialogUiState> = _uiState.asStateFlow()
|
||||
|
||||
private fun loadDialogs() {
|
||||
_uiState.value = DialogUiState(DialogSource.loadDialogs())
|
||||
_uiState.value = DialogUiState(repository.loadDialogs())
|
||||
}
|
||||
|
||||
init {
|
|
@ -16,15 +16,6 @@ private val LightColorPalette = lightColors(
|
|||
primary = Purple500,
|
||||
primaryVariant = Purple700,
|
||||
secondary = Teal200
|
||||
|
||||
/* Other default colors to override
|
||||
background = Color.White,
|
||||
surface = Color.White,
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.Black,
|
||||
onBackground = Color.Black,
|
||||
onSurface = Color.Black,
|
||||
*/
|
||||
)
|
||||
|
||||
@Composable
|
||||
|
|
|
@ -6,23 +6,10 @@ import androidx.compose.ui.text.font.FontFamily
|
|||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
body1 = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
/* Other default text styles to override
|
||||
button = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.W500,
|
||||
fontSize = 14.sp
|
||||
),
|
||||
caption = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
*/
|
||||
)
|
|
@ -0,0 +1,12 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="m11,16 l2,2 9,-9C23,8 22,7 21,8l-8,8 -1,-1z"
|
||||
android:fillColor="@color/black" />
|
||||
<path
|
||||
android:pathData="m3,14 l4,4 9,-9C17,8 16,7 15,8L7,16 4,13C3,12 2,13 3,14Z"
|
||||
android:fillColor="@color/black" />
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="m6,14 l4,4 9,-9C20,8 19,7 18,8L10,16 7,13C6,12 5,13 6,14Z"
|
||||
android:fillColor="@color/black" />
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="m14,4 l-5,5 -6,1 5,5 -5,7 7,-5 5,5 1,-6 5,-5c1,-1 1,-1 0,-2L16,4C15,3 15,3 14,4Z"
|
||||
android:fillColor="@color/black" />
|
||||
</vector>
|
|
@ -15,4 +15,8 @@
|
|||
<string name="back_button">Back</string>
|
||||
<string name="dialog">All chats</string>
|
||||
<string name="chat">Messages</string>
|
||||
<string name="pinned">Pinned</string>
|
||||
<string name="sent">Sent</string>
|
||||
<string name="seen">Seen</string>
|
||||
<string name="message_preview"><em>%s</em>: %s</string>
|
||||
</resources>
|
||||
|
|
|
@ -4,8 +4,10 @@ buildscript {
|
|||
}
|
||||
}// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
plugins {
|
||||
id 'com.android.application' version '7.3.0' apply false
|
||||
id 'com.android.library' version '7.3.0' apply false
|
||||
// uniffi-recommended Gradle script fails with Android plugin 7.3.1.
|
||||
// See https://github.com/mozilla/uniffi-rs/issues/1386 for details.
|
||||
id 'com.android.application' version '7.2.2' apply false
|
||||
id 'com.android.library' version '7.2.2' apply false
|
||||
id 'org.jetbrains.kotlin.android' version '1.6.10' apply false
|
||||
id "org.mozilla.rust-android-gradle.rust-android" version "0.9.3"
|
||||
}
|
||||
|
|
|
@ -4,19 +4,24 @@ version = "0.1.0"
|
|||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "talaria"
|
||||
name = "uniffi_talaria"
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
jni = { version = "0.10.2", default-features = false }
|
||||
uniffi = "0.21.0"
|
||||
# v0.4 of grammers-* is currently unreleased; clone the project and use path dependencies
|
||||
grammers-client = { version = "0.4.0" }
|
||||
grammers-client = { version = "0.4.1" }
|
||||
grammers-tl-types = { version = "0.4.0" }
|
||||
grammers-session = { version = "0.4.0" }
|
||||
grammers-session = { version = "0.4.1" }
|
||||
tokio = { version = "1.5.0", features = ["full"] }
|
||||
log = "0.4.14"
|
||||
android_logger = "0.11.1"
|
||||
once_cell = "1.15.0"
|
||||
sqlite = "0.27.0"
|
||||
|
||||
[build-dependencies]
|
||||
uniffi_build = "0.21.0"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
uniffi_build::generate_scaffolding("src/talaria.udl").unwrap();
|
||||
}
|
|
@ -0,0 +1,172 @@
|
|||
mod model;
|
||||
mod utils;
|
||||
|
||||
use model::Session;
|
||||
use sqlite::{Connection, Error, State};
|
||||
use std::net::IpAddr;
|
||||
use utils::{fetch_many, fetch_one};
|
||||
|
||||
fn init_schema(conn: &Connection) -> Result<(), Error> {
|
||||
let version = match fetch_one(&conn, "SELECT version FROM version LIMIT 1", |stmt| {
|
||||
stmt.read::<i64>(0)
|
||||
}) {
|
||||
Ok(Some(version)) => version,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
if version == 0 {
|
||||
conn.execute(
|
||||
"
|
||||
BEGIN TRANSACTION;
|
||||
CREATE TABLE version (version INTEGER NOT NULL);
|
||||
CREATE TABLE session (
|
||||
user_id INTEGER,
|
||||
dc_id INTEGER,
|
||||
bot INTEGER,
|
||||
pts INTEGER,
|
||||
qts INTEGER,
|
||||
seq INTEGER,
|
||||
date INTEGER
|
||||
);
|
||||
CREATE TABLE channel (
|
||||
session_id INTEGER NOT NULL REFERENCES session (rowid),
|
||||
id INTEGER NOT NULL,
|
||||
hash INTEGER NOT NULL,
|
||||
pts INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE datacenter (
|
||||
session_id INTEGER NOT NULL REFERENCES session (rowid),
|
||||
id INTEGER NOT NULL,
|
||||
ipv4 TEXT,
|
||||
ipv6 TEXT,
|
||||
port INTEGER NOT NULL,
|
||||
auth BLOB,
|
||||
CONSTRAINT SingleIp CHECK(
|
||||
(ipv4 IS NOT NULL AND ipv6 IS NULL) OR
|
||||
(ipv6 IS NOT NULL AND ipv4 IS NULL))
|
||||
);
|
||||
INSERT INTO version VALUES (1);
|
||||
COMMIT;
|
||||
",
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn init_connection(db_path: &str) -> Result<Connection, Error> {
|
||||
let conn = sqlite::open(db_path)?;
|
||||
init_schema(&conn)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
pub fn get_sessions(conn: &Connection) -> Result<Vec<Session>, Error> {
|
||||
let query = "
|
||||
SELECT s.rowid, s.*, COALESCE(d.ipv4, d.ipv6), d.port, d.auth
|
||||
FROM session s
|
||||
LEFT JOIN datacenter d ON d.session_id = s.rowid AND d.id = s.dc_id
|
||||
";
|
||||
fetch_many(conn, query, |stmt| {
|
||||
Ok(Session {
|
||||
id: stmt.read(0)?,
|
||||
user_id: stmt.read(1)?,
|
||||
dc_id: stmt.read::<Option<i64>>(2)?.map(|x| x as _),
|
||||
bot: stmt.read::<Option<i64>>(3)?.map(|x| x != 0),
|
||||
pts: stmt.read::<Option<i64>>(4)?.map(|x| x as _),
|
||||
qts: stmt.read::<Option<i64>>(5)?.map(|x| x as _),
|
||||
seq: stmt.read::<Option<i64>>(6)?.map(|x| x as _),
|
||||
date: stmt.read::<Option<i64>>(7)?.map(|x| x as _),
|
||||
dc_addr: stmt.read::<Option<String>>(8)?,
|
||||
dc_port: stmt.read::<Option<i64>>(9)?.map(|x| x as _),
|
||||
dc_auth: stmt
|
||||
.read::<Option<Vec<u8>>>(10)?
|
||||
.map(|x| x.try_into().unwrap()),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_session(conn: &Connection) -> Result<Session, Error> {
|
||||
conn.execute("INSERT INTO session DEFAULT VALUES;")?;
|
||||
let id = fetch_one(conn, "SELECT LAST_INSERT_ROWID()", |stmt| {
|
||||
stmt.read::<i64>(0)
|
||||
})?
|
||||
.unwrap();
|
||||
Ok(Session {
|
||||
id,
|
||||
user_id: None,
|
||||
dc_id: None,
|
||||
bot: None,
|
||||
pts: None,
|
||||
qts: None,
|
||||
seq: None,
|
||||
date: None,
|
||||
dc_addr: None,
|
||||
dc_port: None,
|
||||
dc_auth: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_session(conn: &Connection, session: &Session) -> Result<(), Error> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"
|
||||
UPDATE session SET
|
||||
user_id = ?,
|
||||
dc_id = ?,
|
||||
bot = ?,
|
||||
pts = ?,
|
||||
qts = ?,
|
||||
seq = ?,
|
||||
date = ?
|
||||
WHERE rowid = ?
|
||||
",
|
||||
)?
|
||||
.bind(1, session.user_id)?
|
||||
.bind(2, session.dc_id.map(|x| x as i64))?
|
||||
.bind(3, session.bot.map(|x| x as i64))?
|
||||
.bind(4, session.pts.map(|x| x as i64))?
|
||||
.bind(5, session.qts.map(|x| x as i64))?
|
||||
.bind(6, session.seq.map(|x| x as i64))?
|
||||
.bind(7, session.date.map(|x| x as i64))?
|
||||
.bind(8, session.id)?;
|
||||
while let State::Row = stmt.next()? {}
|
||||
|
||||
match (
|
||||
session.dc_id,
|
||||
session.dc_addr.as_ref(),
|
||||
session.dc_port,
|
||||
session.dc_auth,
|
||||
) {
|
||||
(Some(id), Some(addr), Some(port), Some(auth)) => {
|
||||
let (ipv4, ipv6) = match addr.parse().unwrap() {
|
||||
IpAddr::V4(ipv4) => (Some(ipv4.to_string()), None),
|
||||
IpAddr::V6(ipv6) => (None, Some(ipv6.to_string())),
|
||||
};
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"
|
||||
DELETE FROM datacenter WHERE session_id = ? AND id = ?
|
||||
",
|
||||
)?
|
||||
.bind(1, session.id)?
|
||||
.bind(2, id as i64)?;
|
||||
|
||||
while let State::Row = stmt.next()? {}
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare("INSERT INTO datacenter VALUES (?, ?, ?, ?, ?, ?)")?
|
||||
.bind(1, session.id)?
|
||||
.bind(2, id as i64)?
|
||||
.bind(3, ipv4.as_deref())?
|
||||
.bind(4, ipv6.as_deref())?
|
||||
.bind(5, port as i64)?
|
||||
.bind(6, auth.as_ref())?;
|
||||
|
||||
while let State::Row = stmt.next()? {}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
#[derive(Debug)]
|
||||
pub struct Session {
|
||||
pub id: i64,
|
||||
pub user_id: Option<i64>,
|
||||
pub dc_id: Option<i32>,
|
||||
pub bot: Option<bool>,
|
||||
pub pts: Option<i32>,
|
||||
pub qts: Option<i32>,
|
||||
pub seq: Option<i32>,
|
||||
pub date: Option<i32>,
|
||||
pub dc_addr: Option<String>,
|
||||
pub dc_port: Option<u16>,
|
||||
pub dc_auth: Option<[u8; 256]>,
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
use sqlite::{Connection, Error, State, Statement};
|
||||
|
||||
pub fn fetch_one<T, F: FnOnce(&Statement) -> Result<T, Error>>(
|
||||
conn: &Connection,
|
||||
query: &str,
|
||||
adaptor: F,
|
||||
) -> Result<Option<T>, Error> {
|
||||
let mut stmt = conn.prepare(query)?;
|
||||
if let State::Row = stmt.next()? {
|
||||
adaptor(&stmt).map(Some)
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fetch_many<T, F: FnMut(&Statement) -> Result<T, Error>>(
|
||||
conn: &Connection,
|
||||
query: &str,
|
||||
mut adaptor: F,
|
||||
) -> Result<Vec<T>, Error> {
|
||||
let mut result = Vec::new();
|
||||
let mut stmt = conn.prepare(query)?;
|
||||
while let State::Row = stmt.next()? {
|
||||
result.push(adaptor(&stmt)?);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
|
@ -1,30 +1,139 @@
|
|||
#![cfg(target_os = "android")]
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use std::ffi::{CStr, CString};
|
||||
use std::future::Future;
|
||||
mod db;
|
||||
|
||||
use grammers_client::{Client, Config};
|
||||
use grammers_client::types::{Dialog, LoginToken};
|
||||
use grammers_session::{PackedChat, Session};
|
||||
use jni::JNIEnv;
|
||||
use jni::objects::{JObject, JString};
|
||||
use jni::sys::{jboolean, jint, jlong, jstring};
|
||||
use grammers_client::types::{LoginToken, Message as OgMessage};
|
||||
use grammers_client::{Client, Config, InitParams};
|
||||
use grammers_session::{PackedChat, Session, UpdateState};
|
||||
use grammers_tl_types as tl;
|
||||
use log;
|
||||
use log::{error, info, Level};
|
||||
use once_cell::sync::OnceCell;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::future::Future;
|
||||
use std::net::SocketAddr;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
use std::sync::Mutex;
|
||||
use std::time::SystemTime;
|
||||
use tokio::runtime;
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/talaria.uniffi.rs"));
|
||||
|
||||
const LOG_MIN_LEVEL: Level = Level::Trace;
|
||||
const LOG_TAG: &str = ".native.talari";
|
||||
const API_ID: i32 = 0;
|
||||
const API_HASH: &str = "";
|
||||
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
const API_ID: i32 = {
|
||||
let mut index = 0;
|
||||
let mut value = 0;
|
||||
let api_id = env!("TALARIA_API_ID");
|
||||
let bytes = api_id.as_bytes();
|
||||
while index < bytes.len() {
|
||||
match bytes[index] {
|
||||
b @ b'0'..=b'9' => value = value * 10 + (b - b'0') as i32,
|
||||
_ => panic!("non-digit character found in API ID"),
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
value
|
||||
};
|
||||
const API_HASH: &str = env!("TALARIA_API_HASH");
|
||||
const SERVER_ADDR: &str = env!("TALARIA_SERVER_ADDR");
|
||||
|
||||
static RUNTIME: OnceCell<Runtime> = OnceCell::new();
|
||||
static CLIENT: OnceCell<Client> = OnceCell::new();
|
||||
static DATABASE: Mutex<Option<sqlite::Connection>> = Mutex::new(None);
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum NativeError {
|
||||
Initialization,
|
||||
Database,
|
||||
Network,
|
||||
}
|
||||
|
||||
impl fmt::Display for NativeError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Initialization => write!(f, "a resource could not be initialized correctly"),
|
||||
Self::Database => write!(f, "a query to the local database failed"),
|
||||
Self::Network => write!(f, "a network request could not complete successfully"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for NativeError {}
|
||||
|
||||
struct LoginProcedure {
|
||||
token: Mutex<Option<LoginToken>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum MessageAck {
|
||||
Received,
|
||||
Seen,
|
||||
Sent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Formatting {
|
||||
Unknown,
|
||||
Mention,
|
||||
HashTag,
|
||||
BotCommand,
|
||||
Url,
|
||||
Email,
|
||||
Bold,
|
||||
Italic,
|
||||
Code,
|
||||
Pre,
|
||||
TextUrl,
|
||||
MentionName,
|
||||
Phone,
|
||||
CashTag,
|
||||
Underline,
|
||||
Strike,
|
||||
Blockquote,
|
||||
BankCard,
|
||||
Spoiler,
|
||||
CustomEmoji,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TextFormat {
|
||||
format: Formatting,
|
||||
offset: i32,
|
||||
length: i32,
|
||||
extra: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Message {
|
||||
id: i32,
|
||||
sender: String,
|
||||
text: String,
|
||||
date: SystemTime,
|
||||
edit_date: Option<SystemTime>,
|
||||
formatting: Vec<TextFormat>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MessagePreview {
|
||||
sender: String,
|
||||
text: String,
|
||||
date: SystemTime,
|
||||
ack: MessageAck,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Dialog {
|
||||
id: String,
|
||||
title: String,
|
||||
last_message: Option<MessagePreview>,
|
||||
pinned: bool,
|
||||
}
|
||||
|
||||
type Result<T> = std::result::Result<T, NativeError>;
|
||||
|
||||
fn block_on<F: Future>(future: F) -> F::Output {
|
||||
if RUNTIME.get().is_none() {
|
||||
|
@ -41,7 +150,22 @@ fn block_on<F: Future>(future: F) -> F::Output {
|
|||
RUNTIME.get().unwrap().block_on(future)
|
||||
}
|
||||
|
||||
async fn init_client() -> Result<()> {
|
||||
pub fn init_database(path: String) -> Result<()> {
|
||||
let mut guard = DATABASE.lock().unwrap();
|
||||
if guard.is_some() {
|
||||
info!("Database is already initialized");
|
||||
}
|
||||
|
||||
match db::init_connection(&path) {
|
||||
Ok(conn) => {
|
||||
*guard = Some(conn);
|
||||
Ok(())
|
||||
}
|
||||
Err(_) => Err(NativeError::Database),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_client() -> Result<()> {
|
||||
android_logger::init_once(
|
||||
android_logger::Config::default()
|
||||
.with_min_level(LOG_MIN_LEVEL)
|
||||
|
@ -53,188 +177,275 @@ async fn init_client() -> Result<()> {
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
let guard = DATABASE.lock().unwrap();
|
||||
let conn = match guard.as_ref() {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
error!("Database was not initialized");
|
||||
return Err(NativeError::Initialization);
|
||||
}
|
||||
};
|
||||
|
||||
info!("Connecting to Telegram...");
|
||||
|
||||
let client = Client::connect(Config {
|
||||
session: Session::new(),
|
||||
let session = Session::new();
|
||||
|
||||
let sessions = db::get_sessions(conn).map_err(|_| NativeError::Database)?;
|
||||
if let Some(s) = sessions.get(0) {
|
||||
match (s.user_id, s.dc_id, s.bot) {
|
||||
(Some(id), Some(dc), Some(bot)) => session.set_user(id, dc, bot),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
match (s.pts, s.qts, s.seq, s.date) {
|
||||
(Some(pts), Some(qts), Some(seq), Some(date)) => session.set_state(UpdateState {
|
||||
pts,
|
||||
qts,
|
||||
seq,
|
||||
date,
|
||||
channels: HashMap::new(),
|
||||
}),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
match (s.dc_id, s.dc_addr.as_ref(), s.dc_port, s.dc_auth) {
|
||||
(Some(id), Some(addr), Some(port), Some(auth)) => {
|
||||
session.insert_dc(id, SocketAddr::new(addr.parse().unwrap(), port), auth)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let client = block_on(Client::connect(Config {
|
||||
session,
|
||||
api_id: API_ID,
|
||||
api_hash: API_HASH.to_string(),
|
||||
params: Default::default(),
|
||||
})
|
||||
.await?;
|
||||
params: InitParams {
|
||||
server_addr: if SERVER_ADDR.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(SERVER_ADDR.parse().unwrap())
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
}))
|
||||
.map_err(|_| NativeError::Network)?;
|
||||
|
||||
info!("Connected!");
|
||||
|
||||
CLIENT
|
||||
.set(client)
|
||||
.map_err(|_| "Client was already initialized")?;
|
||||
.map_err(|_| NativeError::Initialization)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn need_login() -> Result<bool> {
|
||||
let client = CLIENT.get().ok_or("Client not initialized")?;
|
||||
Ok(client.is_authorized().await?)
|
||||
pub fn need_login() -> Result<bool> {
|
||||
let client = CLIENT.get().ok_or(NativeError::Initialization)?;
|
||||
block_on(client.is_authorized()).map_err(|_| NativeError::Network)
|
||||
}
|
||||
|
||||
async fn request_login_code(phone: &str) -> Result<LoginToken> {
|
||||
let client = CLIENT.get().ok_or("Client not initialized")?;
|
||||
let token = client.request_login_code(&phone, API_ID, API_HASH).await?;
|
||||
Ok(token)
|
||||
impl LoginProcedure {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
token: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn sign_in(token: LoginToken, code: &str) -> Result<()> {
|
||||
let client = CLIENT.get().ok_or("Client not initialized")?;
|
||||
client.sign_in(&token, &code).await?;
|
||||
fn request_login_code(&self, phone: String) -> Result<()> {
|
||||
let client = CLIENT.get().ok_or(NativeError::Initialization)?;
|
||||
let token = block_on(client.request_login_code(&phone, API_ID, API_HASH))
|
||||
.map_err(|_| NativeError::Network)?;
|
||||
*self.token.lock().unwrap() = Some(token);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_dialogs() -> Result<Vec<Dialog>> {
|
||||
let client = CLIENT.get().ok_or("Client not initialized")?;
|
||||
fn sign_in(&self, code: String) -> Result<()> {
|
||||
let token = self
|
||||
.token
|
||||
.lock()
|
||||
.unwrap()
|
||||
.take()
|
||||
.ok_or(NativeError::Initialization)?;
|
||||
let client = CLIENT.get().ok_or(NativeError::Initialization)?;
|
||||
|
||||
block_on(client.sign_in(&token, &code)).map_err(|_| NativeError::Network)?;
|
||||
|
||||
let guard = DATABASE.lock().unwrap();
|
||||
let conn = match guard.as_ref() {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
error!("Database was not initialized");
|
||||
return Err(NativeError::Initialization);
|
||||
}
|
||||
};
|
||||
|
||||
let mut session = db::create_session(conn).map_err(|_| NativeError::Database)?;
|
||||
let s = client.session();
|
||||
if let Some(user) = s.get_user() {
|
||||
session.user_id = Some(user.id);
|
||||
session.dc_id = Some(user.dc);
|
||||
session.bot = Some(user.bot);
|
||||
}
|
||||
|
||||
if let Some(state) = s.get_state() {
|
||||
session.pts = Some(state.pts);
|
||||
session.qts = Some(state.qts);
|
||||
session.seq = Some(state.seq);
|
||||
session.date = Some(state.date);
|
||||
}
|
||||
|
||||
if let Some(dc_id) = session.dc_id {
|
||||
for dc in s.get_dcs() {
|
||||
if dc.id == dc_id {
|
||||
if let Some(ipv4) = dc.ipv4 {
|
||||
session.dc_addr = Some(Ipv4Addr::from(ipv4.to_le_bytes()).to_string())
|
||||
} else if let Some(ipv6) = dc.ipv6 {
|
||||
session.dc_addr = Some(Ipv6Addr::from(ipv6).to_string())
|
||||
}
|
||||
session.dc_port = Some(dc.port as u16);
|
||||
session.dc_auth = dc.auth.map(|b| b.try_into().unwrap());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db::update_session(conn, &session).map_err(|_| NativeError::Database)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_dialogs() -> Result<Vec<Dialog>> {
|
||||
let client = CLIENT.get().ok_or(NativeError::Initialization)?;
|
||||
|
||||
block_on(async {
|
||||
let mut result = Vec::new();
|
||||
let mut dialogs = client.iter_dialogs();
|
||||
while let Some(dialog) = dialogs.next().await? {
|
||||
while let Some(dialog) = dialogs.next().await.map_err(|_| NativeError::Network)? {
|
||||
result.push(dialog);
|
||||
}
|
||||
Ok(result)
|
||||
})
|
||||
.map(|dialogs| {
|
||||
dialogs
|
||||
.into_iter()
|
||||
.map(|d| Dialog {
|
||||
id: d.chat().pack().to_hex(),
|
||||
title: d.chat().name().to_string(),
|
||||
last_message: d.last_message.map(|m| MessagePreview {
|
||||
sender: if let Some(sender) = m.sender() {
|
||||
sender.name().to_string()
|
||||
} else {
|
||||
"unknown".to_string()
|
||||
},
|
||||
text: m.text().to_string(),
|
||||
date: m.date().into(),
|
||||
ack: if m.outgoing() {
|
||||
match &d.dialog {
|
||||
tl::enums::Dialog::Dialog(d) => {
|
||||
if m.id() <= d.read_inbox_max_id {
|
||||
MessageAck::Seen
|
||||
} else {
|
||||
MessageAck::Sent
|
||||
}
|
||||
}
|
||||
tl::enums::Dialog::Folder(_) => MessageAck::Received,
|
||||
}
|
||||
} else {
|
||||
MessageAck::Received
|
||||
},
|
||||
}),
|
||||
pinned: match d.dialog {
|
||||
tl::enums::Dialog::Dialog(d) => d.pinned,
|
||||
tl::enums::Dialog::Folder(f) => f.pinned,
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
async fn send_message(chat: PackedChat, text: &str) -> Result<()> {
|
||||
let client = CLIENT.get().ok_or("Client not initialized")?;
|
||||
client.send_message(chat, text).await?;
|
||||
Ok(())
|
||||
fn adapt_message(m: OgMessage) -> Message {
|
||||
Message {
|
||||
id: m.id(),
|
||||
sender: if let Some(sender) = m.sender() {
|
||||
sender.name().to_string()
|
||||
} else {
|
||||
"unknown".to_string()
|
||||
},
|
||||
text: m.text().to_string(),
|
||||
date: m.date().into(),
|
||||
edit_date: m.edit_date().map(|d| d.into()),
|
||||
formatting: if let Some(entities) = m.fmt_entities() {
|
||||
use tl::enums::MessageEntity as ME;
|
||||
|
||||
macro_rules! tf {
|
||||
($formatting:ident($entity:ident)) => {
|
||||
tf!($formatting($entity).extra(None))
|
||||
};
|
||||
($formatting:ident($entity:ident).extra($extra:expr)) => {
|
||||
TextFormat {
|
||||
format: Formatting::$formatting,
|
||||
offset: $entity.offset,
|
||||
length: $entity.length,
|
||||
extra: $extra,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_initClient(_: JNIEnv, _: JObject) {
|
||||
match block_on(init_client()) {
|
||||
Ok(_) => info!("Client initialized"),
|
||||
Err(e) => error!("Failed to initialize client: {}", e),
|
||||
entities
|
||||
.into_iter()
|
||||
.map(|e| match e {
|
||||
ME::Unknown(e) => tf!(Unknown(e)),
|
||||
ME::Mention(e) => tf!(Mention(e)),
|
||||
ME::Hashtag(e) => tf!(HashTag(e)),
|
||||
ME::BotCommand(e) => tf!(BotCommand(e)),
|
||||
ME::Url(e) => tf!(Url(e)),
|
||||
ME::Email(e) => tf!(Email(e)),
|
||||
ME::Bold(e) => tf!(Bold(e)),
|
||||
ME::Italic(e) => tf!(Italic(e)),
|
||||
ME::Code(e) => tf!(Code(e)),
|
||||
ME::Pre(e) => tf!(Pre(e).extra(Some(e.language.to_string()))),
|
||||
ME::TextUrl(e) => tf!(TextUrl(e).extra(Some(e.url.to_string()))),
|
||||
ME::MentionName(e) => tf!(MentionName(e).extra(Some(e.user_id.to_string()))),
|
||||
ME::InputMessageEntityMentionName(e) => tf!(Unknown(e)),
|
||||
ME::Phone(e) => tf!(Phone(e)),
|
||||
ME::Cashtag(e) => tf!(CashTag(e)),
|
||||
ME::Underline(e) => tf!(Underline(e)),
|
||||
ME::Strike(e) => tf!(Strike(e)),
|
||||
ME::Blockquote(e) => tf!(Blockquote(e)),
|
||||
ME::BankCard(e) => tf!(BankCard(e)),
|
||||
ME::Spoiler(e) => tf!(Spoiler(e)),
|
||||
ME::CustomEmoji(e) => {
|
||||
tf!(CustomEmoji(e).extra(Some(e.document_id.to_string())))
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_needLogin(
|
||||
_: JNIEnv,
|
||||
_: JObject,
|
||||
) -> jboolean {
|
||||
match block_on(need_login()) {
|
||||
Ok(login) => login.into(),
|
||||
Err(e) => {
|
||||
error!("Failed to check if user is authorized: {}", e);
|
||||
false.into()
|
||||
}
|
||||
pub fn get_messages(packed: String) -> Result<Vec<Message>> {
|
||||
let chat = PackedChat::from_hex(&packed).unwrap();
|
||||
let client = CLIENT.get().ok_or(NativeError::Initialization)?;
|
||||
|
||||
block_on(async {
|
||||
let mut result = Vec::new();
|
||||
let mut messages = client.iter_messages(chat);
|
||||
while let Some(message) = messages.next().await.map_err(|_| NativeError::Network)? {
|
||||
result.push(message);
|
||||
}
|
||||
Ok(result)
|
||||
})
|
||||
.map(|messages| messages.into_iter().map(adapt_message).collect())
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_requestLoginCode(
|
||||
env: JNIEnv,
|
||||
_: JObject,
|
||||
phone: JString,
|
||||
) -> jlong {
|
||||
let phone = CString::from(CStr::from_ptr(env.get_string(phone).unwrap().as_ptr()));
|
||||
|
||||
match block_on(request_login_code(phone.to_str().unwrap())) {
|
||||
Ok(token) => Box::into_raw(Box::new(token)) as jlong,
|
||||
Err(e) => {
|
||||
error!("Failed to request login code: {}", e);
|
||||
0 as jlong
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_signIn(
|
||||
env: JNIEnv,
|
||||
_: JObject,
|
||||
token_ptr: jlong,
|
||||
code: JString,
|
||||
) {
|
||||
let token = *Box::from_raw(token_ptr as *mut LoginToken);
|
||||
let code = CString::from(CStr::from_ptr(env.get_string(code).unwrap().as_ptr()));
|
||||
|
||||
match block_on(sign_in(token, code.to_str().unwrap())) {
|
||||
Ok(_) => info!("Sign in success"),
|
||||
Err(e) => error!("Failed to sign in: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_getDialogs(
|
||||
_: JNIEnv,
|
||||
_: JObject,
|
||||
) -> jlong {
|
||||
match block_on(get_dialogs()) {
|
||||
Ok(dialogs) => Box::into_raw(Box::new(dialogs)) as jlong,
|
||||
Err(e) => {
|
||||
error!("Failed to get dialogs: {}", e);
|
||||
0 as jlong
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_dialogCount(
|
||||
_: JNIEnv,
|
||||
_: JObject,
|
||||
dialogs_ptr: jlong,
|
||||
) -> jint {
|
||||
let dialogs = Box::leak(Box::from_raw(dialogs_ptr as *mut Vec<Dialog>));
|
||||
dialogs.len() as jint
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_dialogPacked(
|
||||
env: JNIEnv,
|
||||
_: JObject,
|
||||
dialogs_ptr: jlong,
|
||||
index: jint,
|
||||
) -> jstring {
|
||||
let dialogs = Box::leak(Box::from_raw(dialogs_ptr as *mut Vec<Dialog>));
|
||||
|
||||
let packed = dialogs[index as usize].chat().pack().to_hex();
|
||||
let output = env.new_string(packed).unwrap();
|
||||
output.into_inner()
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_dialogTitle(
|
||||
env: JNIEnv,
|
||||
_: JObject,
|
||||
dialogs_ptr: jlong,
|
||||
index: jint,
|
||||
) -> jstring {
|
||||
let dialogs = Box::leak(Box::from_raw(dialogs_ptr as *mut Vec<Dialog>));
|
||||
|
||||
let title = dialogs[index as usize].chat().name();
|
||||
let output = env.new_string(title).unwrap();
|
||||
output.into_inner()
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_freeDialogs(
|
||||
_: JNIEnv,
|
||||
_: JObject,
|
||||
dialogs_ptr: jlong,
|
||||
) {
|
||||
let _ = Box::from_raw(dialogs_ptr as *mut Vec<Dialog>);
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_sendMessage(
|
||||
env: JNIEnv,
|
||||
_: JObject,
|
||||
packed: JString,
|
||||
text: JString,
|
||||
) {
|
||||
let packed = CString::from(CStr::from_ptr(env.get_string(packed).unwrap().as_ptr()));
|
||||
let text = CString::from(CStr::from_ptr(env.get_string(text).unwrap().as_ptr()));
|
||||
|
||||
let packed = PackedChat::from_hex(packed.to_str().unwrap()).unwrap();
|
||||
match block_on(send_message(packed, text.to_str().unwrap())) {
|
||||
Ok(_) => info!("Message sent"),
|
||||
Err(e) => error!("Failed to send message: {}", e),
|
||||
}
|
||||
pub fn send_message(packed: String, text: String) -> Result<Message> {
|
||||
let chat = PackedChat::from_hex(&packed).unwrap();
|
||||
let client = CLIENT.get().ok_or(NativeError::Initialization)?;
|
||||
block_on(client.send_message(chat, text))
|
||||
.map(adapt_message)
|
||||
.map_err(|_| NativeError::Network)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,88 @@
|
|||
[Error]
|
||||
enum NativeError {
|
||||
"Initialization",
|
||||
"Database",
|
||||
"Network",
|
||||
};
|
||||
|
||||
enum MessageAck {
|
||||
"Received",
|
||||
"Seen",
|
||||
"Sent",
|
||||
};
|
||||
|
||||
enum Formatting {
|
||||
"Unknown",
|
||||
"Mention",
|
||||
"HashTag",
|
||||
"BotCommand",
|
||||
"Url",
|
||||
"Email",
|
||||
"Bold",
|
||||
"Italic",
|
||||
"Code",
|
||||
"Pre", // language:string
|
||||
"TextUrl", // url:string
|
||||
"MentionName", // user_id:long
|
||||
"Phone",
|
||||
"CashTag",
|
||||
"Underline",
|
||||
"Strike",
|
||||
"Blockquote",
|
||||
"BankCard",
|
||||
"Spoiler",
|
||||
"CustomEmoji", // document_id:long
|
||||
};
|
||||
|
||||
dictionary TextFormat {
|
||||
Formatting format;
|
||||
i32 offset;
|
||||
i32 length;
|
||||
string? extra;
|
||||
};
|
||||
|
||||
dictionary Message {
|
||||
i32 id;
|
||||
string sender;
|
||||
string text;
|
||||
timestamp date;
|
||||
timestamp? edit_date;
|
||||
sequence<TextFormat> formatting;
|
||||
};
|
||||
|
||||
dictionary MessagePreview {
|
||||
string sender;
|
||||
string text;
|
||||
timestamp date;
|
||||
MessageAck ack;
|
||||
};
|
||||
|
||||
dictionary Dialog {
|
||||
string id;
|
||||
string title;
|
||||
MessagePreview? last_message;
|
||||
boolean pinned;
|
||||
};
|
||||
|
||||
interface LoginProcedure {
|
||||
constructor();
|
||||
[Throws=NativeError]
|
||||
void request_login_code(string phone);
|
||||
[Throws=NativeError]
|
||||
void sign_in(string code);
|
||||
};
|
||||
|
||||
namespace talaria {
|
||||
[Throws=NativeError]
|
||||
void init_database(string path);
|
||||
[Throws=NativeError]
|
||||
void init_client();
|
||||
[Throws=NativeError]
|
||||
boolean need_login();
|
||||
[Throws=NativeError]
|
||||
sequence<Dialog> get_dialogs();
|
||||
[Throws=NativeError]
|
||||
sequence<Message> get_messages(string packed);
|
||||
[Throws=NativeError]
|
||||
Message send_message(string packed, string text);
|
||||
};
|
Loading…
Reference in New Issue