Integrate with Telegram's API

This commit is contained in:
Lonami Exo 2022-10-12 21:26:51 +02:00
parent 9c33d1fbb0
commit 476f80cb5e
11 changed files with 307 additions and 34 deletions

View File

@ -2,6 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"

View File

@ -7,6 +7,7 @@ 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
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@ -23,5 +24,7 @@ class MainActivity : ComponentActivity() {
} }
} }
} }
Native.initClient()
} }
} }

View File

@ -5,8 +5,7 @@ import androidx.compose.foundation.layout.padding
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.ArrowBack
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
@ -47,6 +46,8 @@ fun TalariaApp() {
val currentScreen = val currentScreen =
TalariaScreen.valueOf(backStackEntry?.destination?.route ?: TalariaScreen.Login.name) TalariaScreen.valueOf(backStackEntry?.destination?.route ?: TalariaScreen.Login.name)
var selectedDialog by remember { mutableStateOf("") }
Scaffold( Scaffold(
topBar = { topBar = {
TalariaAppBar( TalariaAppBar(
@ -58,16 +59,17 @@ fun TalariaApp() {
) { innerPadding -> ) { innerPadding ->
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = TalariaScreen.Dialog.name, startDestination = TalariaScreen.Login.name,
Modifier.padding(innerPadding) Modifier.padding(innerPadding)
) { ) {
composable(route = TalariaScreen.Dialog.name) { composable(route = TalariaScreen.Dialog.name) {
DialogScreen(onDialogSelected = { DialogScreen(onDialogSelected = {
selectedDialog = it
navController.navigate(TalariaScreen.Chat.name) navController.navigate(TalariaScreen.Chat.name)
}) })
} }
composable(route = TalariaScreen.Chat.name) { composable(route = TalariaScreen.Chat.name) {
ChatScreen() ChatScreen(selectedDialog)
} }
composable(route = TalariaScreen.Login.name) { composable(route = TalariaScreen.Login.name) {
LoginScreen(onConfirmOtp = { LoginScreen(onConfirmOtp = {

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,20 +1,25 @@
package dev.lonami.talaria.data package dev.lonami.talaria.data
import dev.lonami.talaria.bindings.Native
import dev.lonami.talaria.model.Dialog import dev.lonami.talaria.model.Dialog
object DialogSource { object DialogSource {
fun loadDialogs(): List<Dialog> { fun loadDialogs(): List<Dialog> {
return listOf( val dialogs = mutableListOf<Dialog>()
Dialog("Saved Messages", "Secret launch-code: banana", pinned = true),
Dialog("First Sample Dialog", "Photo", pinned = false), val dialogPtr = Native.getDialogs()
Dialog("Second Sample Dialog", "Video", pinned = false), val dialogCount = Native.dialogCount(dialogPtr)
Dialog("Third Sample Dialog", "Audio", pinned = false), for (i in 0 until dialogCount) {
Dialog("Fourth Sample Dialog", "Sticker (just kidding who uses that)", pinned = false), dialogs.add(
Dialog("Fifth Sample Dialog", "Photo", pinned = false), Dialog(
Dialog("Sixth Sample Dialog", "Video", pinned = false), Native.dialogTitle(dialogPtr, i),
Dialog("Seventh Sample Dialog", "Audio", pinned = false), Native.dialogPacked(dialogPtr, i),
Dialog("Eighth Sample Dialog", "Sticker (just kidding who uses that)", pinned = false), false
Dialog("Ninth Sample Dialog", "Hello, scroll!", pinned = false), )
) )
} }
Native.freeDialogs(dialogPtr)
return dialogs
}
} }

View File

@ -77,7 +77,7 @@ fun MessageInputField(
} }
@Composable @Composable
fun ChatScreen(chatViewModel: ChatViewModel = viewModel()) { fun ChatScreen(selectedDialog: String, chatViewModel: ChatViewModel = viewModel()) {
val chatUiState by chatViewModel.uiState.collectAsState() val chatUiState by chatViewModel.uiState.collectAsState()
var messageText by remember { mutableStateOf("") } var messageText by remember { mutableStateOf("") }
val messageListState = rememberLazyListState() val messageListState = rememberLazyListState()
@ -92,7 +92,7 @@ fun ChatScreen(chatViewModel: ChatViewModel = viewModel()) {
MessageInputField(messageText, onMessageChanged = { MessageInputField(messageText, onMessageChanged = {
messageText = it messageText = it
}, onSendMessage = { }, onSendMessage = {
chatViewModel.sendMessage(messageText) chatViewModel.sendMessage(selectedDialog, messageText)
messageText = "" messageText = ""
coroutineScope.launch { coroutineScope.launch {
messageListState.animateScrollToItem(chatUiState.messages.size - 1) messageListState.animateScrollToItem(chatUiState.messages.size - 1)
@ -105,6 +105,6 @@ fun ChatScreen(chatViewModel: ChatViewModel = viewModel()) {
@Composable @Composable
fun ChatPreview() { fun ChatPreview() {
TalariaTheme { TalariaTheme {
ChatScreen() ChatScreen("")
} }
} }

View File

@ -1,6 +1,7 @@
package dev.lonami.talaria.ui package dev.lonami.talaria.ui
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import dev.lonami.talaria.bindings.Native
import dev.lonami.talaria.data.MessageSource import dev.lonami.talaria.data.MessageSource
import dev.lonami.talaria.model.Message import dev.lonami.talaria.model.Message
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -16,7 +17,8 @@ class ChatViewModel : ViewModel() {
_uiState.value = ChatUiState(MessageSource.loadMessages().toMutableList()) _uiState.value = ChatUiState(MessageSource.loadMessages().toMutableList())
} }
fun sendMessage(message: String) { fun sendMessage(dialog: String, message: String) {
Native.sendMessage(dialog, message)
_uiState.update { state -> _uiState.update { state ->
state.messages.add(Message("You", message)) state.messages.add(Message("You", message))
state state

View File

@ -47,18 +47,21 @@ fun DialogCard(dialog: Dialog, onDialogSelected: () -> Unit) {
} }
@Composable @Composable
fun DialogList(dialogs: List<Dialog>, onDialogSelected: (Int) -> Unit) { fun DialogList(dialogs: List<Dialog>, onDialogSelected: (String) -> Unit) {
LazyColumn { LazyColumn {
items(dialogs.size) { items(dialogs.size) {
DialogCard(dialogs[it], onDialogSelected = { DialogCard(dialogs[it], onDialogSelected = {
onDialogSelected(it) onDialogSelected(dialogs[it].lastMessage)
}) })
} }
} }
} }
@Composable @Composable
fun DialogScreen(onDialogSelected: (Int) -> Unit, dialogViewModel: DialogViewModel = viewModel()) { fun DialogScreen(
onDialogSelected: (String) -> Unit,
dialogViewModel: DialogViewModel = viewModel()
) {
val dialogUiState by dialogViewModel.uiState.collectAsState() val dialogUiState by dialogViewModel.uiState.collectAsState()
DialogList(dialogUiState.dialogs, onDialogSelected = onDialogSelected) DialogList(dialogUiState.dialogs, onDialogSelected = onDialogSelected)
} }

View File

@ -18,6 +18,7 @@ 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
enum class LoginStage { enum class LoginStage {
@ -90,6 +91,8 @@ fun LoginScreen(onConfirmOtp: () -> Unit) {
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) }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -109,11 +112,18 @@ fun LoginScreen(onConfirmOtp: () -> Unit) {
LoginStage.ASK_PHONE -> PhoneInput( LoginStage.ASK_PHONE -> PhoneInput(
phone, phone,
onPhoneChanged = { phone = it }, onPhoneChanged = { phone = it },
onSendCode = { stage = LoginStage.ASK_CODE }) onSendCode = {
tokenPtr = Native.requestLoginCode(phone)
stage = LoginStage.ASK_CODE
}
)
LoginStage.ASK_CODE -> OtpInput( LoginStage.ASK_CODE -> OtpInput(
otp, otp,
onOtpChanged = { otp = it }, onOtpChanged = { otp = it },
onConfirmOtp = onConfirmOtp onConfirmOtp = {
Native.signIn(tokenPtr, otp)
onConfirmOtp()
}
) )
} }
} }

View File

@ -9,6 +9,14 @@ crate-type = ["cdylib"]
[dependencies] [dependencies]
jni = { version = "0.10.2", default-features = false } jni = { version = "0.10.2", default-features = false }
# v0.4 of grammers-* is currently unreleased; clone the project and use path dependencies
grammers-client = { version = "0.4.0" }
grammers-tl-types = { version = "0.4.0" }
grammers-session = { version = "0.4.0" }
tokio = { version = "1.5.0", features = ["full"] }
log = "0.4.14"
android_logger = "0.11.1"
once_cell = "1.15.0"
[profile.release] [profile.release]
lto = true lto = true

View File

@ -2,19 +2,239 @@
#![allow(non_snake_case)] #![allow(non_snake_case)]
use std::ffi::{CStr, CString}; use std::ffi::{CStr, CString};
use std::future::Future;
use grammers_client::{Client, Config};
use grammers_client::types::{Dialog, LoginToken};
use grammers_session::{PackedChat, Session};
use jni::JNIEnv; use jni::JNIEnv;
use jni::objects::{JObject, JString}; use jni::objects::{JObject, JString};
use jni::sys::jstring; use jni::sys::{jboolean, jint, jlong, jstring};
use log;
use log::{error, info, Level};
use once_cell::sync::OnceCell;
use tokio::runtime;
use tokio::runtime::Runtime;
#[no_mangle] const LOG_MIN_LEVEL: Level = Level::Trace;
pub unsafe extern fn Java_dev_lonami_talaria_MainActivity_hello(env: JNIEnv, _: JObject, j_recipient: JString) -> jstring { const LOG_TAG: &str = ".native.talari";
let recipient = CString::from( const API_ID: i32 = 0;
CStr::from_ptr( const API_HASH: &str = "";
env.get_string(j_recipient).unwrap().as_ptr()
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
static RUNTIME: OnceCell<Runtime> = OnceCell::new();
static CLIENT: OnceCell<Client> = OnceCell::new();
fn block_on<F: Future>(future: F) -> F::Output {
if RUNTIME.get().is_none() {
RUNTIME
.set(
runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap(),
) )
.ok();
}
RUNTIME.get().unwrap().block_on(future)
}
async fn init_client() -> Result<()> {
android_logger::init_once(
android_logger::Config::default()
.with_min_level(LOG_MIN_LEVEL)
.with_tag(LOG_TAG),
); );
let output = env.new_string("Hello ".to_owned() + recipient.to_str().unwrap()).unwrap(); if CLIENT.get().is_some() {
info!("Client is already initialized");
return Ok(());
}
info!("Connecting to Telegram...");
let client = Client::connect(Config {
session: Session::new(),
api_id: API_ID,
api_hash: API_HASH.to_string(),
params: Default::default(),
})
.await?;
info!("Connected!");
CLIENT
.set(client)
.map_err(|_| "Client was already initialized")?;
Ok(())
}
async fn need_login() -> Result<bool> {
let client = CLIENT.get().ok_or("Client not initialized")?;
Ok(client.is_authorized().await?)
}
async fn request_login_code(phone: &str) -> Result<LoginToken> {
let client = CLIENT.get().ok_or("Client not initialized")?;
let token = client.request_login_code(&phone, API_ID, API_HASH).await?;
Ok(token)
}
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]
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_requestLoginCode(
env: JNIEnv,
_: JObject,
phone: JString,
) -> jlong {
let phone = CString::from(CStr::from_ptr(env.get_string(phone).unwrap().as_ptr()));
match block_on(request_login_code(phone.to_str().unwrap())) {
Ok(token) => Box::into_raw(Box::new(token)) as jlong,
Err(e) => {
error!("Failed to request login code: {}", e);
0 as jlong
}
}
}
#[no_mangle]
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_signIn(
env: JNIEnv,
_: JObject,
token_ptr: jlong,
code: JString,
) {
let token = *Box::from_raw(token_ptr as *mut LoginToken);
let code = CString::from(CStr::from_ptr(env.get_string(code).unwrap().as_ptr()));
match block_on(sign_in(token, code.to_str().unwrap())) {
Ok(_) => info!("Sign in success"),
Err(e) => error!("Failed to sign in: {}", e),
}
}
#[no_mangle]
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_getDialogs(
_: JNIEnv,
_: JObject,
) -> jlong {
match block_on(get_dialogs()) {
Ok(dialogs) => Box::into_raw(Box::new(dialogs)) as jlong,
Err(e) => {
error!("Failed to get dialogs: {}", e);
0 as jlong
}
}
}
#[no_mangle]
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_dialogCount(
_: JNIEnv,
_: JObject,
dialogs_ptr: jlong,
) -> jint {
let dialogs = Box::leak(Box::from_raw(dialogs_ptr as *mut Vec<Dialog>));
dialogs.len() as jint
}
#[no_mangle]
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_dialogPacked(
env: JNIEnv,
_: JObject,
dialogs_ptr: jlong,
index: jint,
) -> jstring {
let dialogs = Box::leak(Box::from_raw(dialogs_ptr as *mut Vec<Dialog>));
let packed = dialogs[index as usize].chat().pack().to_hex();
let output = env.new_string(packed).unwrap();
output.into_inner() 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),
}
}