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 3ff43d2..53dc4b6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -53,13 +53,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" + }) } } */ @@ -95,6 +98,14 @@ androidComponents { output.outputFileName.set("upchat-${version}-${buildType}.apk") } } + + buildFeatures { + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion '1.5.15' + } } dependencies { @@ -116,14 +127,21 @@ dependencies { implementation 'com.google.android.material:material:1.13.0' - implementation 'androidx.core:core-ktx:1.17.0' - implementation 'androidx.activity:activity-ktx:1.12.4' - - implementation 'androidx.appcompat:appcompat:1.7.1' - implementation 'androidx.lifecycle:lifecycle-process:2.10.0' - implementation 'androidx.security:security-crypto-ktx:1.1.0' - implementation 'androidx.datastore:datastore-preferences:1.2.0' - implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.2.0' + 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' implementation 'com.google.android.gms:play-services-vision:20.1.3' @@ -137,8 +155,8 @@ dependencies { // For generating and scanning QR-Code implementation 'com.google.zxing:core:3.5.4' implementation 'com.journeyapps:zxing-android-embedded:4.3.0' - implementation 'com.google.code.gson:gson:2.13.2' - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2' + 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 ae46913..7ec98d8 100644 --- a/app/src/main/java/com/devusercode/upchat/ConversationActivity.kt +++ b/app/src/main/java/com/devusercode/upchat/ConversationActivity.kt @@ -35,6 +35,7 @@ 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 @@ -70,6 +71,7 @@ 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 @@ -88,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)!! @@ -148,7 +150,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) @@ -164,10 +166,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/*") } @@ -196,19 +198,19 @@ class ConversationActivity : AppCompatActivity() { if (!chatExists) { chatExists = true - ConversationUtil - .newConversation(user!!, participant!!) + 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 -> + } + .addOnFailureListener { error -> chatExists = false Log.e(TAG, "Error creating conversation", error) Util.showMessage( this, - error.message ?: getString(R.string.conversation__error_generic), + error.message ?: getString(R.string.conversation__error_generic) ) } return@setOnClickListener @@ -234,14 +236,13 @@ class ConversationActivity : AppCompatActivity() { } if (message.isNotEmpty()) { - conversationUtil - .sendMessage(message) + conversationUtil.sendMessage(message) .addOnSuccessListener { messageInput.setText("") } .addOnFailureListener { error -> Log.e(TAG, "Failed to send message", error) Util.showMessage( this, - error.message ?: getString(R.string.conversation__error_generic), + error.message ?: getString(R.string.conversation__error_generic) ) } } @@ -322,7 +323,7 @@ class ConversationActivity : AppCompatActivity() { conversationSecret = previousSecret previousSecret?.let { adapter?.setConversationSecret(it) } Util.showMessage(this, "Unable to secure the conversation. Please try again.") - }, + } ) } @@ -337,24 +338,18 @@ class ConversationActivity : AppCompatActivity() { adapter?.stopListening() - adapter = - MessageAdapter(applicationContext, options).apply { - setConversationId(cid) - setParticipant(participant) - conversationSecret?.let { setConversationSecret(it) } - - registerAdapterDataObserver( - object : AdapterDataObserver() { - override fun onItemRangeInserted( - positionStart: Int, - itemCount: Int, - ) { - super.onItemRangeInserted(positionStart, itemCount) - recyclerview.smoothScrollToPosition(positionStart) - } - }, - ) - } + adapter = MessageAdapter(applicationContext, options).apply { + setConversationId(cid) + setParticipant(participant) + conversationSecret?.let { setConversationSecret(it) } + + 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 540cd6e..14335a3 100644 --- a/app/src/main/java/com/devusercode/upchat/HomeActivity.kt +++ b/app/src/main/java/com/devusercode/upchat/HomeActivity.kt @@ -6,6 +6,7 @@ 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 95b0c22..7826f0b 100644 --- a/app/src/main/java/com/devusercode/upchat/ListUsersActivity.kt +++ b/app/src/main/java/com/devusercode/upchat/ListUsersActivity.kt @@ -26,6 +26,9 @@ 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.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 @@ -37,6 +40,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 @@ -54,7 +58,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() @@ -74,17 +78,17 @@ class ListUsersActivity : AppCompatActivity() { 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