Compare commits

..

1 Commits

Author SHA1 Message Date
expectocode 0fef9a8fec WIP implement 2fa login 2022-10-13 08:18:51 +01:00
38 changed files with 770 additions and 1554 deletions

View File

@ -1,7 +1,6 @@
<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 26 minSdk 21
targetSdk 33 targetSdk 33
versionCode 1 versionCode 1
versionName "0.1.0" versionName "0.1.0"
@ -57,7 +57,6 @@ 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'
@ -69,8 +68,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 = "uniffi_talaria" libname = "talaria"
targets = ["arm64"] targets = ["arm64", "arm", "x86"]
profile = 'release' profile = 'release'
} }
@ -79,14 +78,3 @@ 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,7 +25,6 @@ class MainActivity : ComponentActivity() {
} }
} }
initDatabase(getDatabasePath("talaria.db").path) Native.initClient()
initClient()
} }
} }

View File

@ -1,29 +1,20 @@
package dev.lonami.talaria package dev.lonami.talaria
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.Image import androidx.compose.foundation.layout.padding
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.* import androidx.compose.material.icons.filled.ArrowBack
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.screens.ChatScreen import dev.lonami.talaria.ui.ChatScreen
import dev.lonami.talaria.ui.screens.DialogScreen import dev.lonami.talaria.ui.DialogScreen
import dev.lonami.talaria.ui.screens.LoginScreen import dev.lonami.talaria.ui.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),
@ -31,96 +22,9 @@ 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 DrawerAction( fun TalariaAppBar(currentScreen: TalariaScreen, canNavigateBack: Boolean, navigateUp: () -> Unit) {
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) {
@ -130,104 +34,48 @@ fun TalariaAppBar(
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(modifier: Modifier = Modifier) { fun TalariaApp() {
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 ->
ModalDrawer(
drawerState = drawerState,
gesturesEnabled = drawerState.isOpen,
drawerContent = {
Drawer(onSelect = {})
}) {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = if (loggedIn) { startDestination = TalariaScreen.Login.name,
TalariaScreen.Login.name
} else {
TalariaScreen.Dialog.name
},
Modifier.padding(innerPadding) Modifier.padding(innerPadding)
) { ) {
composable(route = TalariaScreen.Dialog.name) { composable(route = TalariaScreen.Dialog.name) {
DialogScreen(onDialogSelected = { DialogScreen(onDialogSelected = {
selectedDialog = it selectedDialog = it
chatViewModel.loadMessages(it)
navController.navigate(TalariaScreen.Chat.name) navController.navigate(TalariaScreen.Chat.name)
}) })
} }
composable(route = TalariaScreen.Chat.name) { composable(route = TalariaScreen.Chat.name) {
ChatScreen(selectedDialog, chatViewModel = chatViewModel) ChatScreen(selectedDialog)
} }
composable(route = TalariaScreen.Login.name) { composable(route = TalariaScreen.Login.name) {
LoginScreen(onConfirmOtp = { LoginScreen(onLoginComplete = {
navController.navigate(TalariaScreen.Dialog.name) 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

@ -0,0 +1,21 @@
package dev.lonami.talaria.bindings
object Native {
init {
System.loadLibrary("talaria")
}
external fun initClient()
external fun needLogin(): Boolean
external fun requestLoginCode(phone: String): Long
// Returns true if sign in complete, false if 2fa password needed
// TODO: more rich return type including password hint / type of error
external fun signIn(tokenPtr: Long, code: String): Boolean
external fun checkPassword(password: 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

@ -1,56 +0,0 @@
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

@ -0,0 +1,25 @@
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

@ -1,46 +0,0 @@
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

@ -0,0 +1,11 @@
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

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

View File

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

View File

@ -0,0 +1,110 @@
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,5 +1,5 @@
package dev.lonami.talaria.ui.state package dev.lonami.talaria.ui
import uniffi.talaria.Message import dev.lonami.talaria.model.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
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

@ -0,0 +1,75 @@
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

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

View File

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

View File

@ -0,0 +1,185 @@
package dev.lonami.talaria.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
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
enum class LoginStage {
ASK_PHONE,
ASK_CODE,
ASK_PASSWORD,
}
fun isPhoneValid(phone: String): Boolean = phone.trim('+', ' ').isNotEmpty()
fun isLoginCodeValid(code: String): Boolean = code.trim().count { it.isDigit() } == 5
fun isPasswordValid(password: String): Boolean = password.isNotEmpty()
@Composable
fun PhoneInput(phone: String, onPhoneChanged: (String) -> Unit, onSendCode: () -> Unit) {
val focusManager = LocalFocusManager.current
Text(stringResource(R.string.enter_phone))
TextField(
phone,
label = { Text(stringResource(R.string.phone_international)) },
placeholder = { Text(stringResource(R.string.phone_example)) },
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Phone,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
modifier = Modifier.fillMaxWidth(),
onValueChange = onPhoneChanged
)
Spacer(Modifier.height(16.dp))
Button(
enabled = isPhoneValid(phone),
modifier = Modifier.fillMaxWidth(),
onClick = onSendCode
) {
Text(stringResource(R.string.send_otp))
}
}
@Composable
fun OtpInput(otp: String, onOtpChanged: (String) -> Unit, onConfirmOtp: () -> Unit) {
val focusManager = LocalFocusManager.current
Text(stringResource(R.string.enter_otp))
TextField(
otp,
label = { Text(stringResource(R.string.otp)) },
placeholder = { Text(stringResource(R.string.otp_example)) },
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
modifier = Modifier.fillMaxWidth(),
onValueChange = onOtpChanged
)
Spacer(Modifier.height(16.dp))
Button(
enabled = isLoginCodeValid(otp),
modifier = Modifier.fillMaxWidth(),
onClick = onConfirmOtp
) {
Text(stringResource(R.string.do_continue))
}
}
@Composable
fun PasswordInput(
password: String,
onPasswordChanged: (String) -> Unit,
onConfirmPassword: () -> Unit
) {
val focusManager = LocalFocusManager.current
Text(stringResource(R.string.enter_2fa_password))
TextField(
password,
label = { Text(stringResource(R.string.password)) },
placeholder = { Text(stringResource(R.string.password_example)) },
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
modifier = Modifier.fillMaxWidth(),
onValueChange = onPasswordChanged
)
Spacer(Modifier.height(16.dp))
Button(
enabled = isPasswordValid(password),
modifier = Modifier.fillMaxWidth(),
onClick = onConfirmPassword
) {
Text(stringResource(R.string.do_login))
}
}
@Composable
fun LoginScreen(onLoginComplete: () -> Unit) {
var stage by remember { mutableStateOf(LoginStage.ASK_PHONE) }
var phone by remember { mutableStateOf("") }
var otp by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var tokenPtr by remember { mutableStateOf(0L) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Center
) {
Text(
stringResource(R.string.welcome_to, stringResource(R.string.app_name)),
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.CenterHorizontally)
.padding(16.dp)
)
when (stage) {
LoginStage.ASK_PHONE -> PhoneInput(
phone,
onPhoneChanged = { phone = it },
onSendCode = {
tokenPtr = Native.requestLoginCode(phone)
stage = LoginStage.ASK_CODE
}
)
LoginStage.ASK_CODE -> OtpInput(
otp,
onOtpChanged = { otp = it },
onConfirmOtp = {
val signInComplete = Native.signIn(tokenPtr, otp)
if (signInComplete) {
onLoginComplete()
} else {
stage = LoginStage.ASK_PASSWORD;
}
}
)
LoginStage.ASK_PASSWORD -> PasswordInput(
password,
onPasswordChanged = { password = it },
onConfirmPassword = {
Native.checkPassword(password)
onLoginComplete();
})
}
}
}
@Preview(showBackground = true)
@Composable
fun LoginPreview() {
TalariaTheme {
LoginScreen(onLoginComplete = { })
}
}

View File

@ -1,181 +0,0 @@
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

@ -1,137 +0,0 @@
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,152 +0,0 @@
package dev.lonami.talaria.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
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.ui.theme.TalariaTheme
import uniffi.talaria.LoginProcedure
enum class LoginStage {
ASK_PHONE,
ASK_CODE,
}
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,
modifier: Modifier = Modifier,
) {
val focusManager = LocalFocusManager.current
Column(modifier = modifier) {
Text(stringResource(R.string.enter_phone))
TextField(
phone,
label = { Text(stringResource(R.string.phone_international)) },
placeholder = { Text(stringResource(R.string.phone_example)) },
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Phone,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
modifier = Modifier.fillMaxWidth(),
onValueChange = onPhoneChanged
)
Spacer(Modifier.height(16.dp))
Button(
enabled = isPhoneValid(phone),
modifier = Modifier.fillMaxWidth(),
onClick = onSendCode
) {
Text(stringResource(R.string.send_otp))
}
}
}
@Composable
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,
label = { Text(stringResource(R.string.otp)) },
placeholder = { Text(stringResource(R.string.otp_example)) },
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
modifier = Modifier.fillMaxWidth(),
onValueChange = onOtpChanged
)
Spacer(Modifier.height(16.dp))
Button(
enabled = isLoginCodeValid(otp),
modifier = Modifier.fillMaxWidth(),
onClick = onConfirmOtp
) {
Text(stringResource(R.string.do_login))
}
}
}
@Composable
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("") }
val loginProcedure by remember { mutableStateOf(LoginProcedure()) }
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Center
) {
Text(
stringResource(R.string.welcome_to, stringResource(R.string.app_name)),
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.CenterHorizontally)
.padding(16.dp)
)
when (stage) {
LoginStage.ASK_PHONE -> PhoneInput(
phone,
onPhoneChanged = { phone = it },
onSendCode = {
loginProcedure.requestLoginCode(phone)
stage = LoginStage.ASK_CODE
}
)
LoginStage.ASK_CODE -> OtpInput(
otp,
onOtpChanged = { otp = it },
onConfirmOtp = {
loginProcedure.signIn(otp)
onConfirmOtp()
}
)
}
}
}
@Preview(showBackground = true)
@Composable
fun LoginPreview() {
TalariaTheme {
LoginScreen(onConfirmOtp = { })
}
}

View File

@ -1,31 +0,0 @@
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

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

View File

@ -16,6 +16,15 @@ 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

View File

@ -6,10 +6,23 @@ 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, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 16.sp 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
)
*/
) )

View File

@ -1,12 +0,0 @@
<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

@ -1,9 +0,0 @@
<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

@ -1,9 +0,0 @@
<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

@ -8,6 +8,10 @@
<string name="enter_otp">Enter the code you received:</string> <string name="enter_otp">Enter the code you received:</string>
<string name="otp">Code</string> <string name="otp">Code</string>
<string name="otp_example">12345</string> <string name="otp_example">12345</string>
<string name="enter_2fa_password">Enter your 2-factor authentication password:</string>
<string name="password">Password</string>
<string name="password_example">hunter2</string>
<string name="do_continue">Continue</string>
<string name="do_login">Login</string> <string name="do_login">Login</string>
<string name="profile_photo">Profile Picture</string> <string name="profile_photo">Profile Picture</string>
<string name="write_message">Write a message…</string> <string name="write_message">Write a message…</string>
@ -15,8 +19,4 @@
<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,10 +4,8 @@ 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 {
// uniffi-recommended Gradle script fails with Android plugin 7.3.1. id 'com.android.application' version '7.3.0' apply false
// See https://github.com/mozilla/uniffi-rs/issues/1386 for details. id 'com.android.library' version '7.3.0' apply false
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,24 +4,19 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[lib] [lib]
name = "uniffi_talaria" name = "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.1" } grammers-client = { version = "0.4.0" }
grammers-tl-types = { version = "0.4.0" } grammers-tl-types = { version = "0.4.0" }
grammers-session = { version = "0.4.1" } grammers-session = { version = "0.4.0" }
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

View File

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

View File

@ -1,172 +0,0 @@
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(())
}

View File

@ -1,14 +0,0 @@
#[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]>,
}

View File

@ -1,27 +0,0 @@
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,139 +1,30 @@
#![cfg(target_os = "android")] #![cfg(target_os = "android")]
#![allow(non_snake_case)] #![allow(non_snake_case)]
mod db; use std::ffi::{CStr, CString};
use std::future::Future;
use grammers_client::types::{LoginToken, Message as OgMessage}; use grammers_client::types::{Dialog, LoginToken, PasswordToken};
use grammers_client::{Client, Config, InitParams}; use grammers_client::{Client, Config, SignInError};
use grammers_session::{PackedChat, Session, UpdateState}; use grammers_session::{PackedChat, Session};
use grammers_tl_types as tl; use jni::objects::{JObject, JString};
use jni::sys::{jboolean, jint, jlong, jstring};
use jni::JNIEnv;
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 = { const API_ID: i32 = 0;
let mut index = 0; const API_HASH: &str = "";
let mut value = 0;
let api_id = env!("TALARIA_API_ID"); type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
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() {
@ -150,22 +41,7 @@ fn block_on<F: Future>(future: F) -> F::Output {
RUNTIME.get().unwrap().block_on(future) RUNTIME.get().unwrap().block_on(future)
} }
pub fn init_database(path: String) -> Result<()> { async fn init_client() -> 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)
@ -177,275 +53,241 @@ pub 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 session = Session::new(); let client = Client::connect(Config {
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: InitParams { params: Default::default(),
server_addr: if SERVER_ADDR.is_empty() { })
None .await?;
} else {
Some(SERVER_ADDR.parse().unwrap())
},
..Default::default()
},
}))
.map_err(|_| NativeError::Network)?;
info!("Connected!"); info!("Connected!");
CLIENT CLIENT
.set(client) .set(client)
.map_err(|_| NativeError::Initialization)?; .map_err(|_| "Client was already initialized")?;
Ok(()) Ok(())
} }
pub fn need_login() -> Result<bool> { async fn need_login() -> Result<bool> {
let client = CLIENT.get().ok_or(NativeError::Initialization)?; let client = CLIENT.get().ok_or("Client not initialized")?;
block_on(client.is_authorized()).map_err(|_| NativeError::Network) Ok(client.is_authorized().await?)
} }
impl LoginProcedure { async fn request_login_code(phone: &str) -> Result<LoginToken> {
fn new() -> Self { let client = CLIENT.get().ok_or("Client not initialized")?;
Self { let token = client.request_login_code(&phone, API_ID, API_HASH).await?;
token: Mutex::new(None), Ok(token)
} }
}
fn request_login_code(&self, phone: String) -> Result<()> { async fn sign_in(token: LoginToken, code: &str) -> Result<bool> {
let client = CLIENT.get().ok_or(NativeError::Initialization)?; let client = CLIENT.get().ok_or("Client not initialized")?;
let token = block_on(client.request_login_code(&phone, API_ID, API_HASH)) return match client.sign_in(&token, &code).await {
.map_err(|_| NativeError::Network)?; Err(SignInError::PasswordRequired(token)) => {
*self.token.lock().unwrap() = Some(token); info!("Sign in 2fa password required. Hint: {:?}", token.hint());
Ok(()) Ok(false)
} }
Err(e) => {
fn sign_in(&self, code: String) -> Result<()> { client.sign_out().await?;
let token = self Err(Box::new(e))
.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);
} }
Ok(_) => Ok(true),
}; };
}
let mut session = db::create_session(conn).map_err(|_| NativeError::Database)?; async fn check_password(password: &str) -> Result<()> {
let s = client.session(); let client = CLIENT.get().ok_or("Client not initialized")?;
if let Some(user) = s.get_user() { let token = PasswordToken::new("hint");
session.user_id = Some(user.id); match client.check_password(token, &password).await {
session.dc_id = Some(user.dc); Err(e) => {
session.bot = Some(user.bot); client.sign_out().await?;
Err(Box::new(e))
} }
Ok(_) => Ok(())
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>> { async fn get_dialogs() -> Result<Vec<Dialog>> {
let client = CLIENT.get().ok_or(NativeError::Initialization)?; let client = CLIENT.get().ok_or("Client not initialized")?;
block_on(async {
let mut result = Vec::new(); let mut result = Vec::new();
let mut dialogs = client.iter_dialogs(); let mut dialogs = client.iter_dialogs();
while let Some(dialog) = dialogs.next().await.map_err(|_| NativeError::Network)? { while let Some(dialog) = dialogs.next().await? {
result.push(dialog); result.push(dialog);
} }
Ok(result) 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()
})
} }
fn adapt_message(m: OgMessage) -> Message { async fn send_message(chat: PackedChat, text: &str) -> Result<()> {
Message { let client = CLIENT.get().ok_or("Client not initialized")?;
id: m.id(), client.send_message(chat, text).await?;
sender: if let Some(sender) = m.sender() { Ok(())
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 { #[no_mangle]
($formatting:ident($entity:ident)) => { pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_initClient(_: JNIEnv, _: JObject) {
tf!($formatting($entity).extra(None)) match block_on(init_client()) {
}; Ok(_) => info!("Client initialized"),
($formatting:ident($entity:ident).extra($extra:expr)) => { Err(e) => error!("Failed to initialize client: {}", e),
TextFormat { }
format: Formatting::$formatting, }
offset: $entity.offset,
length: $entity.length, #[no_mangle]
extra: $extra, 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]
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,
) -> jboolean {
let token = *Box::from_raw(token_ptr as *mut LoginToken);
let code = CString::from(CStr::from_ptr(env.get_string(code).unwrap().as_ptr()));
return match block_on(sign_in(token, code.to_str().unwrap())) {
Ok(sign_in_complete) => {
if sign_in_complete {
info!("Sign in success");
}
sign_in_complete.into()
}
Err(e) => {
error!("Failed to sign in: {}", e);
true.into()
} }
}; };
} }
entities #[no_mangle]
.into_iter() pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_checkPassword(
.map(|e| match e { env: JNIEnv,
ME::Unknown(e) => tf!(Unknown(e)), _: JObject,
ME::Mention(e) => tf!(Mention(e)), token_ptr: jlong,
ME::Hashtag(e) => tf!(HashTag(e)), code: JString,
ME::BotCommand(e) => tf!(BotCommand(e)), ) -> jboolean {
ME::Url(e) => tf!(Url(e)), let token = *Box::from_raw(token_ptr as *mut LoginToken);
ME::Email(e) => tf!(Email(e)), let code = CString::from(CStr::from_ptr(env.get_string(code).unwrap().as_ptr()));
ME::Bold(e) => tf!(Bold(e)),
ME::Italic(e) => tf!(Italic(e)), return match block_on(sign_in(token, code.to_str().unwrap())) {
ME::Code(e) => tf!(Code(e)), Ok(sign_in_complete) => {
ME::Pre(e) => tf!(Pre(e).extra(Some(e.language.to_string()))), if sign_in_complete {
ME::TextUrl(e) => tf!(TextUrl(e).extra(Some(e.url.to_string()))), info!("Sign in success");
ME::MentionName(e) => tf!(MentionName(e).extra(Some(e.user_id.to_string()))), }
ME::InputMessageEntityMentionName(e) => tf!(Unknown(e)), sign_in_complete.into()
ME::Phone(e) => tf!(Phone(e)), }
ME::Cashtag(e) => tf!(CashTag(e)), Err(e) => {
ME::Underline(e) => tf!(Underline(e)), error!("Failed to sign in: {}", e);
ME::Strike(e) => tf!(Strike(e)), true.into()
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()))) #[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
} }
})
.collect()
} else {
Vec::new()
},
} }
} }
pub fn get_messages(packed: String) -> Result<Vec<Message>> { #[no_mangle]
let chat = PackedChat::from_hex(&packed).unwrap(); pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_dialogCount(
let client = CLIENT.get().ok_or(NativeError::Initialization)?; _: JNIEnv,
_: JObject,
dialogs_ptr: jlong,
) -> jint {
let dialogs = Box::leak(Box::from_raw(dialogs_ptr as *mut Vec<Dialog>));
dialogs.len() as jint
}
block_on(async { #[no_mangle]
let mut result = Vec::new(); pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_dialogPacked(
let mut messages = client.iter_messages(chat); env: JNIEnv,
while let Some(message) = messages.next().await.map_err(|_| NativeError::Network)? { _: JObject,
result.push(message); 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),
} }
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)
} }

View File

@ -1,88 +0,0 @@
[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);
};