Message timestamps

This commit is contained in:
Matéo Duparc 2021-06-16 20:57:11 +02:00
parent 6a1d8b61f4
commit 8f33a640ce
Signed by: hardcoresushi
GPG Key ID: 007F84120107191E
14 changed files with 173 additions and 73 deletions

View File

@ -12,7 +12,7 @@ object AIRADatabase {
external fun setContactSeen(contactUuid: String, seen: Boolean): Boolean
external fun changeContactName(contactUuid: String, newName: String): Boolean
external fun setContactAvatar(contactUuid: String, avatarUuid: String?): Boolean
external fun storeMsg(contactUuid: String, outgoing: Boolean, data: ByteArray): Boolean
external fun storeMsg(contactUuid: String, outgoing: Boolean, timestamp: Long, data: ByteArray): Boolean
external fun storeFile(contactUuid: String?, data: ByteArray): ByteArray?
external fun loadMsgs(uuid: String, offset: Int, count: Int): ArrayList<ChatItem>?
external fun loadFile(rawUuid: ByteArray): ByteArray?

View File

@ -23,6 +23,7 @@ import sushi.hardcore.aira.databinding.DialogFingerprintsBinding
import sushi.hardcore.aira.databinding.DialogInfoBinding
import sushi.hardcore.aira.utils.FileUtils
import sushi.hardcore.aira.utils.StringUtils
import sushi.hardcore.aira.utils.TimeUtils
class ChatActivity : ServiceBoundActivity() {
private external fun generateFingerprint(publicKey: ByteArray): String
@ -77,7 +78,7 @@ class ChatActivity : ServiceBoundActivity() {
val msg = binding.editMessage.text.toString()
airaService.sendTo(sessionId, Protocol.newMessage(msg))
binding.editMessage.text.clear()
chatAdapter.newMessage(ChatItem(true, Protocol.newMessage(msg)))
chatAdapter.newMessage(ChatItem(true, TimeUtils.getTimestamp(), Protocol.newMessage(msg)))
if (airaService.contacts.contains(sessionId)) {
lastLoadedMessageOffset += 1
}
@ -178,10 +179,10 @@ class ChatActivity : ServiceBoundActivity() {
}
}
}
override fun onNewMessage(sessionId: Int, data: ByteArray): Boolean {
override fun onNewMessage(sessionId: Int, timestamp: Long, data: ByteArray): Boolean {
return if (this@ChatActivity.sessionId == sessionId) {
runOnUiThread {
chatAdapter.newMessage(ChatItem(false, data))
chatAdapter.newMessage(ChatItem(false, timestamp, data))
binding.recyclerChat.smoothScrollToPosition(chatAdapter.itemCount)
}
if (airaService.contacts.contains(sessionId)) {

View File

@ -2,7 +2,7 @@ package sushi.hardcore.aira
import sushi.hardcore.aira.background_service.Protocol
class ChatItem(val outgoing: Boolean, val data: ByteArray) {
class ChatItem(val outgoing: Boolean, val timestamp: Long, val data: ByteArray) {
companion object {
const val OUTGOING_MESSAGE = 0
const val INCOMING_MESSAGE = 1

View File

@ -73,7 +73,7 @@ class MainActivity : ServiceBoundActivity() {
onlineSessionAdapter.setAvatar(sessionId, avatar)
}
}
override fun onNewMessage(sessionId: Int, data: ByteArray): Boolean {
override fun onNewMessage(sessionId: Int, timestamp: Long, data: ByteArray): Boolean {
runOnUiThread {
onlineSessionAdapter.setSeen(sessionId, false)
}

View File

@ -10,7 +10,6 @@ import android.widget.EditText
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.preference.EditTextPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SwitchPreferenceCompat

View File

@ -1,5 +1,6 @@
package sushi.hardcore.aira.adapters
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
@ -15,6 +16,8 @@ import androidx.core.view.updatePadding
import androidx.recyclerview.widget.RecyclerView
import sushi.hardcore.aira.ChatItem
import sushi.hardcore.aira.R
import sushi.hardcore.aira.utils.StringUtils
import java.util.*
class ChatAdapter(
private val context: Context,
@ -52,8 +55,9 @@ class ChatAdapter(
itemView.updatePadding(left = BUBBLE_HORIZONTAL_PADDING)
}
}
protected fun configureBubble(context: Context, view: View, outgoing: Boolean) {
view.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
protected fun configureBubble(context: Context, outgoing: Boolean) {
val bubble = itemView.findViewById<LinearLayout>(R.id.bubble_content)
bubble.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
gravity = if (outgoing) {
marginStart = CONTAINER_MARGIN
Gravity.END
@ -63,12 +67,21 @@ class ChatAdapter(
}
}
if (!outgoing) {
view.background.colorFilter = PorterDuffColorFilter(
bubble.background.colorFilter = PorterDuffColorFilter(
ContextCompat.getColor(context, R.color.incomingBubbleBackground),
PorterDuff.Mode.SRC
)
}
}
protected fun setTimestamp(chatItem: ChatItem): TextView {
val calendar = Calendar.getInstance().apply {
time = Date(chatItem.timestamp * 1000)
}
val textView = itemView.findViewById<TextView>(R.id.timestamp)
@SuppressLint("SetTextI18n")
textView.text = StringUtils.toTwoDigits(calendar.get(Calendar.HOUR_OF_DAY))+":"+StringUtils.toTwoDigits(calendar.get(Calendar.MINUTE))
return textView
}
}
internal open class MessageViewHolder(itemView: View): BubbleViewHolder(itemView) {
@ -82,43 +95,58 @@ class ChatAdapter(
internal class OutgoingMessageViewHolder(private val context: Context, itemView: View): MessageViewHolder(itemView) {
fun bind(chatItem: ChatItem) {
configureBubble(context, bindMessage(chatItem).apply {
setLinkTextColor(ContextCompat.getColor(context, R.color.outgoingTextLink))
}, true)
setTimestamp(chatItem).apply {
setTextColor(ContextCompat.getColor(context, R.color.outgoingTimestamp))
}
configureBubble(context, true)
bindMessage(chatItem).apply {
setLinkTextColor(ContextCompat.getColor(context, R.color.outgoingTextLink))
}
setPadding(true)
}
}
internal class IncomingMessageViewHolder(private val context: Context, itemView: View): MessageViewHolder(itemView) {
fun bind(chatItem: ChatItem) {
configureBubble(context, bindMessage(chatItem).apply {
setTimestamp(chatItem).apply {
setTextColor(ContextCompat.getColor(context, R.color.incomingTimestamp))
}
configureBubble(context, false)
bindMessage(chatItem).apply {
setLinkTextColor(ContextCompat.getColor(context, R.color.incomingTextLink))
}, false)
}
setPadding(false)
}
}
internal open class FileViewHolder(itemView: View, private val onSavingFile: (filename: String, rawUuid: ByteArray) -> Unit): BubbleViewHolder(itemView) {
protected fun bindFile(chatItem: ChatItem): LinearLayout {
protected fun bindFile(chatItem: ChatItem) {
val filename = chatItem.data.sliceArray(17 until chatItem.data.size).decodeToString()
itemView.findViewById<TextView>(R.id.text_filename).text = filename
itemView.findViewById<ImageButton>(R.id.button_save).setOnClickListener {
onSavingFile(filename, chatItem.data.sliceArray(1 until 17))
}
return itemView.findViewById(R.id.bubble_content)
}
}
internal class OutgoingFileViewHolder(private val context: Context, itemView: View, onSavingFile: (filename: String, rawUuid: ByteArray) -> Unit): FileViewHolder(itemView, onSavingFile) {
fun bind(chatItem: ChatItem) {
configureBubble(context, bindFile(chatItem), true)
setTimestamp(chatItem).apply {
setTextColor(ContextCompat.getColor(context, R.color.outgoingTimestamp))
}
bindFile(chatItem)
configureBubble(context, true)
setPadding(true)
}
}
internal class IncomingFileViewHolder(private val context: Context, itemView: View, onSavingFile: (filename: String, rawUuid: ByteArray) -> Unit): FileViewHolder(itemView, onSavingFile) {
fun bind(chatItem: ChatItem) {
configureBubble(context, bindFile(chatItem), false)
setTimestamp(chatItem).apply {
setTextColor(ContextCompat.getColor(context, R.color.incomingTimestamp))
}
bindFile(chatItem)
configureBubble(context, false)
setPadding(false)
}
}

View File

@ -17,6 +17,7 @@ import androidx.core.app.RemoteInput
import sushi.hardcore.aira.*
import sushi.hardcore.aira.utils.FileUtils
import sushi.hardcore.aira.utils.StringUtils
import sushi.hardcore.aira.utils.TimeUtils
import java.io.IOException
import java.net.*
import java.nio.channels.*
@ -108,7 +109,7 @@ class AIRAService : Service() {
fun onSessionDisconnect(sessionId: Int)
fun onNameTold(sessionId: Int, name: String)
fun onAvatarChanged(sessionId: Int, avatar: ByteArray?)
fun onNewMessage(sessionId: Int, data: ByteArray): Boolean
fun onNewMessage(sessionId: Int, timestamp: Long, data: ByteArray): Boolean
fun onAskLargeFiles(sessionId: Int, filesReceiver: FilesReceiver): Boolean
}
@ -164,7 +165,7 @@ class AIRAService : Service() {
sendFile.inputStream.close()
sendTo(sessionId, Protocol.newFile(sendFile.fileName, buffer))
AIRADatabase.storeFile(contacts[sessionId]?.uuid, buffer)?.let { rawFileUuid ->
saveMsg(sessionId, byteArrayOf(Protocol.FILE) + rawFileUuid + sendFile.fileName.toByteArray())
saveMsg(sessionId, TimeUtils.getTimestamp(), byteArrayOf(Protocol.FILE) + rawFileUuid + sendFile.fileName.toByteArray())
}
}
@ -203,7 +204,7 @@ class AIRAService : Service() {
contacts[sessionId] = contact
savedMsgs.remove(sessionId)?.let { msgs ->
for (msg in msgs) {
AIRADatabase.storeMsg(contact.uuid, msg.outgoing, msg.data)
AIRADatabase.storeMsg(contact.uuid, msg.outgoing, msg.timestamp, msg.data)
}
}
savedNames.remove(sessionId)
@ -419,20 +420,20 @@ class AIRAService : Service() {
)
}
private fun saveMsg(sessionId: Int, msg: ByteArray) {
private fun saveMsg(sessionId: Int, timestamp: Long, msg: ByteArray) {
var msgSaved = false
contacts[sessionId]?.uuid?.let { uuid ->
msgSaved = AIRADatabase.storeMsg(uuid, true, msg)
msgSaved = AIRADatabase.storeMsg(uuid, true, timestamp, msg)
}
if (!msgSaved) {
savedMsgs[sessionId]?.add(ChatItem(true, msg))
savedMsgs[sessionId]?.add(ChatItem(true, timestamp, msg))
}
}
private fun sendAndSave(sessionId: Int, msg: ByteArray) {
sessions[sessionId]?.encryptAndSend(msg, usePadding)
if (msg[0] == Protocol.MESSAGE) {
saveMsg(sessionId, msg)
saveMsg(sessionId, TimeUtils.getTimestamp(), msg)
}
}
@ -810,17 +811,18 @@ class AIRAService : Service() {
null
}
}?.let { handledMsg ->
val timestamp = TimeUtils.getTimestamp()
var seen = false
uiCallbacks?.let { uiCallbacks ->
seen = uiCallbacks.onNewMessage(sessionId, handledMsg)
seen = uiCallbacks.onNewMessage(sessionId, timestamp, handledMsg)
}
setSeen(sessionId, seen)
var msgSaved = false
contacts[sessionId]?.let { contact ->
msgSaved = AIRADatabase.storeMsg(contact.uuid, false, handledMsg)
msgSaved = AIRADatabase.storeMsg(contact.uuid, false, timestamp, handledMsg)
}
if (!msgSaved){
savedMsgs[sessionId]?.add(ChatItem(false, handledMsg))
savedMsgs[sessionId]?.add(ChatItem(false, timestamp, handledMsg))
}
if (isAppInBackground) {
sendNotification(sessionId, handledMsg)

View File

@ -25,4 +25,12 @@ object StringUtils {
fun sanitizeName(name: String): String {
return name.replace('\n', ' ')
}
fun toTwoDigits(number: Int): String {
return if (number < 10) {
"0$number"
} else {
number.toString()
}
}
}

View File

@ -0,0 +1,7 @@
package sushi.hardcore.aira.utils
object TimeUtils {
fun getTimestamp(): Long {
return System.currentTimeMillis()/1000
}
}

View File

@ -43,12 +43,11 @@ fn get_database_path(database_folder: &str) -> String {
Path::new(database_folder).join(DB_NAME).to_str().unwrap().to_owned()
}
struct EncryptedIdentity {
name: String,
encrypted_keypair: Vec<u8>,
salt: Vec<u8>,
encrypted_master_key: Vec<u8>,
encrypted_use_padding: Vec<u8>,
#[derive(Debug, Clone)]
pub struct Message {
pub outgoing: bool,
pub timestamp: u64,
pub data: Vec<u8>,
}
pub struct Contact {
@ -60,6 +59,14 @@ pub struct Contact {
pub seen: bool,
}
struct EncryptedIdentity {
name: String,
encrypted_keypair: Vec<u8>,
salt: Vec<u8>,
encrypted_master_key: Vec<u8>,
encrypted_use_padding: Vec<u8>,
}
pub struct Identity {
pub name: String,
keypair: Keypair,
@ -251,16 +258,17 @@ impl Identity {
Ok(file_uuid)
}
pub fn store_msg(&self, contact_uuid: &Uuid, outgoing: bool, data: &[u8]) -> Result<usize, rusqlite::Error> {
pub fn store_msg(&self, contact_uuid: &Uuid, message: Message) -> Result<usize, rusqlite::Error> {
let db = Connection::open(self.get_database_path())?;
db.execute(&format!("CREATE TABLE IF NOT EXISTS \"{}\" (outgoing BLOB, data BLOB)", contact_uuid), [])?;
let outgoing_byte: u8 = bool_to_byte(outgoing);
db.execute(&format!("CREATE TABLE IF NOT EXISTS \"{}\" (outgoing BLOB, timestamp BLOB, data BLOB)", contact_uuid), [])?;
let outgoing_byte: u8 = bool_to_byte(message.outgoing);
let encrypted_outgoing = crypto::encrypt_data(&[outgoing_byte], &self.master_key).unwrap();
let encrypted_data = crypto::encrypt_data(data, &self.master_key).unwrap();
db.execute(&format!("INSERT INTO \"{}\" (outgoing, data) VALUES (?1, ?2)", contact_uuid), params![encrypted_outgoing, encrypted_data])
let encrypted_timestamp = crypto::encrypt_data(&message.timestamp.to_be_bytes(), &self.master_key).unwrap();
let encrypted_data = crypto::encrypt_data(&message.data, &self.master_key).unwrap();
db.execute(&format!("INSERT INTO \"{}\" (outgoing, timestamp, data) VALUES (?1, ?2, ?3)", contact_uuid), params![encrypted_outgoing, encrypted_timestamp, encrypted_data])
}
pub fn load_msgs(&self, contact_uuid: &Uuid, offset: usize, mut count: usize) -> Option<Vec<(bool, Vec<u8>)>> {
pub fn load_msgs(&self, contact_uuid: &Uuid, offset: usize, mut count: usize) -> Option<Vec<Message>> {
match Connection::open(self.get_database_path()) {
Ok(db) => {
if let Ok(mut stmt) = db.prepare(&format!("SELECT count(*) FROM \"{}\"", contact_uuid)) {
@ -271,7 +279,7 @@ impl Identity {
if offset+count >= total {
count = total-offset;
}
let mut stmt = db.prepare(&format!("SELECT outgoing, data FROM \"{}\" LIMIT {} OFFSET {}", contact_uuid, count, total-offset-count)).unwrap();
let mut stmt = db.prepare(&format!("SELECT outgoing, timestamp, data FROM \"{}\" LIMIT {} OFFSET {}", contact_uuid, count, total-offset-count)).unwrap();
let mut rows = stmt.query([]).unwrap();
let mut msgs = Vec::new();
while let Ok(Some(row)) = rows.next() {
@ -280,9 +288,19 @@ impl Identity {
Ok(outgoing) => {
match byte_to_bool(outgoing[0]) {
Ok(outgoing) => {
let encrypted_data: Vec<u8> = row.get(1).unwrap();
match crypto::decrypt_data(encrypted_data.as_slice(), &self.master_key) {
Ok(data) => msgs.push((outgoing, data)),
let encrypted_timestamp: Vec<u8> = row.get(1).unwrap();
match crypto::decrypt_data(&encrypted_timestamp, &self.master_key) {
Ok(timestamp) => {
let encrypted_data: Vec<u8> = row.get(2).unwrap();
match crypto::decrypt_data(encrypted_data.as_slice(), &self.master_key) {
Ok(data) => msgs.push(Message {
outgoing,
timestamp: u64::from_be_bytes(timestamp.try_into().unwrap()),
data,
}),
Err(e) => print_error!(e)
}
}
Err(e) => print_error!(e)
}
}

View File

@ -7,7 +7,7 @@ use std::{convert::TryInto, fmt::Display, str::FromStr, sync::{Mutex}};
use lazy_static::lazy_static;
use uuid::Uuid;
use android_log;
use identity::{Identity, Contact};
use identity::{Identity, Contact, Message};
use crate::crypto::{HandshakeKeys, ApplicationKeys};
lazy_static! {
@ -17,7 +17,7 @@ lazy_static! {
#[cfg(target_os="android")]
use jni::JNIEnv;
use jni::objects::{JClass, JObject, JString, JList, JValue};
use jni::sys::{jboolean, jint, jbyteArray, jobject};
use jni::sys::{jboolean, jint, jlong, jbyteArray, jobject};
fn jstring_to_string(env: JNIEnv, input: JString) -> String {
String::from(env.get_string(input).unwrap())
@ -287,8 +287,13 @@ pub fn Java_sushi_hardcore_aira_AIRADatabase_setContactSeen(env: JNIEnv, _: JCla
#[allow(non_snake_case)]
#[no_mangle]
pub fn Java_sushi_hardcore_aira_AIRADatabase_storeMsg(env: JNIEnv, _: JClass, contactUuid: JString, outgoing: jboolean, data: jbyteArray) -> jboolean {
result_to_jboolean(loaded_identity.lock().unwrap().as_ref().unwrap().store_msg(&jstring_to_uuid(env, contactUuid).unwrap(), jboolean_to_bool(outgoing), &env.convert_byte_array(data).unwrap()))
pub fn Java_sushi_hardcore_aira_AIRADatabase_storeMsg(env: JNIEnv, _: JClass, contactUuid: JString, outgoing: jboolean, timestamp: jlong, data: jbyteArray) -> jboolean {
let message = Message {
outgoing: jboolean_to_bool(outgoing),
timestamp: timestamp as u64,
data: env.convert_byte_array(data).unwrap(),
};
result_to_jboolean(loaded_identity.lock().unwrap().as_ref().unwrap().store_msg(&jstring_to_uuid(env, contactUuid).unwrap(), message))
}
#[allow(non_snake_case)]
@ -301,7 +306,11 @@ pub fn Java_sushi_hardcore_aira_AIRADatabase_loadMsgs(env: JNIEnv, _: JClass, uu
let array_list = JList::from_env(&env, array_list).unwrap();
let chat_item_class = env.find_class("sushi/hardcore/aira/ChatItem").unwrap();
for msg in msgs {
let chat_item_object = env.new_object(chat_item_class, "(Z[B)V", &[JValue::Bool(bool_to_jboolean(msg.0)), slice_to_jvalue(env, &msg.1)]).unwrap();
let chat_item_object = env.new_object(chat_item_class, "(ZJ[B)V", &[
JValue::Bool(bool_to_jboolean(msg.outgoing)),
JValue::Long(msg.timestamp as jlong),
slice_to_jvalue(env, &msg.data),
]).unwrap();
array_list.add(chat_item_object).unwrap();
}
*array_list

View File

@ -8,26 +8,38 @@
android:id="@+id/bubble_content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
style="@style/Bubble"
android:paddingStart="6dp"
android:paddingVertical="5dp">
android:orientation="vertical"
style="@style/Bubble">
<ImageButton
android:id="@+id/button_save"
android:layout_width="@dimen/image_button_size"
android:layout_height="@dimen/image_button_size"
android:src="@drawable/ic_save"
style="@style/ImageButton"
android:contentDescription="@string/download" />
<TextView
android:id="@+id/text_filename"
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textIsSelectable="true"
android:textColor="@color/messageTextColor"/>
android:gravity="center_vertical">
<ImageButton
android:id="@+id/button_save"
android:layout_width="@dimen/image_button_size"
android:layout_height="@dimen/image_button_size"
android:src="@drawable/ic_save"
android:scaleType="fitXY"
android:background="@color/transparent"
android:layout_marginEnd="8dp"
android:contentDescription="@string/download" />
<TextView
android:id="@+id/text_filename"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textIsSelectable="true"
android:textColor="@color/messageTextColor"/>
</LinearLayout>
<TextView
android:id="@+id/timestamp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"/>
</LinearLayout>

View File

@ -4,13 +4,27 @@
android:layout_height="wrap_content"
android:paddingVertical="1dp">
<TextView
android:id="@+id/text_message"
<LinearLayout
android:id="@+id/bubble_content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textIsSelectable="true"
android:autoLink="all"
android:textColor="@color/messageTextColor"
style="@style/Bubble"/>
android:orientation="vertical"
style="@style/Bubble">
<TextView
android:id="@+id/text_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textIsSelectable="true"
android:autoLink="all"
android:textColor="@color/messageTextColor"/>
<TextView
android:id="@+id/timestamp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"/>
</LinearLayout>
</LinearLayout>

View File

@ -12,4 +12,6 @@
<color name="itemSelected">#66666666</color>
<color name="transparent">#00000000</color>
<color name="sessionArrow">#777777</color>
<color name="outgoingTimestamp">#777777</color>
<color name="incomingTimestamp">#d7d7d7</color>
</resources>