Compare commits

...

32 Commits

Author SHA1 Message Date
Lonami Exo 49fa3c4d5a Render formatted text in the ChatScreen 2022-10-29 13:35:05 +02:00
Lonami Exo d350d1a048 Load messages from API 2022-10-29 13:06:38 +02:00
Lonami Exo 43421f6e54 Use UDL interface to avoid passing around pointers 2022-10-27 20:12:41 +02:00
Lonami Exo 912949a079 Use snake_case in UDL
uniffi automatically converts to camelCase for Kotlin,
but it won't convert to snake_case for Rust.
2022-10-27 19:48:47 +02:00
Lonami Exo 1574ce683c Inline native methods
The split no longer makes sense now that uniffi is the bridge.
2022-10-27 19:44:48 +02:00
Lonami Exo d11a00d062 Make use of errors in the UDL 2022-10-27 19:42:21 +02:00
Lonami Exo 004a921299 Use UDL dictionaries to simplify return values 2022-10-27 18:42:48 +02:00
Lonami Exo 1a56b03614 Move MessageAck enum to UDL 2022-10-27 18:08:57 +02:00
Lonami Exo 812597f027 Replace manual native bindings with UniFFI 2022-10-27 17:42:55 +02:00
Lonami Exo fd1dac1045 Use trailing comma to improve future diffs 2022-10-25 11:33:45 +02:00
Lonami Exo b5dddcef02 Add missing modifier use for TopAppBar 2022-10-24 15:51:03 +02:00
Lonami Exo ea53d3cb1d Add modifier parameter to composables
For context, see:
https://android.googlesource.com/platform/frameworks/support/+/androidx-main/compose/docs/compose-api-guidelines.md#elements-accept-and-respect-a-modifier-parameter

> Element functions MUST accept a parameter of type Modifier.
> This parameter MUST be named modifier and MUST appear as the
> first optional parameter in the element function's parameter list
2022-10-24 14:15:20 +02:00
Lonami Exo da136a1990 Add a navigation drawer 2022-10-24 13:58:47 +02:00
Lonami Exo 35ed8e12c6 Remove unnecessary Card for Dialog 2022-10-24 12:44:41 +02:00
Lonami Exo 0db1599cf7 Remove unnecessary comments from template and stray semicolon 2022-10-24 11:40:29 +02:00
Lonami Exo 50a2403a85 Reformat code in theme package 2022-10-24 11:31:23 +02:00
Lonami Exo 07d0e5e505 Wrap freeDialogs call inside a finally block
For instance, LocalDateTime.parse could fail,
and then the dialogs would be leaked.
2022-10-23 20:27:15 +02:00
Lonami Exo 442b019134 Load more dialog data from the API 2022-10-23 20:25:45 +02:00
Lonami Exo 51ef704cf7 Improve dialog screen by displaying more info 2022-10-23 20:01:24 +02:00
Lonami Exo 17d7805654 Bump minSdk to 26
This lets us use LocalDate.
The minSdk can be turned back if needed but for now this simplifies things.
2022-10-23 19:06:18 +02:00
Lonami Exo 6c652e3e1b Fix references to DialogRepository and other imports 2022-10-21 20:34:25 +02:00
Lonami Exo 999c59e9ec Create a separate package for UI screens and state 2022-10-21 20:31:29 +02:00
Lonami Exo c627db973c Rename model package to models for consistency 2022-10-21 20:27:33 +02:00
Lonami Exo fc7dc68b62 Rename Source to Repository
This is more in line with the repository
google-developer-training/basic-android-kotlin-compose-training-mars-photos
which contains good practices.
2022-10-21 20:24:29 +02:00
Lonami Exo 189c1e8db8 Use mock dialog values in preview 2022-10-21 14:19:34 +02:00
Lonami Exo 7bfbcc955c Add env var to support connecting to test servers 2022-10-20 21:18:56 +02:00
Lonami Exo 0f412f7334 Load API ID and hash from env 2022-10-20 21:18:33 +02:00
Lonami Exo a341466749 Don't show login screen when already logged in 2022-10-20 20:35:07 +02:00
Lonami Exo cd37c5aa14 Run cargo fmt on the project
IDE Action on Save seemed to be messing with the ordering.
2022-10-20 20:06:22 +02:00
Lonami Exo 0919c4a13c Persist and load login details to a local database 2022-10-20 19:59:11 +02:00
Lonami Exo 5e1f253dd3 Build Rust side only for arm64
This reduces the build time since the other targets are unused.
2022-10-17 17:44:31 +02:00
Lonami Exo f2cd0eb69a Remove unnecessary Box roundtrip in Dialog accessors 2022-10-13 17:06:44 +02:00
37 changed files with 1473 additions and 582 deletions

View File

@ -1,6 +1,7 @@
<component name="ProjectCodeStyleConfiguration"> <component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173"> <code_scheme name="Project" version="173">
<JetCodeStyleSettings> <JetCodeStyleSettings>
<option name="ALLOW_TRAILING_COMMA" value="true" />
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" /> <option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings> </JetCodeStyleSettings>
<codeStyleSettings language="XML"> <codeStyleSettings language="XML">

View File

@ -10,7 +10,7 @@ android {
defaultConfig { defaultConfig {
applicationId "dev.lonami.talaria" applicationId "dev.lonami.talaria"
minSdk 21 minSdk 26
targetSdk 33 targetSdk 33
versionCode 1 versionCode 1
versionName "0.1.0" versionName "0.1.0"
@ -57,6 +57,7 @@ dependencies {
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version" implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
implementation 'androidx.compose.material:material:1.2.1' implementation 'androidx.compose.material:material:1.2.1'
implementation "androidx.navigation:navigation-compose:2.5.2" implementation "androidx.navigation:navigation-compose:2.5.2"
implementation "net.java.dev.jna:jna:5.12.0@aar"
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' 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 // See https://github.com/mozilla/rust-android-gradle for other targets and required toolchains
cargo { cargo {
module = "../native" module = "../native"
libname = "talaria" libname = "uniffi_talaria"
targets = ["arm64", "arm", "x86"] targets = ["arm64"]
profile = 'release' profile = 'release'
} }
@ -78,3 +79,14 @@ tasks.whenTaskAdded { task ->
task.dependsOn 'cargoBuild' 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")
}

View File

@ -7,15 +7,15 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface import androidx.compose.material.Surface
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import dev.lonami.talaria.bindings.Native
import dev.lonami.talaria.ui.theme.TalariaTheme import dev.lonami.talaria.ui.theme.TalariaTheme
import uniffi.talaria.initClient
import uniffi.talaria.initDatabase
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { setContent {
TalariaTheme { TalariaTheme {
// A surface container using the 'background' color from the theme
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background color = MaterialTheme.colors.background
@ -25,6 +25,7 @@ class MainActivity : ComponentActivity() {
} }
} }
Native.initClient() initDatabase(getDatabasePath("talaria.db").path)
initClient()
} }
} }

View File

@ -1,20 +1,29 @@
package dev.lonami.talaria package dev.lonami.talaria
import androidx.annotation.StringRes 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.*
import androidx.compose.material.icons.Icons 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.runtime.*
import androidx.compose.ui.Modifier 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.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import dev.lonami.talaria.ui.ChatScreen import dev.lonami.talaria.ui.screens.ChatScreen
import dev.lonami.talaria.ui.DialogScreen import dev.lonami.talaria.ui.screens.DialogScreen
import dev.lonami.talaria.ui.LoginScreen 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) { enum class TalariaScreen(@StringRes val title: Int) {
Login(title = R.string.app_name), Login(title = R.string.app_name),
@ -22,9 +31,96 @@ enum class TalariaScreen(@StringRes val title: Int) {
Chat(title = R.string.chat), Chat(title = R.string.chat),
} }
enum class DrawerAction {
SelectAccount,
AddAccount,
SavedMessages,
Contacts,
NewChat,
Settings,
Help,
}
@Composable @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( TopAppBar(
modifier = modifier,
title = { Text(stringResource(currentScreen.title)) }, title = { Text(stringResource(currentScreen.title)) },
navigationIcon = { navigationIcon = {
if (canNavigateBack) { if (canNavigateBack) {
@ -34,48 +130,104 @@ fun TalariaAppBar(currentScreen: TalariaScreen, canNavigateBack: Boolean, naviga
contentDescription = stringResource(R.string.back_button) 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 @Composable
fun TalariaApp() { fun TalariaApp(modifier: Modifier = Modifier) {
val navController = rememberNavController() val navController = rememberNavController()
val backStackEntry by navController.currentBackStackEntryAsState() val backStackEntry by navController.currentBackStackEntryAsState()
val currentScreen = val currentScreen =
TalariaScreen.valueOf(backStackEntry?.destination?.route ?: TalariaScreen.Login.name) TalariaScreen.valueOf(backStackEntry?.destination?.route ?: TalariaScreen.Login.name)
val loggedIn by remember { mutableStateOf(!needLogin()) }
var selectedDialog by remember { mutableStateOf("") } 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( Scaffold(
modifier = modifier,
topBar = { topBar = {
TalariaAppBar( TalariaAppBar(
currentScreen, currentScreen,
canNavigateBack = navController.previousBackStackEntry != null, canNavigateBack = navController.previousBackStackEntry != null,
navigateUp = { navController.navigateUp() } navigateUp = { navController.navigateUp() },
openDrawer = { toggleDrawer(drawerState.isClosed) }
) )
} }
) { innerPadding -> ) { innerPadding ->
NavHost( ModalDrawer(
navController = navController, drawerState = drawerState,
startDestination = TalariaScreen.Login.name, gesturesEnabled = drawerState.isOpen,
Modifier.padding(innerPadding) drawerContent = {
) { Drawer(onSelect = {})
composable(route = TalariaScreen.Dialog.name) { }) {
DialogScreen(onDialogSelected = {
selectedDialog = it NavHost(
navController.navigate(TalariaScreen.Chat.name) navController = navController,
}) startDestination = if (loggedIn) {
} TalariaScreen.Login.name
composable(route = TalariaScreen.Chat.name) { } else {
ChatScreen(selectedDialog) TalariaScreen.Dialog.name
} },
composable(route = TalariaScreen.Login.name) { Modifier.padding(innerPadding)
LoginScreen(onConfirmOtp = { ) {
navController.navigate(TalariaScreen.Dialog.name) composable(route = TalariaScreen.Dialog.name) {
}) DialogScreen(onDialogSelected = {
selectedDialog = it
chatViewModel.loadMessages(it)
navController.navigate(TalariaScreen.Chat.name)
})
}
composable(route = TalariaScreen.Chat.name) {
ChatScreen(selectedDialog, chatViewModel = chatViewModel)
}
composable(route = TalariaScreen.Login.name) {
LoginScreen(onConfirmOtp = {
navController.navigate(TalariaScreen.Dialog.name)
})
}
} }
} }
} }
} }
@Preview
@Composable
fun TopBarPreview() {
TalariaAppBar(
currentScreen = TalariaScreen.Dialog,
canNavigateBack = false,
navigateUp = {},
openDrawer = {},
)
}
@Preview
@Composable
fun NavDrawerPreview() {
Surface {
Drawer(onSelect = {})
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +0,0 @@
package dev.lonami.talaria.model
data class Dialog(val title: String, val lastMessage: String, val pinned: Boolean)

View File

@ -1,3 +0,0 @@
package dev.lonami.talaria.model
data class Message(val sender: String, val text: String)

View File

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

View File

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

View File

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

View File

@ -1,5 +0,0 @@
package dev.lonami.talaria.ui
import dev.lonami.talaria.model.Dialog
data class DialogUiState(val dialogs: List<Dialog> = listOf())

View File

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

View File

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

View File

@ -1,4 +1,4 @@
package dev.lonami.talaria.ui package dev.lonami.talaria.ui.screens
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import dev.lonami.talaria.R import dev.lonami.talaria.R
import dev.lonami.talaria.bindings.Native
import dev.lonami.talaria.ui.theme.TalariaTheme import dev.lonami.talaria.ui.theme.TalariaTheme
import uniffi.talaria.LoginProcedure
enum class LoginStage { enum class LoginStage {
ASK_PHONE, ASK_PHONE,
@ -30,71 +30,85 @@ fun isPhoneValid(phone: String): Boolean = phone.trim('+', ' ').isNotEmpty()
fun isLoginCodeValid(code: String): Boolean = code.trim().count { it.isDigit() } == 5 fun isLoginCodeValid(code: String): Boolean = code.trim().count { it.isDigit() } == 5
@Composable @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 val focusManager = LocalFocusManager.current
Text(stringResource(R.string.enter_phone)) Column(modifier = modifier) {
TextField( Text(stringResource(R.string.enter_phone))
phone, TextField(
label = { Text(stringResource(R.string.phone_international)) }, phone,
placeholder = { Text(stringResource(R.string.phone_example)) }, label = { Text(stringResource(R.string.phone_international)) },
singleLine = true, placeholder = { Text(stringResource(R.string.phone_example)) },
keyboardOptions = KeyboardOptions( singleLine = true,
keyboardType = KeyboardType.Phone, keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done keyboardType = KeyboardType.Phone,
), imeAction = ImeAction.Done
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), ),
modifier = Modifier.fillMaxWidth(), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChange = onPhoneChanged modifier = Modifier.fillMaxWidth(),
) onValueChange = onPhoneChanged
Spacer(Modifier.height(16.dp)) )
Button( Spacer(Modifier.height(16.dp))
enabled = isPhoneValid(phone), Button(
modifier = Modifier.fillMaxWidth(), enabled = isPhoneValid(phone),
onClick = onSendCode modifier = Modifier.fillMaxWidth(),
) { onClick = onSendCode
Text(stringResource(R.string.send_otp)) ) {
Text(stringResource(R.string.send_otp))
}
} }
} }
@Composable @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 val focusManager = LocalFocusManager.current
Text(stringResource(R.string.enter_otp)) Column(modifier = modifier) {
TextField( Text(stringResource(R.string.enter_otp))
otp, TextField(
label = { Text(stringResource(R.string.otp)) }, otp,
placeholder = { Text(stringResource(R.string.otp_example)) }, label = { Text(stringResource(R.string.otp)) },
singleLine = true, placeholder = { Text(stringResource(R.string.otp_example)) },
keyboardOptions = KeyboardOptions( singleLine = true,
keyboardType = KeyboardType.Number, keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done keyboardType = KeyboardType.Number,
), imeAction = ImeAction.Done
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), ),
modifier = Modifier.fillMaxWidth(), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChange = onOtpChanged modifier = Modifier.fillMaxWidth(),
) onValueChange = onOtpChanged
Spacer(Modifier.height(16.dp)) )
Button( Spacer(Modifier.height(16.dp))
enabled = isLoginCodeValid(otp), Button(
modifier = Modifier.fillMaxWidth(), enabled = isLoginCodeValid(otp),
onClick = onConfirmOtp modifier = Modifier.fillMaxWidth(),
) { onClick = onConfirmOtp
Text(stringResource(R.string.do_login)) ) {
Text(stringResource(R.string.do_login))
}
} }
} }
@Composable @Composable
fun LoginScreen(onConfirmOtp: () -> Unit) { fun LoginScreen(onConfirmOtp: () -> Unit, modifier: Modifier = Modifier) {
var stage by remember { mutableStateOf(LoginStage.ASK_PHONE) } var stage by remember { mutableStateOf(LoginStage.ASK_PHONE) }
var phone by remember { mutableStateOf("") } var phone by remember { mutableStateOf("") }
var otp by remember { mutableStateOf("") } var otp by remember { mutableStateOf("") }
var tokenPtr by remember { mutableStateOf(0L) } val loginProcedure by remember { mutableStateOf(LoginProcedure()) }
Column( Column(
modifier = Modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
@ -113,7 +127,7 @@ fun LoginScreen(onConfirmOtp: () -> Unit) {
phone, phone,
onPhoneChanged = { phone = it }, onPhoneChanged = { phone = it },
onSendCode = { onSendCode = {
tokenPtr = Native.requestLoginCode(phone) loginProcedure.requestLoginCode(phone)
stage = LoginStage.ASK_CODE stage = LoginStage.ASK_CODE
} }
) )
@ -121,7 +135,7 @@ fun LoginScreen(onConfirmOtp: () -> Unit) {
otp, otp,
onOtpChanged = { otp = it }, onOtpChanged = { otp = it },
onConfirmOtp = { onConfirmOtp = {
Native.signIn(tokenPtr, otp) loginProcedure.signIn(otp)
onConfirmOtp() onConfirmOtp()
} }
) )

View File

@ -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()) data class ChatUiState(val messages: MutableList<Message> = mutableListOf())

View File

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

View File

@ -0,0 +1,5 @@
package dev.lonami.talaria.ui.state
import uniffi.talaria.Dialog
data class DialogUiState(val dialogs: List<Dialog> = listOf())

View File

@ -1,17 +1,19 @@
package dev.lonami.talaria.ui package dev.lonami.talaria.ui.state
import androidx.lifecycle.ViewModel 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
class DialogViewModel : ViewModel() { class DialogViewModel(private val repository: DialogRepository = NativeDialogRepository()) :
ViewModel() {
private val _uiState = MutableStateFlow(DialogUiState()) private val _uiState = MutableStateFlow(DialogUiState())
val uiState: StateFlow<DialogUiState> = _uiState.asStateFlow() val uiState: StateFlow<DialogUiState> = _uiState.asStateFlow()
private fun loadDialogs() { private fun loadDialogs() {
_uiState.value = DialogUiState(DialogSource.loadDialogs()) _uiState.value = DialogUiState(repository.loadDialogs())
} }
init { init {

View File

@ -5,7 +5,7 @@ import androidx.compose.material.Shapes
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
val Shapes = Shapes( val Shapes = Shapes(
small = RoundedCornerShape(4.dp), small = RoundedCornerShape(4.dp),
medium = RoundedCornerShape(4.dp), medium = RoundedCornerShape(4.dp),
large = RoundedCornerShape(0.dp) large = RoundedCornerShape(0.dp)
) )

View File

@ -7,24 +7,15 @@ import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
private val DarkColorPalette = darkColors( private val DarkColorPalette = darkColors(
primary = Purple200, primary = Purple200,
primaryVariant = Purple700, primaryVariant = Purple700,
secondary = Teal200 secondary = Teal200
) )
private val LightColorPalette = lightColors( private val LightColorPalette = lightColors(
primary = Purple500, primary = Purple500,
primaryVariant = Purple700, primaryVariant = Purple700,
secondary = Teal200 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 @Composable
@ -36,9 +27,9 @@ fun TalariaTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composabl
} }
MaterialTheme( MaterialTheme(
colors = colors, colors = colors,
typography = Typography, typography = Typography,
shapes = Shapes, shapes = Shapes,
content = content content = content
) )
} }

View File

@ -6,23 +6,10 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography( val Typography = Typography(
body1 = TextStyle( 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, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 12.sp fontSize = 16.sp
) )
*/ )
)

View File

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

View File

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

View File

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

View File

@ -15,4 +15,8 @@
<string name="back_button">Back</string> <string name="back_button">Back</string>
<string name="dialog">All chats</string> <string name="dialog">All chats</string>
<string name="chat">Messages</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> </resources>

View File

@ -4,8 +4,10 @@ buildscript {
} }
}// Top-level build file where you can add configuration options common to all sub-projects/modules. }// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins { plugins {
id 'com.android.application' version '7.3.0' apply false // uniffi-recommended Gradle script fails with Android plugin 7.3.1.
id 'com.android.library' version '7.3.0' apply false // 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.jetbrains.kotlin.android' version '1.6.10' apply false
id "org.mozilla.rust-android-gradle.rust-android" version "0.9.3" id "org.mozilla.rust-android-gradle.rust-android" version "0.9.3"
} }

View File

@ -4,19 +4,24 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[lib] [lib]
name = "talaria" name = "uniffi_talaria"
crate-type = ["cdylib"] crate-type = ["cdylib"]
[dependencies] [dependencies]
jni = { version = "0.10.2", default-features = false } 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 # 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-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"] } tokio = { version = "1.5.0", features = ["full"] }
log = "0.4.14" log = "0.4.14"
android_logger = "0.11.1" android_logger = "0.11.1"
once_cell = "1.15.0" once_cell = "1.15.0"
sqlite = "0.27.0"
[build-dependencies]
uniffi_build = "0.21.0"
[profile.release] [profile.release]
lto = true lto = true

3
native/build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
uniffi_build::generate_scaffolding("src/talaria.udl").unwrap();
}

172
native/src/db/mod.rs Normal file
View File

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

14
native/src/db/model.rs Normal file
View File

@ -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]>,
}

27
native/src/db/utils.rs Normal file
View File

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

View File

@ -1,30 +1,139 @@
#![cfg(target_os = "android")] #![cfg(target_os = "android")]
#![allow(non_snake_case)] #![allow(non_snake_case)]
use std::ffi::{CStr, CString}; mod db;
use std::future::Future;
use grammers_client::{Client, Config}; use grammers_client::types::{LoginToken, Message as OgMessage};
use grammers_client::types::{Dialog, LoginToken}; use grammers_client::{Client, Config, InitParams};
use grammers_session::{PackedChat, Session}; use grammers_session::{PackedChat, Session, UpdateState};
use jni::JNIEnv; use grammers_tl_types as tl;
use jni::objects::{JObject, JString};
use jni::sys::{jboolean, jint, jlong, jstring};
use log; use log;
use log::{error, info, Level}; use log::{error, info, Level};
use once_cell::sync::OnceCell; 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;
use tokio::runtime::Runtime; use tokio::runtime::Runtime;
include!(concat!(env!("OUT_DIR"), "/talaria.uniffi.rs"));
const LOG_MIN_LEVEL: Level = Level::Trace; const LOG_MIN_LEVEL: Level = Level::Trace;
const LOG_TAG: &str = ".native.talari"; const LOG_TAG: &str = ".native.talari";
const API_ID: i32 = 0; const API_ID: i32 = {
const API_HASH: &str = ""; let mut index = 0;
let mut value = 0;
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>; 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 RUNTIME: OnceCell<Runtime> = OnceCell::new();
static CLIENT: OnceCell<Client> = 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 { fn block_on<F: Future>(future: F) -> F::Output {
if RUNTIME.get().is_none() { if RUNTIME.get().is_none() {
@ -41,7 +150,22 @@ fn block_on<F: Future>(future: F) -> F::Output {
RUNTIME.get().unwrap().block_on(future) 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::init_once(
android_logger::Config::default() android_logger::Config::default()
.with_min_level(LOG_MIN_LEVEL) .with_min_level(LOG_MIN_LEVEL)
@ -53,188 +177,275 @@ async fn init_client() -> Result<()> {
return Ok(()); 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..."); info!("Connecting to Telegram...");
let client = Client::connect(Config { let session = Session::new();
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_id: API_ID,
api_hash: API_HASH.to_string(), api_hash: API_HASH.to_string(),
params: Default::default(), params: InitParams {
}) server_addr: if SERVER_ADDR.is_empty() {
.await?; None
} else {
Some(SERVER_ADDR.parse().unwrap())
},
..Default::default()
},
}))
.map_err(|_| NativeError::Network)?;
info!("Connected!"); info!("Connected!");
CLIENT CLIENT
.set(client) .set(client)
.map_err(|_| "Client was already initialized")?; .map_err(|_| NativeError::Initialization)?;
Ok(()) Ok(())
} }
async fn need_login() -> Result<bool> { pub fn need_login() -> Result<bool> {
let client = CLIENT.get().ok_or("Client not initialized")?; let client = CLIENT.get().ok_or(NativeError::Initialization)?;
Ok(client.is_authorized().await?) block_on(client.is_authorized()).map_err(|_| NativeError::Network)
} }
async fn request_login_code(phone: &str) -> Result<LoginToken> { impl LoginProcedure {
let client = CLIENT.get().ok_or("Client not initialized")?; fn new() -> Self {
let token = client.request_login_code(&phone, API_ID, API_HASH).await?; Self {
Ok(token) 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?;
Ok(())
}
async fn get_dialogs() -> Result<Vec<Dialog>> {
let client = CLIENT.get().ok_or("Client not initialized")?;
let mut result = Vec::new();
let mut dialogs = client.iter_dialogs();
while let Some(dialog) = dialogs.next().await? {
result.push(dialog);
}
Ok(result)
}
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(())
}
#[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),
}
}
#[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()
} }
} }
}
#[no_mangle] fn request_login_code(&self, phone: String) -> Result<()> {
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_requestLoginCode( let client = CLIENT.get().ok_or(NativeError::Initialization)?;
env: JNIEnv, let token = block_on(client.request_login_code(&phone, API_ID, API_HASH))
_: JObject, .map_err(|_| NativeError::Network)?;
phone: JString, *self.token.lock().unwrap() = Some(token);
) -> jlong { Ok(())
let phone = CString::from(CStr::from_ptr(env.get_string(phone).unwrap().as_ptr())); }
match block_on(request_login_code(phone.to_str().unwrap())) { fn sign_in(&self, code: String) -> Result<()> {
Ok(token) => Box::into_raw(Box::new(token)) as jlong, let token = self
Err(e) => { .token
error!("Failed to request login code: {}", e); .lock()
0 as jlong .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);
} }
}
}
#[no_mangle] if let Some(state) = s.get_state() {
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_signIn( session.pts = Some(state.pts);
env: JNIEnv, session.qts = Some(state.qts);
_: JObject, session.seq = Some(state.seq);
token_ptr: jlong, session.date = Some(state.date);
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
} }
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(())
} }
} }
#[no_mangle] pub fn get_dialogs() -> Result<Vec<Dialog>> {
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_dialogCount( let client = CLIENT.get().ok_or(NativeError::Initialization)?;
_: JNIEnv,
_: JObject, block_on(async {
dialogs_ptr: jlong, let mut result = Vec::new();
) -> jint { let mut dialogs = client.iter_dialogs();
let dialogs = Box::leak(Box::from_raw(dialogs_ptr as *mut Vec<Dialog>)); while let Some(dialog) = dialogs.next().await.map_err(|_| NativeError::Network)? {
dialogs.len() as jint 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()
})
} }
#[no_mangle] fn adapt_message(m: OgMessage) -> Message {
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_dialogPacked( Message {
env: JNIEnv, id: m.id(),
_: JObject, sender: if let Some(sender) = m.sender() {
dialogs_ptr: jlong, sender.name().to_string()
index: jint, } else {
) -> jstring { "unknown".to_string()
let dialogs = Box::leak(Box::from_raw(dialogs_ptr as *mut Vec<Dialog>)); },
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;
let packed = dialogs[index as usize].chat().pack().to_hex(); macro_rules! tf {
let output = env.new_string(packed).unwrap(); ($formatting:ident($entity:ident)) => {
output.into_inner() 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] entities
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_dialogTitle( .into_iter()
env: JNIEnv, .map(|e| match e {
_: JObject, ME::Unknown(e) => tf!(Unknown(e)),
dialogs_ptr: jlong, ME::Mention(e) => tf!(Mention(e)),
index: jint, ME::Hashtag(e) => tf!(HashTag(e)),
) -> jstring { ME::BotCommand(e) => tf!(BotCommand(e)),
let dialogs = Box::leak(Box::from_raw(dialogs_ptr as *mut Vec<Dialog>)); ME::Url(e) => tf!(Url(e)),
ME::Email(e) => tf!(Email(e)),
let title = dialogs[index as usize].chat().name(); ME::Bold(e) => tf!(Bold(e)),
let output = env.new_string(title).unwrap(); ME::Italic(e) => tf!(Italic(e)),
output.into_inner() 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()))),
#[no_mangle] ME::MentionName(e) => tf!(MentionName(e).extra(Some(e.user_id.to_string()))),
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_freeDialogs( ME::InputMessageEntityMentionName(e) => tf!(Unknown(e)),
_: JNIEnv, ME::Phone(e) => tf!(Phone(e)),
_: JObject, ME::Cashtag(e) => tf!(CashTag(e)),
dialogs_ptr: jlong, ME::Underline(e) => tf!(Underline(e)),
) { ME::Strike(e) => tf!(Strike(e)),
let _ = Box::from_raw(dialogs_ptr as *mut Vec<Dialog>); ME::Blockquote(e) => tf!(Blockquote(e)),
} ME::BankCard(e) => tf!(BankCard(e)),
ME::Spoiler(e) => tf!(Spoiler(e)),
#[no_mangle] ME::CustomEmoji(e) => {
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_sendMessage( tf!(CustomEmoji(e).extra(Some(e.document_id.to_string())))
env: JNIEnv, }
_: JObject, })
packed: JString, .collect()
text: JString, } else {
) { Vec::new()
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 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())
}
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)
}

88
native/src/talaria.udl Normal file
View File

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