Compare commits

..

No commits in common. "master" and "master" have entirely different histories.

39 changed files with 583 additions and 1482 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

@ -1,7 +1,5 @@
# Building # Building
Clone the repo, then open the project in Android Studio.
Make sure the required Android NDK platforms are installed, and the environment Make sure the required Android NDK platforms are installed, and the environment
variable `ANDROID_NDK_TOOLCHAIN_DIR` is configured correctly. variable `ANDROID_NDK_TOOLCHAIN_DIR` is configured correctly.
@ -10,9 +8,3 @@ On Windows, this might be a path such as the following (NDK "Side by side" SDK t
``` ```
%LOCALAPPDATA%\Android\Sdk\ndk\25.1.8937393\toolchains\llvm\prebuilt\windows-x86_64\bin %LOCALAPPDATA%\Android\Sdk\ndk\25.1.8937393\toolchains\llvm\prebuilt\windows-x86_64\bin
``` ```
Set your API ID and hash in the rust code.
Change the Cargo.toml to point to a local grammers source tree and remove the version argument.
Sync gradle files, and Android Studio's "build" and "run" should Just Work.

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,78 +34,42 @@ 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(onConfirmOtp = {
@ -211,23 +79,3 @@ fun TalariaApp(modifier: Modifier = Modifier) {
} }
} }
} }
}
@Preview
@Composable
fun TopBarPreview() {
TalariaAppBar(
currentScreen = TalariaScreen.Dialog,
canNavigateBack = false,
navigateUp = {},
openDrawer = {},
)
}
@Preview
@Composable
fun NavDrawerPreview() {
Surface {
Drawer(onSelect = {})
}
}

View File

@ -0,0 +1,18 @@
package dev.lonami.talaria.bindings
object Native {
init {
System.loadLibrary("talaria")
}
external fun initClient()
external fun needLogin(): Boolean
external fun requestLoginCode(phone: String): Long
external fun signIn(tokenPtr: Long, code: String)
external fun getDialogs(): Long
external fun dialogCount(dialogsPtr: Long): Int
external fun dialogPacked(dialogsPtr: Long, index: Int): String
external fun dialogTitle(dialogsPtr: Long, index: Int): String
external fun freeDialogs(dialogsPtr: Long)
external fun sendMessage(packed: String, text: String)
}

View File

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

@ -1,4 +1,4 @@
package dev.lonami.talaria.ui.screens package dev.lonami.talaria.ui
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,15 +30,9 @@ 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( fun PhoneInput(phone: String, onPhoneChanged: (String) -> Unit, onSendCode: () -> Unit) {
phone: String,
onPhoneChanged: (String) -> Unit,
onSendCode: () -> Unit,
modifier: Modifier = Modifier,
) {
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
Column(modifier = modifier) {
Text(stringResource(R.string.enter_phone)) Text(stringResource(R.string.enter_phone))
TextField( TextField(
phone, phone,
@ -62,18 +56,11 @@ fun PhoneInput(
Text(stringResource(R.string.send_otp)) Text(stringResource(R.string.send_otp))
} }
} }
}
@Composable @Composable
fun OtpInput( fun OtpInput(otp: String, onOtpChanged: (String) -> Unit, onConfirmOtp: () -> Unit) {
otp: String,
onOtpChanged: (String) -> Unit,
onConfirmOtp: () -> Unit,
modifier: Modifier = Modifier,
) {
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
Column(modifier = modifier) {
Text(stringResource(R.string.enter_otp)) Text(stringResource(R.string.enter_otp))
TextField( TextField(
otp, otp,
@ -97,18 +84,17 @@ fun OtpInput(
Text(stringResource(R.string.do_login)) Text(stringResource(R.string.do_login))
} }
} }
}
@Composable @Composable
fun LoginScreen(onConfirmOtp: () -> Unit, modifier: Modifier = Modifier) { fun LoginScreen(onConfirmOtp: () -> Unit) {
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("") }
val loginProcedure by remember { mutableStateOf(LoginProcedure()) } var tokenPtr by remember { mutableStateOf(0L) }
Column( Column(
modifier = modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
@ -127,7 +113,7 @@ fun LoginScreen(onConfirmOtp: () -> Unit, modifier: Modifier = Modifier) {
phone, phone,
onPhoneChanged = { phone = it }, onPhoneChanged = { phone = it },
onSendCode = { onSendCode = {
loginProcedure.requestLoginCode(phone) tokenPtr = Native.requestLoginCode(phone)
stage = LoginStage.ASK_CODE stage = LoginStage.ASK_CODE
} }
) )
@ -135,7 +121,7 @@ fun LoginScreen(onConfirmOtp: () -> Unit, modifier: Modifier = Modifier) {
otp, otp,
onOtpChanged = { otp = it }, onOtpChanged = { otp = it },
onConfirmOtp = { onConfirmOtp = {
loginProcedure.signIn(otp) Native.signIn(tokenPtr, otp)
onConfirmOtp() onConfirmOtp()
} }
) )

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

@ -15,8 +15,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"
} }

0
gradlew vendored Executable file → Normal file
View File

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::{Client, Config};
use grammers_client::{Client, Config, InitParams}; use grammers_client::types::{Dialog, LoginToken};
use grammers_session::{PackedChat, Session, UpdateState}; use grammers_session::{PackedChat, Session};
use grammers_tl_types as tl; use jni::JNIEnv;
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 = { 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,188 @@ 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<()> {
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)) client.sign_in(&token, &code).await?;
.map_err(|_| NativeError::Network)?;
*self.token.lock().unwrap() = Some(token);
Ok(()) Ok(())
} }
fn sign_in(&self, code: String) -> Result<()> { async fn get_dialogs() -> Result<Vec<Dialog>> {
let token = self let client = CLIENT.get().ok_or("Client not initialized")?;
.token
.lock()
.unwrap()
.take()
.ok_or(NativeError::Initialization)?;
let client = CLIENT.get().ok_or(NativeError::Initialization)?;
block_on(client.sign_in(&token, &code)).map_err(|_| NativeError::Network)?;
let guard = DATABASE.lock().unwrap();
let conn = match guard.as_ref() {
Some(c) => c,
None => {
error!("Database was not initialized");
return Err(NativeError::Initialization);
}
};
let mut session = db::create_session(conn).map_err(|_| NativeError::Database)?;
let s = client.session();
if let Some(user) = s.get_user() {
session.user_id = Some(user.id);
session.dc_id = Some(user.dc);
session.bot = Some(user.bot);
}
if let Some(state) = s.get_state() {
session.pts = Some(state.pts);
session.qts = Some(state.qts);
session.seq = Some(state.seq);
session.date = Some(state.date);
}
if let Some(dc_id) = session.dc_id {
for dc in s.get_dcs() {
if dc.id == dc_id {
if let Some(ipv4) = dc.ipv4 {
session.dc_addr = Some(Ipv4Addr::from(ipv4.to_le_bytes()).to_string())
} else if let Some(ipv6) = dc.ipv6 {
session.dc_addr = Some(Ipv6Addr::from(ipv6).to_string())
}
session.dc_port = Some(dc.port as u16);
session.dc_auth = dc.auth.map(|b| b.try_into().unwrap());
break;
}
}
}
db::update_session(conn, &session).map_err(|_| NativeError::Database)?;
Ok(())
}
}
pub fn get_dialogs() -> Result<Vec<Dialog>> {
let client = CLIENT.get().ok_or(NativeError::Initialization)?;
block_on(async {
let mut result = Vec::new(); let mut 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 {
($formatting:ident($entity:ident)) => {
tf!($formatting($entity).extra(None))
};
($formatting:ident($entity:ident).extra($extra:expr)) => {
TextFormat {
format: Formatting::$formatting,
offset: $entity.offset,
length: $entity.length,
extra: $extra,
}
};
} }
entities #[no_mangle]
.into_iter() pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_initClient(_: JNIEnv, _: JObject) {
.map(|e| match e { match block_on(init_client()) {
ME::Unknown(e) => tf!(Unknown(e)), Ok(_) => info!("Client initialized"),
ME::Mention(e) => tf!(Mention(e)), Err(e) => error!("Failed to initialize client: {}", e),
ME::Hashtag(e) => tf!(HashTag(e)),
ME::BotCommand(e) => tf!(BotCommand(e)),
ME::Url(e) => tf!(Url(e)),
ME::Email(e) => tf!(Email(e)),
ME::Bold(e) => tf!(Bold(e)),
ME::Italic(e) => tf!(Italic(e)),
ME::Code(e) => tf!(Code(e)),
ME::Pre(e) => tf!(Pre(e).extra(Some(e.language.to_string()))),
ME::TextUrl(e) => tf!(TextUrl(e).extra(Some(e.url.to_string()))),
ME::MentionName(e) => tf!(MentionName(e).extra(Some(e.user_id.to_string()))),
ME::InputMessageEntityMentionName(e) => tf!(Unknown(e)),
ME::Phone(e) => tf!(Phone(e)),
ME::Cashtag(e) => tf!(CashTag(e)),
ME::Underline(e) => tf!(Underline(e)),
ME::Strike(e) => tf!(Strike(e)),
ME::Blockquote(e) => tf!(Blockquote(e)),
ME::BankCard(e) => tf!(BankCard(e)),
ME::Spoiler(e) => tf!(Spoiler(e)),
ME::CustomEmoji(e) => {
tf!(CustomEmoji(e).extra(Some(e.document_id.to_string())))
}
})
.collect()
} else {
Vec::new()
},
} }
} }
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_needLogin(
let client = CLIENT.get().ok_or(NativeError::Initialization)?; _: JNIEnv,
_: JObject,
block_on(async { ) -> jboolean {
let mut result = Vec::new(); match block_on(need_login()) {
let mut messages = client.iter_messages(chat); Ok(login) => login.into(),
while let Some(message) = messages.next().await.map_err(|_| NativeError::Network)? { Err(e) => {
result.push(message); error!("Failed to check if user is authorized: {}", e);
false.into()
}
} }
Ok(result)
})
.map(|messages| messages.into_iter().map(adapt_message).collect())
} }
pub fn send_message(packed: String, text: String) -> Result<Message> { #[no_mangle]
let chat = PackedChat::from_hex(&packed).unwrap(); pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_requestLoginCode(
let client = CLIENT.get().ok_or(NativeError::Initialization)?; env: JNIEnv,
block_on(client.send_message(chat, text)) _: JObject,
.map(adapt_message) phone: JString,
.map_err(|_| NativeError::Network) ) -> jlong {
let phone = CString::from(CStr::from_ptr(env.get_string(phone).unwrap().as_ptr()));
match block_on(request_login_code(phone.to_str().unwrap())) {
Ok(token) => Box::into_raw(Box::new(token)) as jlong,
Err(e) => {
error!("Failed to request login code: {}", e);
0 as jlong
}
}
}
#[no_mangle]
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_signIn(
env: JNIEnv,
_: JObject,
token_ptr: jlong,
code: JString,
) {
let token = *Box::from_raw(token_ptr as *mut LoginToken);
let code = CString::from(CStr::from_ptr(env.get_string(code).unwrap().as_ptr()));
match block_on(sign_in(token, code.to_str().unwrap())) {
Ok(_) => info!("Sign in success"),
Err(e) => error!("Failed to sign in: {}", e),
}
}
#[no_mangle]
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_getDialogs(
_: JNIEnv,
_: JObject,
) -> jlong {
match block_on(get_dialogs()) {
Ok(dialogs) => Box::into_raw(Box::new(dialogs)) as jlong,
Err(e) => {
error!("Failed to get dialogs: {}", e);
0 as jlong
}
}
}
#[no_mangle]
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_dialogCount(
_: JNIEnv,
_: JObject,
dialogs_ptr: jlong,
) -> jint {
let dialogs = Box::leak(Box::from_raw(dialogs_ptr as *mut Vec<Dialog>));
dialogs.len() as jint
}
#[no_mangle]
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_dialogPacked(
env: JNIEnv,
_: JObject,
dialogs_ptr: jlong,
index: jint,
) -> jstring {
let dialogs = Box::leak(Box::from_raw(dialogs_ptr as *mut Vec<Dialog>));
let packed = dialogs[index as usize].chat().pack().to_hex();
let output = env.new_string(packed).unwrap();
output.into_inner()
}
#[no_mangle]
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_dialogTitle(
env: JNIEnv,
_: JObject,
dialogs_ptr: jlong,
index: jint,
) -> jstring {
let dialogs = Box::leak(Box::from_raw(dialogs_ptr as *mut Vec<Dialog>));
let title = dialogs[index as usize].chat().name();
let output = env.new_string(title).unwrap();
output.into_inner()
}
#[no_mangle]
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_freeDialogs(
_: JNIEnv,
_: JObject,
dialogs_ptr: jlong,
) {
let _ = Box::from_raw(dialogs_ptr as *mut Vec<Dialog>);
}
#[no_mangle]
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_sendMessage(
env: JNIEnv,
_: JObject,
packed: JString,
text: JString,
) {
let packed = CString::from(CStr::from_ptr(env.get_string(packed).unwrap().as_ptr()));
let text = CString::from(CStr::from_ptr(env.get_string(text).unwrap().as_ptr()));
let packed = PackedChat::from_hex(packed.to_str().unwrap()).unwrap();
match block_on(send_message(packed, text.to_str().unwrap())) {
Ok(_) => info!("Message sent"),
Err(e) => error!("Failed to send message: {}", e),
}
} }

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