Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 92 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spelling error: 'canonicalPayload' should be 'canonicalize' in the README development tips section to match the actual method name used in the code.

Suggested change
- **Cryptography**`ConversationKeyManager` publishes RSA public keys and distributes shared secrets. When extending message types, use `MessageIntegrity.canonicalPayload` to keep MAC coverage consistent.
- **Cryptography**`ConversationKeyManager` publishes RSA public keys and distributes shared secrets. When extending message types, use `MessageIntegrity.canonicalize` to keep MAC coverage consistent.

Copilot uses AI. Check for mistakes.
- **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.
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README states to report vulnerabilities to security@upchat.app, but this email domain may not exist or be monitored. Verify that this security contact is valid and actively monitored before including it in the documentation. Consider using GitHub's security advisory feature as an alternative reporting mechanism.

Suggested change
- Report vulnerabilities privately to **security@upchat.app** (PGP preferred). Avoid public issues for sensitive disclosures.
- Report vulnerabilities privately via GitHub [Security Advisories](https://github.com/ARCOOON/UpChat/security/advisories/new). Avoid public issues for sensitive disclosures.

Copilot uses AI. Check for mistakes.

---

**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).
Comment on lines 1 to +105
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description states this is a "documentation-only change" that rewrites the README, but this PR includes substantial code changes throughout the application including security implementations, storage refactoring, activity changes, and new features. The description should accurately reflect the scope of changes to help reviewers and maintainers understand the full impact of this PR.

Copilot uses AI. Check for mistakes.
50 changes: 34 additions & 16 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"
})
}
}
*/
Expand Down Expand Up @@ -95,6 +98,14 @@ androidComponents {
output.outputFileName.set("upchat-${version}-${buildType}.apk")
}
}

buildFeatures {
compose true
}

composeOptions {
kotlinCompilerExtensionVersion '1.5.15'
}
}

dependencies {
Expand All @@ -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'
Expand All @@ -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'
Expand Down
59 changes: 27 additions & 32 deletions app/src/main/java/com/devusercode/upchat/ConversationActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The conversationSecret is stored as a mutable var at the activity level but can be accessed and modified from multiple async callbacks (prepareConversation success/error handlers, send button click). This could lead to race conditions where the secret is cleared while being used, or where stale secrets are used after new ones are established. Consider using StateFlow or synchronized access patterns.

Suggested change
private var conversationSecret: String? = null
@Volatile private var conversationSecret: String? = null

Copilot uses AI. Check for mistakes.
Expand All @@ -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)!!

Expand Down Expand Up @@ -148,7 +150,7 @@ class ConversationActivity : AppCompatActivity() {
private fun initialize() {
// val coordinator = findViewById<CoordinatorLayout>(R.id._coordinator)
// val appBar = findViewById<AppBarLayout>(R.id.app_bar)
val toolbar = findViewById<Toolbar>(R.id.toolbar)
val toolbar = contentView.findViewById<Toolbar>(R.id.toolbar)

setSupportActionBar(toolbar)

Expand All @@ -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/*") }

Expand Down Expand Up @@ -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
Expand All @@ -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)
)
}
}
Expand Down Expand Up @@ -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.")
},
}
)
}

Expand All @@ -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()
Expand Down
Loading
Loading