From a176353357d5d4ffca485b565f62a873694f041a Mon Sep 17 00:00:00 2001 From: Leon <78073305+ARCOOON@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:17:05 +0100 Subject: [PATCH] Refocus README for developers --- README.md | 125 ++++++--- app/build.gradle | 31 ++- .../upchat/ConversationActivity.kt | 118 +++++++-- .../com/devusercode/upchat/HomeActivity.kt | 13 +- .../devusercode/upchat/ListUsersActivity.kt | 63 ++--- .../com/devusercode/upchat/LoginActivity.kt | 23 +- .../devusercode/upchat/MessagingService.kt | 2 +- .../devusercode/upchat/MyProfileActivity.kt | 36 +-- .../devusercode/upchat/RegisterActivity.kt | 27 +- .../com/devusercode/upchat/StartActivity.kt | 67 +++-- .../devusercode/upchat/UserProfileActivity.kt | 20 +- .../upchat/adapter/MessageAdapter.kt | 17 +- .../viewholder/ReceivedImageViewHolder.kt | 34 ++- .../viewholder/ReceivedMessageViewHolder.kt | 34 +-- .../adapter/viewholder/SentImageViewHolder.kt | 34 ++- .../viewholder/SentMessageViewHolder.kt | 30 ++- .../com/devusercode/upchat/models/Message.kt | 2 + .../com/devusercode/upchat/models/User.kt | 4 +- .../com/devusercode/upchat/security/AES.kt | 64 +++-- .../upchat/security/ConversationKeyManager.kt | 218 ++++++++++++++++ .../com/devusercode/upchat/security/ETE.kt | 44 ++-- .../com/devusercode/upchat/security/Hkdf.kt | 38 +++ .../com/devusercode/upchat/security/MAC.kt | 66 ++--- .../upchat/security/MessageIntegrity.kt | 54 ++++ .../upchat/utils/ActivityTransitions.kt | 26 ++ .../upchat/utils/ComposeInterop.kt | 26 ++ .../upchat/utils/ConversationUtil.kt | 91 ++++--- .../upchat/utils/StorageController.kt | 243 ++++++++++++------ .../java/com/devusercode/upchat/utils/Util.kt | 12 +- .../res/layout/dialog_download_progress.xml | 39 +++ app/src/main/res/values/strings.xml | 4 + gradlew | 0 32 files changed, 1192 insertions(+), 413 deletions(-) create mode 100644 app/src/main/java/com/devusercode/upchat/security/ConversationKeyManager.kt create mode 100644 app/src/main/java/com/devusercode/upchat/security/Hkdf.kt create mode 100644 app/src/main/java/com/devusercode/upchat/security/MessageIntegrity.kt create mode 100644 app/src/main/java/com/devusercode/upchat/utils/ActivityTransitions.kt create mode 100644 app/src/main/java/com/devusercode/upchat/utils/ComposeInterop.kt create mode 100644 app/src/main/res/layout/dialog_download_progress.xml mode change 100644 => 100755 gradlew diff --git a/README.md b/README.md index a0ff347..164f095 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,105 @@ # UpChat -UpChat is an open source messenger for android. - [![Latest Build](https://github.com/ARCOOON/UpChat/actions/workflows/build.yml/badge.svg)](https://github.com/ARCOOON/UpChat/actions/workflows/build.yml) -# Development Status +UpChat is an Android chat client built with Kotlin, Firebase Realtime Database, and a locally managed cryptography stack. This document focuses on how the project is organized and how to get productive quickly as a contributor. + +--- + +## What’s in the box? + +- **Realtime messaging** – Conversations sync through Firebase Database and Firebase Cloud Messaging. +- **Encrypted content** – AES-GCM keys are negotiated per conversation, with canonical MACs for message integrity. +- **Media sharing** – Inline photo capture/upload with storage backed by Firebase Storage. +- **Secure storage** – Jetpack DataStore wrapped in Android Keystore protects local session data. +- **Tooling** – GitHub Actions workflow builds debug/release artifacts and surfaces lint output. + +--- -- [x] Conversation - - [x] Send Message - - [ ] Send Files - - [x] Send Photos / Videos (Photos supported) - - [ ] Send Audio - - [ ] Highlight Links - - [ ] Message Actions: Delete, Reply and Edit - - [x] Messages design +## Project layout -- [x] Home - - [x] View all you chats - - [X] Delete a single message _(working on)_ - - [ ] Delete a conversation - - [x] Is the Main Activity +``` +app/ +├─ src/main/java/com/devusercode/upchat/ +│ ├─ security/ # AES, MAC, HKDF, RSA helpers +│ ├─ utils/ # Conversation helpers, storage access, activity utilities +│ ├─ adapter/ # RecyclerView adapters and view holders +│ ├─ models/ # Firebase-backed data models +│ └─ *Activity.kt # UI entry points for each screen +├─ src/main/res/ # XML layouts, drawables, strings +└─ build.gradle # Android module configuration +``` -- [x] View All Users (in Future: Add user) - - [x] View all users - - [ ] Block users - - [x] View user profile +Gradle build logic lives at the root alongside the `settings.gradle` file. Unit and instrumentation tests mirror the source tree under `src/test` and `src/androidTest` respectively. + +--- + +## Architecture overview + +| Layer | Notes | +| --- | --- | +| **UI** | View-based activities and RecyclerView adapters render conversations, profiles, and onboarding flows. Compose interop utilities are present while a full migration is planned. | +| **Domain** | `ConversationUtil` coordinates message send/receive flows, atomic conversation creation, and MAC payload assembly. | +| **Security** | `security/` contains the symmetric/asymmetric primitives; `MessageIntegrity` unifies canonical payload generation. | +| **Data** | Firebase Database/Storage handle persistence, with `StorageController` wrapping encrypted DataStore for local preferences and session state. | +| **Background** | `MessagingService` subscribes to FCM notifications for new messages. | + +--- + +## Getting started + +1. **Clone the repo** + ```bash + git clone https://github.com/ARCOOON/UpChat.git + cd UpChat + ``` +2. **Supply Firebase config** + - Place `google-services.json` inside `app/`. + - Ensure Database and Storage rules match your test environment. +3. **Install dependencies** + ```bash + ./gradlew dependencies + ``` +4. **Build and run** + ```bash + ./gradlew assembleDebug + ``` +5. **Execute checks** + ```bash + ./gradlew lint test + ``` + +--- + +## Development tips + +- **Cryptography** – `ConversationKeyManager` publishes RSA public keys and distributes shared secrets. When extending message types, use `MessageIntegrity.canonicalPayload` to keep MAC coverage consistent. +- **Messaging pipeline** – `ConversationActivity` requests a shared key, feeds it to `ConversationUtil`, and observes Firebase for message streams. `MessageAdapter` binds decrypted content and verification state in view holders. +- **Secure persistence** – `StorageController` exposes suspending getters/setters backed by an encrypted DataStore; prefer these over direct SharedPreferences access. +- **UI consistency** – Activity transition helpers live in `ActivityTransitions.kt`; update both enter and exit animations when adding new flows. +- **Testing** – Run `./gradlew lint` for static checks and `./gradlew test` for unit coverage before submitting patches. + +--- + +## Contributing + +1. Fork the repository and branch from `main`. +2. Follow Kotlin coding standards and keep changes modular. +3. Document security-sensitive modifications clearly in commit messages and pull requests. +4. Provide screenshots or screen recordings when altering UI behavior. +5. Open a pull request once lint and tests pass locally. + +--- -- [ ] Preferences - - [ ] Customise message design - - [ ] Customise accent color - - [ ] Privacy - - [ ] Who can message you (everyone / no one) - - [ ] Blocked users +## Security -- [x] App - - [x] Signing Config - - [x] Auto Update (download and install) +- Per-conversation secrets are established via RSA-wrapped keys and expanded with HKDF. +- Message data is encrypted with AES-GCM; MACs cover ciphertext plus sender metadata. +- Local secrets are stored with encrypted Jetpack DataStore using Android Keystore-backed keys. +- Report vulnerabilities privately to **security@upchat.app** (PGP preferred). Avoid public issues for sensitive disclosures. --- -**Github** +## License -> - From now on are normal, weekly and daily builds possible. -> - Another feature are automatic github releases. -> - And building both apk and bundles. +UpChat is distributed under the [Apache 2.0 License](LICENSE). diff --git a/app/build.gradle b/app/build.gradle index b05778f..c964ec7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -51,12 +51,16 @@ android { debuggable true // signingConfig signingConfigs.debug } + } - applicationVariants.all { variant -> - variant.outputs.all { - var buildType = variant.name // release | debug - var version = variant.versionName - outputFileName = "upchat-${version}-${buildType}.apk" + androidComponents { + onVariants(selector().all()) { variant -> + variant.outputs.forEach { output -> + def buildType = variant.name + output.outputFileName.set(output.versionName.map { version -> + def versionSuffix = version != null ? "${version}-" : "" + "upchat-${versionSuffix}${buildType}.apk" + }) } } } @@ -70,6 +74,14 @@ android { kotlinOptions { jvmTarget = '17' } + + buildFeatures { + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion '1.5.15' + } } dependencies { @@ -93,10 +105,18 @@ dependencies { implementation 'androidx.core:core-ktx:1.13.1' implementation 'androidx.activity:activity-ktx:1.9.1' + implementation 'androidx.activity:activity-compose:1.9.1' + + implementation platform('androidx.compose:compose-bom:2024.09.01') + implementation 'androidx.compose.ui:ui' + implementation 'androidx.compose.ui:ui-tooling-preview' + implementation 'androidx.compose.material3:material3' + debugImplementation 'androidx.compose.ui:ui-tooling' implementation 'androidx.appcompat:appcompat:1.7.0' implementation 'androidx.lifecycle:lifecycle-process:2.7.0' implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06' + implementation 'androidx.datastore:datastore-preferences:1.1.1' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.constraintlayout:constraintlayout:2.2.1' @@ -112,6 +132,7 @@ dependencies { implementation 'com.google.zxing:core:3.5.3' implementation 'com.journeyapps:zxing-android-embedded:4.3.0' implementation 'com.google.code.gson:gson:2.11.0' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1' // Libsignal for coreLibraryDesugaring 'org.signal:libsignal-android:0.71.0' diff --git a/app/src/main/java/com/devusercode/upchat/ConversationActivity.kt b/app/src/main/java/com/devusercode/upchat/ConversationActivity.kt index 52309af..c8c9f27 100644 --- a/app/src/main/java/com/devusercode/upchat/ConversationActivity.kt +++ b/app/src/main/java/com/devusercode/upchat/ConversationActivity.kt @@ -28,11 +28,13 @@ import com.devusercode.upchat.adapter.MessageAdapter import com.devusercode.upchat.adapter.WrapLayoutManager import com.devusercode.upchat.models.Message import com.devusercode.upchat.models.User +import com.devusercode.upchat.security.ConversationKeyManager import com.devusercode.upchat.utils.ConversationUtil import com.devusercode.upchat.utils.ErrorCodes import com.devusercode.upchat.utils.StorageController import com.devusercode.upchat.utils.UserUtils import com.devusercode.upchat.utils.Util +import com.devusercode.upchat.utils.setComposeContent import com.firebase.ui.database.FirebaseRecyclerOptions import com.google.android.material.appbar.AppBarLayout import com.google.android.material.textfield.TextInputEditText @@ -69,8 +71,10 @@ class ConversationActivity : AppCompatActivity() { private var participant: User? = null private var user: User? = null + private lateinit var contentView: View private lateinit var storageController: StorageController private lateinit var conversationUtil: ConversationUtil + private var conversationSecret: String? = null private var file: Uri? = null private var filePickerLauncher: ActivityResultLauncher = @@ -86,7 +90,7 @@ class ConversationActivity : AppCompatActivity() { FirebaseApp.initializeApp(this) super.onCreate(savedInstanceState) - setContentView(R.layout.activity_conversation) + contentView = setComposeContent(R.layout.activity_conversation) storageController = StorageController.getInstance(this)!! @@ -147,7 +151,7 @@ class ConversationActivity : AppCompatActivity() { private fun initialize() { // val coordinator = findViewById(R.id._coordinator) // val appBar = findViewById(R.id.app_bar) - val toolbar = findViewById(R.id.toolbar) + val toolbar = contentView.findViewById(R.id.toolbar) setSupportActionBar(toolbar) @@ -163,10 +167,10 @@ class ConversationActivity : AppCompatActivity() { profileImage = toolbar.findViewById(R.id.profile_image) statusOnline = toolbar.findViewById(R.id.status_online) - attachButton = findViewById(R.id.attach_button) - messageInput = findViewById(R.id.message_input) - sendButton = findViewById(R.id.send_button) - recyclerview = findViewById(R.id.chat_recyclerview) + attachButton = contentView.findViewById(R.id.attach_button) + messageInput = contentView.findViewById(R.id.message_input) + sendButton = contentView.findViewById(R.id.send_button) + recyclerview = contentView.findViewById(R.id.chat_recyclerview) attachButton.setOnClickListener { filePickerLauncher.launch("image/*") } @@ -192,13 +196,30 @@ class ConversationActivity : AppCompatActivity() { sendButton.setOnClickListener { if (!chatExists) { - currentConversationId = ConversationUtil.newConversation(user!!, participant!!) - conversationUtil = - ConversationUtil(this, currentConversationId, user!!, participant!!) - - Log.d(TAG, "Conversation ($currentConversationId) created") + chatExists = true + + ConversationUtil.newConversation(user!!, participant!!) + .addOnSuccessListener { conversationId -> + currentConversationId = conversationId + Log.d(TAG, "Conversation ($currentConversationId) created") + prepareConversation(currentConversationId) + Util.showMessage(this, "Setting up secure channel...") + } + .addOnFailureListener { error -> + chatExists = false + Log.e(TAG, "Error creating conversation", error) + Util.showMessage( + this, + error.message ?: getString(R.string.conversation__error_generic) + ) + } + return@setOnClickListener + } - loadConversation(currentConversationId) + if (!::conversationUtil.isInitialized) { + prepareConversation(currentConversationId) + Util.showMessage(this, "Secure channel is initializing. Please wait...") + return@setOnClickListener } val message: String = messageInput.text.toString() @@ -216,7 +237,14 @@ class ConversationActivity : AppCompatActivity() { if (message.isNotEmpty()) { conversationUtil.sendMessage(message) - messageInput.setText("") + .addOnSuccessListener { messageInput.setText("") } + .addOnFailureListener { error -> + Log.e(TAG, "Failed to send message", error) + Util.showMessage( + this, + error.message ?: getString(R.string.conversation__error_generic) + ) + } } } @@ -255,14 +283,47 @@ class ConversationActivity : AppCompatActivity() { chatExists = ConversationUtil.conversationExists(user!!, participant!!) if (chatExists) { - currentConversationId = - ConversationUtil.getConversationId(participant!!, user!!.conversations).toString() - conversationUtil = ConversationUtil(this, currentConversationId, user!!, participant!!) - - loadConversation(currentConversationId) + val existingConversationId = + ConversationUtil.getConversationId(participant!!, user!!.conversations) + + if (!existingConversationId.isNullOrEmpty()) { + currentConversationId = existingConversationId + prepareConversation(existingConversationId) + } else { + Log.e(TAG, "Conversation ID not found despite chat existence") + } } } + private fun prepareConversation(conversationId: String) { + val previousSecret = conversationSecret + conversationSecret = null + + ConversationKeyManager.ensureConversationSecret( + this, + conversationId, + user!!, + participant!!, + onSuccess = success@{ secret -> + if (previousSecret == secret && ::conversationUtil.isInitialized) { + conversationSecret = secret + adapter?.setConversationSecret(secret) + return@success + } + + conversationSecret = secret + conversationUtil = ConversationUtil(this, conversationId, user!!, participant!!, secret) + loadConversation(conversationId) + }, + onError = { error -> + Log.e(TAG, "Failed to establish secure channel", error) + conversationSecret = previousSecret + previousSecret?.let { adapter?.setConversationSecret(it) } + Util.showMessage(this, "Unable to secure the conversation. Please try again.") + } + ) + } + private fun loadConversation(cid: String) { val messages: Query = conversations.child(cid).child(Key.Conversation.MESSAGES) @@ -270,17 +331,20 @@ class ConversationActivity : AppCompatActivity() { FirebaseRecyclerOptions.Builder().setQuery(messages, Message::class.java) .build() - adapter = MessageAdapter(applicationContext, options) + adapter?.stopListening() - adapter?.setConversationId(cid) - adapter?.setParticipant(participant) + adapter = MessageAdapter(applicationContext, options).apply { + setConversationId(cid) + setParticipant(participant) + conversationSecret?.let { setConversationSecret(it) } - adapter?.registerAdapterDataObserver(object : AdapterDataObserver() { - override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { - super.onItemRangeInserted(positionStart, itemCount) - recyclerview.smoothScrollToPosition(positionStart) - } - }) + registerAdapterDataObserver(object : AdapterDataObserver() { + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + super.onItemRangeInserted(positionStart, itemCount) + recyclerview.smoothScrollToPosition(positionStart) + } + }) + } recyclerview.adapter = adapter adapter?.startListening() diff --git a/app/src/main/java/com/devusercode/upchat/HomeActivity.kt b/app/src/main/java/com/devusercode/upchat/HomeActivity.kt index 795a0bf..76b784c 100644 --- a/app/src/main/java/com/devusercode/upchat/HomeActivity.kt +++ b/app/src/main/java/com/devusercode/upchat/HomeActivity.kt @@ -1,11 +1,12 @@ package com.devusercode.upchat import android.content.Intent -import android.os.Bundle import android.os.Build +import android.os.Bundle import android.util.Log import android.view.Menu import android.view.MenuItem +import android.view.View import android.widget.TextView import android.widget.Toast import androidx.annotation.RequiresApi @@ -20,6 +21,7 @@ import com.devusercode.upchat.utils.ConversationUtil import com.devusercode.upchat.utils.ErrorCodes import com.devusercode.upchat.utils.StorageController import com.devusercode.upchat.utils.UserUtils +import com.devusercode.upchat.utils.setComposeContent import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.firebase.auth.FirebaseAuth import com.google.firebase.database.DataSnapshot @@ -36,6 +38,7 @@ class HomeActivity : AppCompatActivity() { private var openConversations: MutableList? = null private val userListeners: MutableMap = HashMap() + private lateinit var contentView: View private lateinit var recyclerView: RecyclerView private lateinit var storageController: StorageController @@ -64,7 +67,7 @@ class HomeActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_home) + contentView = setComposeContent(R.layout.activity_home) storageController = StorageController.getInstance(this)!! @@ -74,18 +77,18 @@ class HomeActivity : AppCompatActivity() { } private fun setupToolbar() { - val toolbar = findViewById(R.id.toolbar) + val toolbar = contentView.findViewById(R.id.toolbar) setSupportActionBar(toolbar) toolbar.findViewById(R.id.toolbar_profile_name).text = getString(R.string.app_name) } private fun setupViews() { - findViewById(R.id.new_conversation_button).setOnClickListener { + contentView.findViewById(R.id.new_conversation_button).setOnClickListener { startActivity(Intent(this, ListUsersActivity::class.java)) } - recyclerView = findViewById(R.id.recyclerview) + recyclerView = contentView.findViewById(R.id.recyclerview) recyclerView.layoutManager = LinearLayoutManager(this) } diff --git a/app/src/main/java/com/devusercode/upchat/ListUsersActivity.kt b/app/src/main/java/com/devusercode/upchat/ListUsersActivity.kt index 431eb4e..f697e5f 100644 --- a/app/src/main/java/com/devusercode/upchat/ListUsersActivity.kt +++ b/app/src/main/java/com/devusercode/upchat/ListUsersActivity.kt @@ -24,7 +24,11 @@ import com.firebase.ui.database.FirebaseRecyclerOptions import com.google.firebase.FirebaseApp import com.google.firebase.auth.FirebaseAuth import com.google.firebase.database.FirebaseDatabase -import com.google.zxing.integration.android.IntentIntegrator +import com.devusercode.upchat.utils.applyActivityCloseAnimation +import com.devusercode.upchat.utils.applyActivityOpenAnimation +import com.devusercode.upchat.utils.setComposeContent +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions @RequiresApi(Build.VERSION_CODES.O) class ListUsersActivity : AppCompatActivity() { @@ -34,6 +38,7 @@ class ListUsersActivity : AppCompatActivity() { private val firebaseDatabase = FirebaseDatabase.getInstance() private val users = firebaseDatabase.getReference("users") + private lateinit var contentView: View private lateinit var scanQrcodeButton: Button private lateinit var recyclerview1: RecyclerView private lateinit var swipeRefreshLayout: SwipeRefreshLayout @@ -51,7 +56,7 @@ class ListUsersActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { FirebaseApp.initializeApp(this) super.onCreate(savedInstanceState) - setContentView(R.layout.activity_view_all_users) + contentView = setComposeContent(R.layout.activity_view_all_users) initialize() initializeLogic() @@ -59,44 +64,29 @@ class ListUsersActivity : AppCompatActivity() { override fun startActivity(intent: Intent) { super.startActivity(intent) - overridePendingTransition(R.anim.right_in, R.anim.left_out) + applyActivityOpenAnimation(R.anim.right_in, R.anim.left_out) } override fun finish() { super.finish() - overridePendingTransition(R.anim.left_in, R.anim.right_out) - } - - @RequiresApi(Build.VERSION_CODES.O) - @Deprecated("Deprecated") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - val result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data) - - if (result != null && result.contents != null) { - val qrcodeData = result.contents - val intent = Intent(this, ConversationActivity::class.java) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - intent.putExtra("uid", qrcodeData) - startActivity(intent) - } + applyActivityCloseAnimation(R.anim.left_in, R.anim.right_out) } @SuppressLint("NotifyDataSetChanged") private fun initialize() { // val appBar = findViewById(R.id.app_bar) // val coordinator = findViewById(R.id.coordinator) - val toolbar = findViewById(R.id.toolbar) - val toolbarBackButton = findViewById