forked from Lonami/Talaria
Compare commits
32 Commits
2fa-suppor
...
master
Author | SHA1 | Date |
---|---|---|
Lonami Exo | 49fa3c4d5a | |
Lonami Exo | d350d1a048 | |
Lonami Exo | 43421f6e54 | |
Lonami Exo | 912949a079 | |
Lonami Exo | 1574ce683c | |
Lonami Exo | d11a00d062 | |
Lonami Exo | 004a921299 | |
Lonami Exo | 1a56b03614 | |
Lonami Exo | 812597f027 | |
Lonami Exo | fd1dac1045 | |
Lonami Exo | b5dddcef02 | |
Lonami Exo | ea53d3cb1d | |
Lonami Exo | da136a1990 | |
Lonami Exo | 35ed8e12c6 | |
Lonami Exo | 0db1599cf7 | |
Lonami Exo | 50a2403a85 | |
Lonami Exo | 07d0e5e505 | |
Lonami Exo | 442b019134 | |
Lonami Exo | 51ef704cf7 | |
Lonami Exo | 17d7805654 | |
Lonami Exo | 6c652e3e1b | |
Lonami Exo | 999c59e9ec | |
Lonami Exo | c627db973c | |
Lonami Exo | fc7dc68b62 | |
Lonami Exo | 189c1e8db8 | |
Lonami Exo | 7bfbcc955c | |
Lonami Exo | 0f412f7334 | |
Lonami Exo | a341466749 | |
Lonami Exo | cd37c5aa14 | |
Lonami Exo | 0919c4a13c | |
Lonami Exo | 5e1f253dd3 | |
Lonami Exo | f2cd0eb69a |
|
@ -1,6 +1,7 @@
|
||||||
<component name="ProjectCodeStyleConfiguration">
|
<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">
|
||||||
|
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,18 +0,0 @@
|
||||||
package dev.lonami.talaria.bindings
|
|
||||||
|
|
||||||
object Native {
|
|
||||||
init {
|
|
||||||
System.loadLibrary("talaria")
|
|
||||||
}
|
|
||||||
|
|
||||||
external fun initClient()
|
|
||||||
external fun needLogin(): Boolean
|
|
||||||
external fun requestLoginCode(phone: String): Long
|
|
||||||
external fun signIn(tokenPtr: Long, code: String)
|
|
||||||
external fun getDialogs(): Long
|
|
||||||
external fun dialogCount(dialogsPtr: Long): Int
|
|
||||||
external fun dialogPacked(dialogsPtr: Long, index: Int): String
|
|
||||||
external fun dialogTitle(dialogsPtr: Long, index: Int): String
|
|
||||||
external fun freeDialogs(dialogsPtr: Long)
|
|
||||||
external fun sendMessage(packed: String, text: String)
|
|
||||||
}
|
|
|
@ -0,0 +1,56 @@
|
||||||
|
package dev.lonami.talaria.data
|
||||||
|
|
||||||
|
import uniffi.talaria.Dialog
|
||||||
|
import uniffi.talaria.MessageAck
|
||||||
|
import uniffi.talaria.MessagePreview
|
||||||
|
import uniffi.talaria.getDialogs
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
interface DialogRepository {
|
||||||
|
fun loadDialogs(): List<Dialog>
|
||||||
|
}
|
||||||
|
|
||||||
|
class NativeDialogRepository : DialogRepository {
|
||||||
|
override fun loadDialogs(): List<Dialog> {
|
||||||
|
return getDialogs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockDialogRepository : DialogRepository {
|
||||||
|
override fun loadDialogs(): List<Dialog> {
|
||||||
|
val dialogs = mutableListOf<Dialog>()
|
||||||
|
for (i in 0 until 10) {
|
||||||
|
dialogs.add(
|
||||||
|
Dialog(
|
||||||
|
id = "$i",
|
||||||
|
title = "Sample Dialog $i",
|
||||||
|
lastMessage = if (i % 4 == 3) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
MessagePreview(
|
||||||
|
sender = if (i % 2 == 0) {
|
||||||
|
"Sender A"
|
||||||
|
} else {
|
||||||
|
"Sender B"
|
||||||
|
},
|
||||||
|
text = if (i % 3 == 2) {
|
||||||
|
"Very Long Sample Message $i, with a Lot of Text, which makes it hard to Preview"
|
||||||
|
} else {
|
||||||
|
"Sample Message $i"
|
||||||
|
},
|
||||||
|
date = Instant.now(),
|
||||||
|
ack = when (i % 3) {
|
||||||
|
0 -> MessageAck.RECEIVED
|
||||||
|
1 -> MessageAck.SENT
|
||||||
|
2 -> MessageAck.SEEN
|
||||||
|
else -> throw RuntimeException()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
pinned = i < 4
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return dialogs
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,25 +0,0 @@
|
||||||
package dev.lonami.talaria.data
|
|
||||||
|
|
||||||
import dev.lonami.talaria.bindings.Native
|
|
||||||
import dev.lonami.talaria.model.Dialog
|
|
||||||
|
|
||||||
object DialogSource {
|
|
||||||
fun loadDialogs(): List<Dialog> {
|
|
||||||
val dialogs = mutableListOf<Dialog>()
|
|
||||||
|
|
||||||
val dialogPtr = Native.getDialogs()
|
|
||||||
val dialogCount = Native.dialogCount(dialogPtr)
|
|
||||||
for (i in 0 until dialogCount) {
|
|
||||||
dialogs.add(
|
|
||||||
Dialog(
|
|
||||||
Native.dialogTitle(dialogPtr, i),
|
|
||||||
Native.dialogPacked(dialogPtr, i),
|
|
||||||
false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Native.freeDialogs(dialogPtr)
|
|
||||||
|
|
||||||
return dialogs
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,46 @@
|
||||||
|
package dev.lonami.talaria.data
|
||||||
|
|
||||||
|
import uniffi.talaria.Message
|
||||||
|
import uniffi.talaria.getMessages
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
interface MessageRepository {
|
||||||
|
fun loadMessages(chat: String): List<Message>
|
||||||
|
fun sendMessage(chat: String, message: String): Message
|
||||||
|
}
|
||||||
|
|
||||||
|
class NativeMessageRepository : MessageRepository {
|
||||||
|
override fun loadMessages(chat: String): List<Message> {
|
||||||
|
return getMessages(chat)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun sendMessage(chat: String, message: String): Message {
|
||||||
|
return uniffi.talaria.sendMessage(chat, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockMessageRepository(private var msgCounter: Int = 50) : MessageRepository {
|
||||||
|
override fun loadMessages(chat: String): List<Message> {
|
||||||
|
return (0 until 50).map {
|
||||||
|
Message(
|
||||||
|
id = it,
|
||||||
|
sender = "Alice",
|
||||||
|
text = "Testing",
|
||||||
|
date = Instant.now(),
|
||||||
|
editDate = null,
|
||||||
|
formatting = listOf(),
|
||||||
|
)
|
||||||
|
}.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun sendMessage(chat: String, message: String): Message {
|
||||||
|
return Message(
|
||||||
|
id = msgCounter++,
|
||||||
|
sender = "You",
|
||||||
|
text = message,
|
||||||
|
date = Instant.now(),
|
||||||
|
editDate = null,
|
||||||
|
formatting = listOf(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,11 +0,0 @@
|
||||||
package dev.lonami.talaria.data
|
|
||||||
|
|
||||||
import dev.lonami.talaria.model.Message
|
|
||||||
|
|
||||||
object MessageSource {
|
|
||||||
fun loadMessages(): List<Message> {
|
|
||||||
return generateSequence {
|
|
||||||
Message("Alice", "Testing")
|
|
||||||
}.take(50).toList()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,3 +0,0 @@
|
||||||
package dev.lonami.talaria.model
|
|
||||||
|
|
||||||
data class Dialog(val title: String, val lastMessage: String, val pinned: Boolean)
|
|
|
@ -1,3 +0,0 @@
|
||||||
package dev.lonami.talaria.model
|
|
||||||
|
|
||||||
data class Message(val sender: String, val text: String)
|
|
|
@ -1,110 +0,0 @@
|
||||||
package dev.lonami.talaria.ui
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material.Button
|
|
||||||
import androidx.compose.material.Card
|
|
||||||
import androidx.compose.material.Text
|
|
||||||
import androidx.compose.material.TextField
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import dev.lonami.talaria.R
|
|
||||||
import dev.lonami.talaria.model.Message
|
|
||||||
import dev.lonami.talaria.ui.theme.TalariaTheme
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MessageCard(message: Message) {
|
|
||||||
Card(
|
|
||||||
elevation = 4.dp,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(8.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(8.dp)
|
|
||||||
) {
|
|
||||||
Text(message.sender, fontWeight = FontWeight.Bold)
|
|
||||||
Text(message.text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MessageList(messages: List<Message>, modifier: Modifier, listState: LazyListState) {
|
|
||||||
LazyColumn(modifier, state = listState) {
|
|
||||||
items(messages.size) { MessageCard(messages[it]) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MessageInputField(
|
|
||||||
messageText: String,
|
|
||||||
onMessageChanged: (String) -> Unit,
|
|
||||||
onSendMessage: () -> Unit
|
|
||||||
) {
|
|
||||||
Row {
|
|
||||||
TextField(
|
|
||||||
messageText,
|
|
||||||
placeholder = { Text(stringResource(R.string.write_message)) },
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
imeAction = ImeAction.Send
|
|
||||||
),
|
|
||||||
keyboardActions = KeyboardActions(
|
|
||||||
onDone = { onSendMessage() }
|
|
||||||
),
|
|
||||||
modifier = Modifier.weight(1.0f),
|
|
||||||
onValueChange = onMessageChanged
|
|
||||||
)
|
|
||||||
Button(
|
|
||||||
onClick = onSendMessage
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.send_message))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ChatScreen(selectedDialog: String, chatViewModel: ChatViewModel = viewModel()) {
|
|
||||||
val chatUiState by chatViewModel.uiState.collectAsState()
|
|
||||||
var messageText by remember { mutableStateOf("") }
|
|
||||||
val messageListState = rememberLazyListState()
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
|
||||||
MessageList(
|
|
||||||
chatUiState.messages,
|
|
||||||
modifier = Modifier.weight(1.0f),
|
|
||||||
listState = messageListState
|
|
||||||
)
|
|
||||||
MessageInputField(messageText, onMessageChanged = {
|
|
||||||
messageText = it
|
|
||||||
}, onSendMessage = {
|
|
||||||
chatViewModel.sendMessage(selectedDialog, messageText)
|
|
||||||
messageText = ""
|
|
||||||
coroutineScope.launch {
|
|
||||||
messageListState.animateScrollToItem(chatUiState.messages.size - 1)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun ChatPreview() {
|
|
||||||
TalariaTheme {
|
|
||||||
ChatScreen("")
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,31 +0,0 @@
|
||||||
package dev.lonami.talaria.ui
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import dev.lonami.talaria.bindings.Native
|
|
||||||
import dev.lonami.talaria.data.MessageSource
|
|
||||||
import dev.lonami.talaria.model.Message
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
|
|
||||||
class ChatViewModel : ViewModel() {
|
|
||||||
private val _uiState = MutableStateFlow(ChatUiState())
|
|
||||||
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
|
|
||||||
|
|
||||||
private fun loadMessages() {
|
|
||||||
_uiState.value = ChatUiState(MessageSource.loadMessages().toMutableList())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendMessage(dialog: String, message: String) {
|
|
||||||
Native.sendMessage(dialog, message)
|
|
||||||
_uiState.update { state ->
|
|
||||||
state.messages.add(Message("You", message))
|
|
||||||
state
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
loadMessages()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,75 +0,0 @@
|
||||||
package dev.lonami.talaria.ui
|
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.material.Card
|
|
||||||
import androidx.compose.material.Switch
|
|
||||||
import androidx.compose.material.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import dev.lonami.talaria.R
|
|
||||||
import dev.lonami.talaria.model.Dialog
|
|
||||||
import dev.lonami.talaria.ui.theme.TalariaTheme
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun DialogCard(dialog: Dialog, onDialogSelected: () -> Unit) {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(8.dp, 16.dp)
|
|
||||||
.clickable(onClick = onDialogSelected)
|
|
||||||
) {
|
|
||||||
Row {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(R.drawable.ic_launcher_foreground),
|
|
||||||
contentDescription = stringResource(R.string.profile_photo),
|
|
||||||
)
|
|
||||||
Column(modifier = Modifier.weight(1.0f)) {
|
|
||||||
Text(dialog.title, fontWeight = FontWeight.Bold)
|
|
||||||
Text(dialog.lastMessage)
|
|
||||||
}
|
|
||||||
Switch(dialog.pinned, enabled = false, onCheckedChange = null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun DialogList(dialogs: List<Dialog>, onDialogSelected: (String) -> Unit) {
|
|
||||||
LazyColumn {
|
|
||||||
items(dialogs.size) {
|
|
||||||
DialogCard(dialogs[it], onDialogSelected = {
|
|
||||||
onDialogSelected(dialogs[it].lastMessage)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun DialogScreen(
|
|
||||||
onDialogSelected: (String) -> Unit,
|
|
||||||
dialogViewModel: DialogViewModel = viewModel()
|
|
||||||
) {
|
|
||||||
val dialogUiState by dialogViewModel.uiState.collectAsState()
|
|
||||||
DialogList(dialogUiState.dialogs, onDialogSelected = onDialogSelected)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun DialogPreview() {
|
|
||||||
TalariaTheme {
|
|
||||||
DialogScreen(onDialogSelected = { })
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,5 +0,0 @@
|
||||||
package dev.lonami.talaria.ui
|
|
||||||
|
|
||||||
import dev.lonami.talaria.model.Dialog
|
|
||||||
|
|
||||||
data class DialogUiState(val dialogs: List<Dialog> = listOf())
|
|
|
@ -0,0 +1,181 @@
|
||||||
|
package dev.lonami.talaria.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.text.ClickableText
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.Button
|
||||||
|
import androidx.compose.material.Card
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.material.TextField
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import dev.lonami.talaria.R
|
||||||
|
import dev.lonami.talaria.data.MockMessageRepository
|
||||||
|
import dev.lonami.talaria.ui.state.ChatViewModel
|
||||||
|
import dev.lonami.talaria.ui.theme.TalariaTheme
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import uniffi.talaria.Formatting
|
||||||
|
import uniffi.talaria.Message
|
||||||
|
import uniffi.talaria.TextFormat
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FormattedText(
|
||||||
|
text: String,
|
||||||
|
formatting: List<TextFormat>,
|
||||||
|
onFormatClicked: (TextFormat) -> Unit,
|
||||||
|
) {
|
||||||
|
val anno = AnnotatedString(text, spanStyles = formatting.map {
|
||||||
|
AnnotatedString.Range(SpanStyle(
|
||||||
|
fontWeight = when (it.format) {
|
||||||
|
Formatting.BOLD -> FontWeight.Bold
|
||||||
|
else -> FontWeight.Normal
|
||||||
|
},
|
||||||
|
fontStyle = when (it.format) {
|
||||||
|
Formatting.ITALIC -> FontStyle.Italic
|
||||||
|
else -> FontStyle.Normal
|
||||||
|
},
|
||||||
|
fontFamily = when (it.format) {
|
||||||
|
Formatting.CODE,
|
||||||
|
Formatting.PRE,
|
||||||
|
-> FontFamily.Monospace
|
||||||
|
else -> FontFamily.Default
|
||||||
|
},
|
||||||
|
textDecoration = when (it.format) {
|
||||||
|
Formatting.UNDERLINE -> TextDecoration.Underline
|
||||||
|
Formatting.STRIKE -> TextDecoration.LineThrough
|
||||||
|
else -> TextDecoration.None
|
||||||
|
},
|
||||||
|
color = when (it.format) {
|
||||||
|
Formatting.MENTION,
|
||||||
|
Formatting.HASH_TAG,
|
||||||
|
Formatting.BOT_COMMAND,
|
||||||
|
Formatting.URL,
|
||||||
|
Formatting.EMAIL,
|
||||||
|
Formatting.TEXT_URL,
|
||||||
|
Formatting.MENTION_NAME,
|
||||||
|
Formatting.PHONE,
|
||||||
|
Formatting.CASH_TAG,
|
||||||
|
-> Color(0xff0000ff)
|
||||||
|
else -> Color.Black
|
||||||
|
}), it.offset, it.offset + it.length)
|
||||||
|
})
|
||||||
|
ClickableText(
|
||||||
|
anno,
|
||||||
|
onClick = { offset ->
|
||||||
|
anno.spanStyles.indexOfFirst {
|
||||||
|
it.start <= offset && offset <= it.end
|
||||||
|
}.takeIf { it != -1 }?.also {
|
||||||
|
onFormatClicked(formatting[it])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MessageCard(message: Message, modifier: Modifier = Modifier) {
|
||||||
|
Card(
|
||||||
|
elevation = 4.dp,
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(8.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(8.dp)
|
||||||
|
) {
|
||||||
|
Text(message.sender, fontWeight = FontWeight.Bold)
|
||||||
|
FormattedText(message.text, message.formatting, onFormatClicked = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MessageList(messages: List<Message>, listState: LazyListState, modifier: Modifier = Modifier) {
|
||||||
|
LazyColumn(modifier = modifier, state = listState) {
|
||||||
|
items(messages.size) { MessageCard(messages[it]) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MessageInputField(
|
||||||
|
messageText: String,
|
||||||
|
onMessageChanged: (String) -> Unit,
|
||||||
|
onSendMessage: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(modifier = modifier) {
|
||||||
|
TextField(
|
||||||
|
messageText,
|
||||||
|
placeholder = { Text(stringResource(R.string.write_message)) },
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
imeAction = ImeAction.Send
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = { onSendMessage() }
|
||||||
|
),
|
||||||
|
modifier = Modifier.weight(1.0f),
|
||||||
|
onValueChange = onMessageChanged
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = onSendMessage
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.send_message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatScreen(
|
||||||
|
selectedDialog: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
chatViewModel: ChatViewModel = viewModel(),
|
||||||
|
) {
|
||||||
|
val chatUiState by chatViewModel.uiState.collectAsState()
|
||||||
|
var messageText by remember { mutableStateOf("") }
|
||||||
|
val messageListState = rememberLazyListState()
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
Column(modifier = modifier.fillMaxSize()) {
|
||||||
|
MessageList(
|
||||||
|
chatUiState.messages,
|
||||||
|
modifier = Modifier.weight(1.0f),
|
||||||
|
listState = messageListState
|
||||||
|
)
|
||||||
|
MessageInputField(messageText, onMessageChanged = {
|
||||||
|
messageText = it
|
||||||
|
}, onSendMessage = {
|
||||||
|
chatViewModel.sendMessage(selectedDialog, messageText)
|
||||||
|
messageText = ""
|
||||||
|
coroutineScope.launch {
|
||||||
|
messageListState.animateScrollToItem(chatUiState.messages.size - 1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun ChatPreview() {
|
||||||
|
val viewModel = remember { ChatViewModel(MockMessageRepository()).apply { loadMessages("") } }
|
||||||
|
|
||||||
|
TalariaTheme {
|
||||||
|
ChatScreen("", chatViewModel = viewModel)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,137 @@
|
||||||
|
package dev.lonami.talaria.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material.Divider
|
||||||
|
import androidx.compose.material.Icon
|
||||||
|
import androidx.compose.material.Surface
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import dev.lonami.talaria.R
|
||||||
|
import dev.lonami.talaria.data.MockDialogRepository
|
||||||
|
import dev.lonami.talaria.ui.state.DialogViewModel
|
||||||
|
import dev.lonami.talaria.ui.theme.TalariaTheme
|
||||||
|
import uniffi.talaria.Dialog
|
||||||
|
import uniffi.talaria.MessageAck
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Dialog(dialog: Dialog, onDialogSelected: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.padding(4.dp)
|
||||||
|
.clickable(onClick = onDialogSelected)
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||||
|
contentDescription = stringResource(R.string.profile_photo),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = Modifier.height(48.dp),
|
||||||
|
)
|
||||||
|
Column(modifier = Modifier.weight(1.0f)) {
|
||||||
|
Text(
|
||||||
|
dialog.title,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
if (dialog.lastMessage != null) {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
R.string.message_preview,
|
||||||
|
dialog.lastMessage!!.sender,
|
||||||
|
dialog.lastMessage!!.text
|
||||||
|
),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
if (dialog.lastMessage != null) {
|
||||||
|
Row {
|
||||||
|
when (dialog.lastMessage!!.ack) {
|
||||||
|
MessageAck.RECEIVED -> {}
|
||||||
|
MessageAck.SENT -> Icon(
|
||||||
|
painterResource(R.drawable.sent),
|
||||||
|
stringResource(R.string.sent)
|
||||||
|
)
|
||||||
|
MessageAck.SEEN -> Icon(
|
||||||
|
painterResource(R.drawable.seen),
|
||||||
|
stringResource(R.string.seen)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
LocalDateTime.ofInstant(dialog.lastMessage!!.date, ZoneOffset.UTC).format(
|
||||||
|
DateTimeFormatter.ofLocalizedTime(
|
||||||
|
FormatStyle.SHORT
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dialog.pinned) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.tack),
|
||||||
|
stringResource(R.string.pinned),
|
||||||
|
modifier = Modifier.align(Alignment.End)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DialogList(
|
||||||
|
dialogs: List<Dialog>,
|
||||||
|
onDialogSelected: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
LazyColumn(modifier = modifier) {
|
||||||
|
items(dialogs.size) {
|
||||||
|
Dialog(dialogs[it], onDialogSelected = {
|
||||||
|
onDialogSelected(dialogs[it].id)
|
||||||
|
})
|
||||||
|
Divider(startIndent = 52.dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DialogScreen(
|
||||||
|
onDialogSelected: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
dialogViewModel: DialogViewModel = viewModel(),
|
||||||
|
) {
|
||||||
|
val dialogUiState by dialogViewModel.uiState.collectAsState()
|
||||||
|
Surface(modifier = modifier) {
|
||||||
|
DialogList(dialogUiState.dialogs, onDialogSelected = onDialogSelected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun DialogPreview() {
|
||||||
|
val viewModel = DialogViewModel(MockDialogRepository())
|
||||||
|
TalariaTheme {
|
||||||
|
DialogScreen(onDialogSelected = { }, dialogViewModel = viewModel)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
package dev.lonami.talaria.ui
|
package dev.lonami.talaria.ui.screens
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.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()
|
||||||
}
|
}
|
||||||
)
|
)
|
|
@ -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())
|
|
@ -0,0 +1,31 @@
|
||||||
|
package dev.lonami.talaria.ui.state
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import dev.lonami.talaria.data.MessageRepository
|
||||||
|
import dev.lonami.talaria.data.NativeMessageRepository
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
|
||||||
|
class ChatViewModel(private val repository: MessageRepository = NativeMessageRepository()) :
|
||||||
|
ViewModel() {
|
||||||
|
private val _uiState = MutableStateFlow(ChatUiState())
|
||||||
|
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
fun loadMessages(chat: String) {
|
||||||
|
_uiState.update {
|
||||||
|
it.messages.clear()
|
||||||
|
it.messages.addAll(repository.loadMessages(chat))
|
||||||
|
it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendMessage(dialog: String, message: String) {
|
||||||
|
val sent = repository.sendMessage(dialog, message)
|
||||||
|
_uiState.update {
|
||||||
|
it.messages.add(sent)
|
||||||
|
it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
package dev.lonami.talaria.ui.state
|
||||||
|
|
||||||
|
import uniffi.talaria.Dialog
|
||||||
|
|
||||||
|
data class DialogUiState(val dialogs: List<Dialog> = listOf())
|
|
@ -1,17 +1,19 @@
|
||||||
package dev.lonami.talaria.ui
|
package dev.lonami.talaria.ui.state
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import 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 {
|
|
@ -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)
|
||||||
)
|
)
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
|
@ -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
|
||||||
)
|
)
|
||||||
*/
|
|
||||||
)
|
)
|
|
@ -0,0 +1,12 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:pathData="m11,16 l2,2 9,-9C23,8 22,7 21,8l-8,8 -1,-1z"
|
||||||
|
android:fillColor="@color/black" />
|
||||||
|
<path
|
||||||
|
android:pathData="m3,14 l4,4 9,-9C17,8 16,7 15,8L7,16 4,13C3,12 2,13 3,14Z"
|
||||||
|
android:fillColor="@color/black" />
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:pathData="m6,14 l4,4 9,-9C20,8 19,7 18,8L10,16 7,13C6,12 5,13 6,14Z"
|
||||||
|
android:fillColor="@color/black" />
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:pathData="m14,4 l-5,5 -6,1 5,5 -5,7 7,-5 5,5 1,-6 5,-5c1,-1 1,-1 0,-2L16,4C15,3 15,3 14,4Z"
|
||||||
|
android:fillColor="@color/black" />
|
||||||
|
</vector>
|
|
@ -15,4 +15,8 @@
|
||||||
<string name="back_button">Back</string>
|
<string name="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>
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
fn main() {
|
||||||
|
uniffi_build::generate_scaffolding("src/talaria.udl").unwrap();
|
||||||
|
}
|
|
@ -0,0 +1,172 @@
|
||||||
|
mod model;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
|
use model::Session;
|
||||||
|
use sqlite::{Connection, Error, State};
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use utils::{fetch_many, fetch_one};
|
||||||
|
|
||||||
|
fn init_schema(conn: &Connection) -> Result<(), Error> {
|
||||||
|
let version = match fetch_one(&conn, "SELECT version FROM version LIMIT 1", |stmt| {
|
||||||
|
stmt.read::<i64>(0)
|
||||||
|
}) {
|
||||||
|
Ok(Some(version)) => version,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if version == 0 {
|
||||||
|
conn.execute(
|
||||||
|
"
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
CREATE TABLE version (version INTEGER NOT NULL);
|
||||||
|
CREATE TABLE session (
|
||||||
|
user_id INTEGER,
|
||||||
|
dc_id INTEGER,
|
||||||
|
bot INTEGER,
|
||||||
|
pts INTEGER,
|
||||||
|
qts INTEGER,
|
||||||
|
seq INTEGER,
|
||||||
|
date INTEGER
|
||||||
|
);
|
||||||
|
CREATE TABLE channel (
|
||||||
|
session_id INTEGER NOT NULL REFERENCES session (rowid),
|
||||||
|
id INTEGER NOT NULL,
|
||||||
|
hash INTEGER NOT NULL,
|
||||||
|
pts INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE datacenter (
|
||||||
|
session_id INTEGER NOT NULL REFERENCES session (rowid),
|
||||||
|
id INTEGER NOT NULL,
|
||||||
|
ipv4 TEXT,
|
||||||
|
ipv6 TEXT,
|
||||||
|
port INTEGER NOT NULL,
|
||||||
|
auth BLOB,
|
||||||
|
CONSTRAINT SingleIp CHECK(
|
||||||
|
(ipv4 IS NOT NULL AND ipv6 IS NULL) OR
|
||||||
|
(ipv6 IS NOT NULL AND ipv4 IS NULL))
|
||||||
|
);
|
||||||
|
INSERT INTO version VALUES (1);
|
||||||
|
COMMIT;
|
||||||
|
",
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init_connection(db_path: &str) -> Result<Connection, Error> {
|
||||||
|
let conn = sqlite::open(db_path)?;
|
||||||
|
init_schema(&conn)?;
|
||||||
|
Ok(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_sessions(conn: &Connection) -> Result<Vec<Session>, Error> {
|
||||||
|
let query = "
|
||||||
|
SELECT s.rowid, s.*, COALESCE(d.ipv4, d.ipv6), d.port, d.auth
|
||||||
|
FROM session s
|
||||||
|
LEFT JOIN datacenter d ON d.session_id = s.rowid AND d.id = s.dc_id
|
||||||
|
";
|
||||||
|
fetch_many(conn, query, |stmt| {
|
||||||
|
Ok(Session {
|
||||||
|
id: stmt.read(0)?,
|
||||||
|
user_id: stmt.read(1)?,
|
||||||
|
dc_id: stmt.read::<Option<i64>>(2)?.map(|x| x as _),
|
||||||
|
bot: stmt.read::<Option<i64>>(3)?.map(|x| x != 0),
|
||||||
|
pts: stmt.read::<Option<i64>>(4)?.map(|x| x as _),
|
||||||
|
qts: stmt.read::<Option<i64>>(5)?.map(|x| x as _),
|
||||||
|
seq: stmt.read::<Option<i64>>(6)?.map(|x| x as _),
|
||||||
|
date: stmt.read::<Option<i64>>(7)?.map(|x| x as _),
|
||||||
|
dc_addr: stmt.read::<Option<String>>(8)?,
|
||||||
|
dc_port: stmt.read::<Option<i64>>(9)?.map(|x| x as _),
|
||||||
|
dc_auth: stmt
|
||||||
|
.read::<Option<Vec<u8>>>(10)?
|
||||||
|
.map(|x| x.try_into().unwrap()),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_session(conn: &Connection) -> Result<Session, Error> {
|
||||||
|
conn.execute("INSERT INTO session DEFAULT VALUES;")?;
|
||||||
|
let id = fetch_one(conn, "SELECT LAST_INSERT_ROWID()", |stmt| {
|
||||||
|
stmt.read::<i64>(0)
|
||||||
|
})?
|
||||||
|
.unwrap();
|
||||||
|
Ok(Session {
|
||||||
|
id,
|
||||||
|
user_id: None,
|
||||||
|
dc_id: None,
|
||||||
|
bot: None,
|
||||||
|
pts: None,
|
||||||
|
qts: None,
|
||||||
|
seq: None,
|
||||||
|
date: None,
|
||||||
|
dc_addr: None,
|
||||||
|
dc_port: None,
|
||||||
|
dc_auth: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_session(conn: &Connection, session: &Session) -> Result<(), Error> {
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"
|
||||||
|
UPDATE session SET
|
||||||
|
user_id = ?,
|
||||||
|
dc_id = ?,
|
||||||
|
bot = ?,
|
||||||
|
pts = ?,
|
||||||
|
qts = ?,
|
||||||
|
seq = ?,
|
||||||
|
date = ?
|
||||||
|
WHERE rowid = ?
|
||||||
|
",
|
||||||
|
)?
|
||||||
|
.bind(1, session.user_id)?
|
||||||
|
.bind(2, session.dc_id.map(|x| x as i64))?
|
||||||
|
.bind(3, session.bot.map(|x| x as i64))?
|
||||||
|
.bind(4, session.pts.map(|x| x as i64))?
|
||||||
|
.bind(5, session.qts.map(|x| x as i64))?
|
||||||
|
.bind(6, session.seq.map(|x| x as i64))?
|
||||||
|
.bind(7, session.date.map(|x| x as i64))?
|
||||||
|
.bind(8, session.id)?;
|
||||||
|
while let State::Row = stmt.next()? {}
|
||||||
|
|
||||||
|
match (
|
||||||
|
session.dc_id,
|
||||||
|
session.dc_addr.as_ref(),
|
||||||
|
session.dc_port,
|
||||||
|
session.dc_auth,
|
||||||
|
) {
|
||||||
|
(Some(id), Some(addr), Some(port), Some(auth)) => {
|
||||||
|
let (ipv4, ipv6) = match addr.parse().unwrap() {
|
||||||
|
IpAddr::V4(ipv4) => (Some(ipv4.to_string()), None),
|
||||||
|
IpAddr::V6(ipv6) => (None, Some(ipv6.to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"
|
||||||
|
DELETE FROM datacenter WHERE session_id = ? AND id = ?
|
||||||
|
",
|
||||||
|
)?
|
||||||
|
.bind(1, session.id)?
|
||||||
|
.bind(2, id as i64)?;
|
||||||
|
|
||||||
|
while let State::Row = stmt.next()? {}
|
||||||
|
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("INSERT INTO datacenter VALUES (?, ?, ?, ?, ?, ?)")?
|
||||||
|
.bind(1, session.id)?
|
||||||
|
.bind(2, id as i64)?
|
||||||
|
.bind(3, ipv4.as_deref())?
|
||||||
|
.bind(4, ipv6.as_deref())?
|
||||||
|
.bind(5, port as i64)?
|
||||||
|
.bind(6, auth.as_ref())?;
|
||||||
|
|
||||||
|
while let State::Row = stmt.next()? {}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Session {
|
||||||
|
pub id: i64,
|
||||||
|
pub user_id: Option<i64>,
|
||||||
|
pub dc_id: Option<i32>,
|
||||||
|
pub bot: Option<bool>,
|
||||||
|
pub pts: Option<i32>,
|
||||||
|
pub qts: Option<i32>,
|
||||||
|
pub seq: Option<i32>,
|
||||||
|
pub date: Option<i32>,
|
||||||
|
pub dc_addr: Option<String>,
|
||||||
|
pub dc_port: Option<u16>,
|
||||||
|
pub dc_auth: Option<[u8; 256]>,
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
use sqlite::{Connection, Error, State, Statement};
|
||||||
|
|
||||||
|
pub fn fetch_one<T, F: FnOnce(&Statement) -> Result<T, Error>>(
|
||||||
|
conn: &Connection,
|
||||||
|
query: &str,
|
||||||
|
adaptor: F,
|
||||||
|
) -> Result<Option<T>, Error> {
|
||||||
|
let mut stmt = conn.prepare(query)?;
|
||||||
|
if let State::Row = stmt.next()? {
|
||||||
|
adaptor(&stmt).map(Some)
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fetch_many<T, F: FnMut(&Statement) -> Result<T, Error>>(
|
||||||
|
conn: &Connection,
|
||||||
|
query: &str,
|
||||||
|
mut adaptor: F,
|
||||||
|
) -> Result<Vec<T>, Error> {
|
||||||
|
let mut result = Vec::new();
|
||||||
|
let mut stmt = conn.prepare(query)?;
|
||||||
|
while let State::Row = stmt.next()? {
|
||||||
|
result.push(adaptor(&stmt)?);
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
|
@ -1,30 +1,139 @@
|
||||||
#![cfg(target_os = "android")]
|
#![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)
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,88 @@
|
||||||
|
[Error]
|
||||||
|
enum NativeError {
|
||||||
|
"Initialization",
|
||||||
|
"Database",
|
||||||
|
"Network",
|
||||||
|
};
|
||||||
|
|
||||||
|
enum MessageAck {
|
||||||
|
"Received",
|
||||||
|
"Seen",
|
||||||
|
"Sent",
|
||||||
|
};
|
||||||
|
|
||||||
|
enum Formatting {
|
||||||
|
"Unknown",
|
||||||
|
"Mention",
|
||||||
|
"HashTag",
|
||||||
|
"BotCommand",
|
||||||
|
"Url",
|
||||||
|
"Email",
|
||||||
|
"Bold",
|
||||||
|
"Italic",
|
||||||
|
"Code",
|
||||||
|
"Pre", // language:string
|
||||||
|
"TextUrl", // url:string
|
||||||
|
"MentionName", // user_id:long
|
||||||
|
"Phone",
|
||||||
|
"CashTag",
|
||||||
|
"Underline",
|
||||||
|
"Strike",
|
||||||
|
"Blockquote",
|
||||||
|
"BankCard",
|
||||||
|
"Spoiler",
|
||||||
|
"CustomEmoji", // document_id:long
|
||||||
|
};
|
||||||
|
|
||||||
|
dictionary TextFormat {
|
||||||
|
Formatting format;
|
||||||
|
i32 offset;
|
||||||
|
i32 length;
|
||||||
|
string? extra;
|
||||||
|
};
|
||||||
|
|
||||||
|
dictionary Message {
|
||||||
|
i32 id;
|
||||||
|
string sender;
|
||||||
|
string text;
|
||||||
|
timestamp date;
|
||||||
|
timestamp? edit_date;
|
||||||
|
sequence<TextFormat> formatting;
|
||||||
|
};
|
||||||
|
|
||||||
|
dictionary MessagePreview {
|
||||||
|
string sender;
|
||||||
|
string text;
|
||||||
|
timestamp date;
|
||||||
|
MessageAck ack;
|
||||||
|
};
|
||||||
|
|
||||||
|
dictionary Dialog {
|
||||||
|
string id;
|
||||||
|
string title;
|
||||||
|
MessagePreview? last_message;
|
||||||
|
boolean pinned;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LoginProcedure {
|
||||||
|
constructor();
|
||||||
|
[Throws=NativeError]
|
||||||
|
void request_login_code(string phone);
|
||||||
|
[Throws=NativeError]
|
||||||
|
void sign_in(string code);
|
||||||
|
};
|
||||||
|
|
||||||
|
namespace talaria {
|
||||||
|
[Throws=NativeError]
|
||||||
|
void init_database(string path);
|
||||||
|
[Throws=NativeError]
|
||||||
|
void init_client();
|
||||||
|
[Throws=NativeError]
|
||||||
|
boolean need_login();
|
||||||
|
[Throws=NativeError]
|
||||||
|
sequence<Dialog> get_dialogs();
|
||||||
|
[Throws=NativeError]
|
||||||
|
sequence<Message> get_messages(string packed);
|
||||||
|
[Throws=NativeError]
|
||||||
|
Message send_message(string packed, string text);
|
||||||
|
};
|
Loading…
Reference in New Issue