diff --git a/.gitignore b/.gitignore
index b1dff0d..49e73d5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -42,4 +42,6 @@ bin/
.vscode/
### Mac OS ###
-.DS_Store
\ No newline at end of file
+.DS_Store
+
+WEBSOCKET_API_SPEC.md
\ No newline at end of file
diff --git a/.idea/caches/deviceStreaming.xml b/.idea/caches/deviceStreaming.xml
new file mode 100644
index 0000000..8d6eef7
--- /dev/null
+++ b/.idea/caches/deviceStreaming.xml
@@ -0,0 +1,1538 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
new file mode 100644
index 0000000..7643783
--- /dev/null
+++ b/.idea/codeStyles/Project.xml
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ xmlns:android
+
+ ^$
+
+
+
+
+
+
+
+
+ xmlns:.*
+
+ ^$
+
+
+ BY_NAME
+
+
+
+
+
+
+ .*:id
+
+ http://schemas.android.com/apk/res/android
+
+
+
+
+
+
+
+
+ .*:name
+
+ http://schemas.android.com/apk/res/android
+
+
+
+
+
+
+
+
+ name
+
+ ^$
+
+
+
+
+
+
+
+
+ style
+
+ ^$
+
+
+
+
+
+
+
+
+ .*
+
+ ^$
+
+
+ BY_NAME
+
+
+
+
+
+
+ .*
+
+ http://schemas.android.com/apk/res/android
+
+
+ ANDROID_ATTRIBUTE_ORDER
+
+
+
+
+
+
+ .*
+
+ .*
+
+
+ BY_NAME
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
new file mode 100644
index 0000000..79ee123
--- /dev/null
+++ b/.idea/codeStyles/codeStyleConfig.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml
new file mode 100644
index 0000000..91f9558
--- /dev/null
+++ b/.idea/deviceManager.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 2a65317..2ff74fd 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -4,13 +4,14 @@
+
-
+
diff --git a/.idea/markdown.xml b/.idea/markdown.xml
new file mode 100644
index 0000000..c61ea33
--- /dev/null
+++ b/.idea/markdown.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/migrations.xml b/.idea/migrations.xml
new file mode 100644
index 0000000..f8051a6
--- /dev/null
+++ b/.idea/migrations.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 7a1bab3..d15a481 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,6 +1,7 @@
+
-
+
\ No newline at end of file
diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml
new file mode 100644
index 0000000..16660f1
--- /dev/null
+++ b/.idea/runConfigurations.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..35c2a13
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,100 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("com.google.devtools.ksp")
+ id("com.google.dagger.hilt.android")
+ id("org.jetbrains.kotlin.plugin.serialization")
+ kotlin("plugin.compose")
+}
+
+android {
+ namespace = "com.postcard"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.postcard"
+ minSdk = 26
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+ buildFeatures {
+ compose = true
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+ implementation("androidx.core:core-ktx:1.13.1")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3")
+ implementation("androidx.activity:activity-compose:1.9.0")
+
+ // Compose
+ implementation(platform("androidx.compose:compose-bom:2024.06.00"))
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.ui:ui-graphics")
+ implementation("androidx.compose.ui:ui-tooling-preview")
+ implementation("androidx.compose.material3:material3")
+ implementation("androidx.navigation:navigation-compose:2.7.7")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.3")
+ implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.3")
+
+ // Room
+ val roomVersion = "2.6.1"
+ implementation("androidx.room:room-runtime:$roomVersion")
+ implementation("androidx.room:room-ktx:$roomVersion")
+ ksp("androidx.room:room-compiler:$roomVersion")
+
+ // Hilt
+ implementation("com.google.dagger:hilt-android:2.51.1")
+ ksp("com.google.dagger:hilt-android-compiler:2.51.1")
+ implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
+
+ // Ktor (Using OkHttp engine as Ktor is idiomatic for Kotlin Multiplatform, but works great on Android too)
+ val ktorVersion = "2.3.12"
+ implementation("io.ktor:ktor-client-core:$ktorVersion")
+ implementation("io.ktor:ktor-client-okhttp:$ktorVersion")
+ implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
+ implementation("io.ktor:ktor-client-websockets:$ktorVersion")
+ implementation("io.ktor:ktor-client-auth:$ktorVersion")
+ implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
+
+ // Serialization
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1")
+
+ // Coil
+ implementation("io.coil-kt:coil-compose:2.6.0")
+
+ // Security (for EncryptedSharedPreferences)
+ implementation("androidx.security:security-crypto:1.1.0-alpha06")
+
+ // Media (ExoPlayer)
+ implementation("androidx.media3:media3-exoplayer:1.3.1")
+ implementation("androidx.media3:media3-ui:1.3.1")
+
+ // Coroutines
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..3502501
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/postcard/MainActivity.kt b/app/src/main/java/com/postcard/MainActivity.kt
new file mode 100644
index 0000000..97d9e51
--- /dev/null
+++ b/app/src/main/java/com/postcard/MainActivity.kt
@@ -0,0 +1,89 @@
+package com.postcard
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import com.postcard.ui.auth.AuthViewModel
+import com.postcard.ui.auth.LoginScreen
+import com.postcard.ui.auth.RegisterScreen
+import com.postcard.ui.conversations.ConversationListScreen
+import com.postcard.ui.theme.PostcardTheme
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ PostcardTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ PostcardApp()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun PostcardApp() {
+ val navController = rememberNavController()
+ val authViewModel: AuthViewModel = hiltViewModel()
+
+ val startDestination = if (authViewModel.isLoggedIn()) "conversations" else "login"
+
+ NavHost(navController = navController, startDestination = startDestination) {
+ composable("login") {
+ LoginScreen(
+ onNavigateToRegister = {
+ navController.navigate("register") {
+ popUpTo("login") { inclusive = true }
+ }
+ },
+ onLoginSuccess = {
+ navController.navigate("conversations") {
+ popUpTo("login") { inclusive = true }
+ }
+ }
+ )
+ }
+ composable("register") {
+ RegisterScreen(
+ onNavigateToLogin = {
+ navController.navigate("login") {
+ popUpTo("register") { inclusive = true }
+ }
+ },
+ onRegisterSuccess = {
+ navController.navigate("conversations") {
+ popUpTo("register") { inclusive = true }
+ }
+ }
+ )
+ }
+ composable("conversations") {
+ ConversationListScreen(
+ onLogout = {
+ authViewModel.resetState()
+ authViewModel.logout()
+ navController.navigate("login") {
+ popUpTo("conversations") { inclusive = true }
+ }
+ }
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/postcard/PostcardApplication.kt b/app/src/main/java/com/postcard/PostcardApplication.kt
new file mode 100644
index 0000000..daf1fe3
--- /dev/null
+++ b/app/src/main/java/com/postcard/PostcardApplication.kt
@@ -0,0 +1,7 @@
+package com.postcard
+
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+
+@HiltAndroidApp
+class PostcardApplication : Application()
diff --git a/app/src/main/java/com/postcard/data/auth/AuthTokenManager.kt b/app/src/main/java/com/postcard/data/auth/AuthTokenManager.kt
new file mode 100644
index 0000000..b26b3bc
--- /dev/null
+++ b/app/src/main/java/com/postcard/data/auth/AuthTokenManager.kt
@@ -0,0 +1,41 @@
+package com.postcard.data.auth
+
+import android.content.Context
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class AuthTokenManager @Inject constructor(
+ @ApplicationContext context: Context
+) {
+ private val masterKey = MasterKey.Builder(context)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build()
+
+ private val sharedPreferences = EncryptedSharedPreferences.create(
+ context,
+ "auth_prefs",
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ )
+
+ fun saveToken(token: String) {
+ sharedPreferences.edit().putString(KEY_TOKEN, token).apply()
+ }
+
+ fun getToken(): String? {
+ return sharedPreferences.getString(KEY_TOKEN, null)
+ }
+
+ fun clearToken() {
+ sharedPreferences.edit().remove(KEY_TOKEN).apply()
+ }
+
+ companion object {
+ private const val KEY_TOKEN = "jwt_token"
+ }
+}
diff --git a/app/src/main/java/com/postcard/data/local/ConversationDao.kt b/app/src/main/java/com/postcard/data/local/ConversationDao.kt
new file mode 100644
index 0000000..36ea4e3
--- /dev/null
+++ b/app/src/main/java/com/postcard/data/local/ConversationDao.kt
@@ -0,0 +1,28 @@
+package com.postcard.data.local
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface ConversationDao {
+ @Query("SELECT * FROM conversations ORDER BY lastMessageTimestamp DESC")
+ fun getAllConversations(): Flow>
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertConversations(conversations: List)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertConversation(conversation: ConversationEntity)
+
+ @Query("SELECT * FROM conversations WHERE id = :id")
+ suspend fun getConversationById(id: String): ConversationEntity?
+
+ @Query("UPDATE conversations SET lastSyncedSequence = :sequence WHERE id = :id")
+ suspend fun updateLastSyncedSequence(id: String, sequence: Long)
+
+ @Query("DELETE FROM conversations")
+ suspend fun clearConversations()
+}
diff --git a/app/src/main/java/com/postcard/data/local/ConversationEntity.kt b/app/src/main/java/com/postcard/data/local/ConversationEntity.kt
new file mode 100644
index 0000000..85d2bd7
--- /dev/null
+++ b/app/src/main/java/com/postcard/data/local/ConversationEntity.kt
@@ -0,0 +1,15 @@
+package com.postcard.data.local
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "conversations")
+data class ConversationEntity(
+ @PrimaryKey val id: String,
+ val participantDisplayName: String,
+ val participantAvatarUrl: String?,
+ val lastMessagePreview: String?,
+ val lastMessageTimestamp: Long?,
+ val unreadCount: Int,
+ val lastSyncedSequence: Long
+)
diff --git a/app/src/main/java/com/postcard/data/local/MessageDao.kt b/app/src/main/java/com/postcard/data/local/MessageDao.kt
new file mode 100644
index 0000000..3e7f64a
--- /dev/null
+++ b/app/src/main/java/com/postcard/data/local/MessageDao.kt
@@ -0,0 +1,25 @@
+package com.postcard.data.local
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface MessageDao {
+ @Query("SELECT * FROM messages WHERE conversationId = :conversationId ORDER BY sequence DESC, createdAt DESC")
+ fun getMessagesForConversation(conversationId: String): Flow>
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertMessage(message: MessageEntity)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertMessages(messages: List)
+
+ @Query("UPDATE messages SET status = :status WHERE clientMsgId = :clientMsgId")
+ suspend fun updateMessageStatus(clientMsgId: String, status: String)
+
+ @Query("DELETE FROM messages")
+ suspend fun clearMessages()
+}
diff --git a/app/src/main/java/com/postcard/data/local/MessageEntity.kt b/app/src/main/java/com/postcard/data/local/MessageEntity.kt
new file mode 100644
index 0000000..459577b
--- /dev/null
+++ b/app/src/main/java/com/postcard/data/local/MessageEntity.kt
@@ -0,0 +1,18 @@
+package com.postcard.data.local
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "messages")
+data class MessageEntity(
+ @PrimaryKey val id: String,
+ val conversationId: String,
+ val senderId: String,
+ val sequence: Long,
+ val type: String,
+ val body: String?,
+ val mediaMetadata: String?, // JSON string
+ val clientMsgId: String?,
+ val status: String, // e.g. "sending", "sent", "delivered", "read"
+ val createdAt: Long
+)
diff --git a/app/src/main/java/com/postcard/data/local/PostcardDatabase.kt b/app/src/main/java/com/postcard/data/local/PostcardDatabase.kt
new file mode 100644
index 0000000..b438e97
--- /dev/null
+++ b/app/src/main/java/com/postcard/data/local/PostcardDatabase.kt
@@ -0,0 +1,15 @@
+package com.postcard.data.local
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+
+@Database(
+ entities = [UserEntity::class, ConversationEntity::class, MessageEntity::class],
+ version = 1,
+ exportSchema = false
+)
+abstract class PostcardDatabase : RoomDatabase() {
+ abstract fun userDao(): UserDao
+ abstract fun conversationDao(): ConversationDao
+ abstract fun messageDao(): MessageDao
+}
diff --git a/app/src/main/java/com/postcard/data/local/UserDao.kt b/app/src/main/java/com/postcard/data/local/UserDao.kt
new file mode 100644
index 0000000..e12bcb0
--- /dev/null
+++ b/app/src/main/java/com/postcard/data/local/UserDao.kt
@@ -0,0 +1,22 @@
+package com.postcard.data.local
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface UserDao {
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertUser(user: UserEntity)
+
+ @Query("SELECT * FROM users WHERE id = :userId")
+ suspend fun getUserById(userId: String): UserEntity?
+
+ @Query("SELECT * FROM users LIMIT 1")
+ suspend fun getCurrentUser(): UserEntity?
+
+ @Query("DELETE FROM users")
+ suspend fun clearUsers()
+}
diff --git a/app/src/main/java/com/postcard/data/local/UserEntity.kt b/app/src/main/java/com/postcard/data/local/UserEntity.kt
new file mode 100644
index 0000000..fec1472
--- /dev/null
+++ b/app/src/main/java/com/postcard/data/local/UserEntity.kt
@@ -0,0 +1,12 @@
+package com.postcard.data.local
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "users")
+data class UserEntity(
+ @PrimaryKey val id: String,
+ val username: String,
+ val displayName: String,
+ val avatarUrl: String?
+)
diff --git a/app/src/main/java/com/postcard/data/repository/AuthRepository.kt b/app/src/main/java/com/postcard/data/repository/AuthRepository.kt
new file mode 100644
index 0000000..67c2783
--- /dev/null
+++ b/app/src/main/java/com/postcard/data/repository/AuthRepository.kt
@@ -0,0 +1,77 @@
+package com.postcard.data.repository
+
+import com.postcard.data.auth.AuthTokenManager
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.http.ContentType
+import io.ktor.http.contentType
+import kotlinx.serialization.Serializable
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class AuthRepository @Inject constructor(
+ private val httpClient: HttpClient,
+ private val authTokenManager: AuthTokenManager
+) {
+
+ suspend fun login(request: LoginRequest): Result {
+ return try {
+ val response: AuthResponse = httpClient.post("/api/auth/login") {
+ contentType(ContentType.Application.Json)
+ setBody(request)
+ }.body()
+
+ authTokenManager.saveToken(response.token)
+ Result.success(response)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun register(request: RegisterRequest): Result {
+ return try {
+ val response: AuthResponse = httpClient.post("/api/auth/register") {
+ contentType(ContentType.Application.Json)
+ setBody(request)
+ }.body()
+
+ authTokenManager.saveToken(response.token)
+ Result.success(response)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ fun logout() {
+ authTokenManager.clearToken()
+ }
+
+ fun isLoggedIn(): Boolean {
+ return authTokenManager.getToken() != null
+ }
+}
+
+@Serializable
+data class AuthResponse(
+ val token: String,
+ val userId: String,
+ val username: String,
+ val displayName: String,
+ val avatarUrl: String? = null
+)
+
+@Serializable
+data class LoginRequest(
+ val username: String,
+ val passwordHash: String // Usually frontend sends hash, but we'll send password based on spec, assuming standard password field for simplicity
+)
+
+@Serializable
+data class RegisterRequest(
+ val username: String,
+ val displayName: String,
+ val passwordHash: String
+)
diff --git a/app/src/main/java/com/postcard/di/DatabaseModule.kt b/app/src/main/java/com/postcard/di/DatabaseModule.kt
new file mode 100644
index 0000000..decc44c
--- /dev/null
+++ b/app/src/main/java/com/postcard/di/DatabaseModule.kt
@@ -0,0 +1,44 @@
+package com.postcard.di
+
+import android.content.Context
+import androidx.room.Room
+import com.postcard.data.local.ConversationDao
+import com.postcard.data.local.MessageDao
+import com.postcard.data.local.PostcardDatabase
+import com.postcard.data.local.UserDao
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object DatabaseModule {
+
+ @Provides
+ @Singleton
+ fun providePostcardDatabase(@ApplicationContext context: Context): PostcardDatabase {
+ return Room.databaseBuilder(
+ context,
+ PostcardDatabase::class.java,
+ "postcard_db"
+ ).fallbackToDestructiveMigration().build()
+ }
+
+ @Provides
+ fun provideUserDao(database: PostcardDatabase): UserDao {
+ return database.userDao()
+ }
+
+ @Provides
+ fun provideConversationDao(database: PostcardDatabase): ConversationDao {
+ return database.conversationDao()
+ }
+
+ @Provides
+ fun provideMessageDao(database: PostcardDatabase): MessageDao {
+ return database.messageDao()
+ }
+}
diff --git a/app/src/main/java/com/postcard/di/NetworkModule.kt b/app/src/main/java/com/postcard/di/NetworkModule.kt
new file mode 100644
index 0000000..ead6a54
--- /dev/null
+++ b/app/src/main/java/com/postcard/di/NetworkModule.kt
@@ -0,0 +1,61 @@
+package com.postcard.di
+
+import com.postcard.data.auth.AuthTokenManager
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.okhttp.OkHttp
+import io.ktor.client.plugins.auth.Auth
+import io.ktor.client.plugins.auth.providers.BearerTokens
+import io.ktor.client.plugins.auth.providers.bearer
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.client.plugins.defaultRequest
+import io.ktor.client.plugins.websocket.WebSockets
+import io.ktor.client.request.header
+import io.ktor.http.ContentType
+import io.ktor.http.HttpHeaders
+import io.ktor.serialization.kotlinx.json.json
+import kotlinx.serialization.json.Json
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object NetworkModule {
+
+ private const val BASE_URL = "http://10.0.2.2:8080" // Default Android emulator loopback to host
+
+ @Provides
+ @Singleton
+ fun provideHttpClient(authTokenManager: AuthTokenManager): HttpClient {
+ return HttpClient(OkHttp) {
+ install(ContentNegotiation) {
+ json(Json {
+ prettyPrint = true
+ isLenient = true
+ ignoreUnknownKeys = true
+ })
+ }
+ install(WebSockets) {
+ pingInterval = 20_000
+ }
+ install(Auth) {
+ bearer {
+ loadTokens {
+ authTokenManager.getToken()?.let { BearerTokens(it, "") }
+ }
+ refreshTokens {
+ // For this PoC, we just clear the token and return null to trigger a 401 error flow
+ authTokenManager.clearToken()
+ null
+ }
+ }
+ }
+ defaultRequest {
+ url(BASE_URL)
+ header(HttpHeaders.ContentType, ContentType.Application.Json)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/postcard/ui/auth/AuthViewModel.kt b/app/src/main/java/com/postcard/ui/auth/AuthViewModel.kt
new file mode 100644
index 0000000..f3f151e
--- /dev/null
+++ b/app/src/main/java/com/postcard/ui/auth/AuthViewModel.kt
@@ -0,0 +1,64 @@
+package com.postcard.ui.auth
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.postcard.data.repository.AuthRepository
+import com.postcard.data.repository.LoginRequest
+import com.postcard.data.repository.RegisterRequest
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class AuthViewModel @Inject constructor(
+ private val authRepository: AuthRepository
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(AuthState.Idle)
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ fun login(username: String, passwordHash: String) {
+ viewModelScope.launch {
+ _uiState.value = AuthState.Loading
+ val result = authRepository.login(LoginRequest(username, passwordHash))
+ if (result.isSuccess) {
+ _uiState.value = AuthState.Success
+ } else {
+ _uiState.value = AuthState.Error(result.exceptionOrNull()?.message ?: "Login failed")
+ }
+ }
+ }
+
+ fun register(username: String, displayName: String, passwordHash: String) {
+ viewModelScope.launch {
+ _uiState.value = AuthState.Loading
+ val result = authRepository.register(RegisterRequest(username, displayName, passwordHash))
+ if (result.isSuccess) {
+ _uiState.value = AuthState.Success
+ } else {
+ _uiState.value = AuthState.Error(result.exceptionOrNull()?.message ?: "Registration failed")
+ }
+ }
+ }
+
+ fun isLoggedIn(): Boolean = authRepository.isLoggedIn()
+
+ fun logout() {
+ authRepository.logout()
+ _uiState.value = AuthState.Idle
+ }
+
+ fun resetState() {
+ _uiState.value = AuthState.Idle
+ }
+}
+
+sealed class AuthState {
+ object Idle : AuthState()
+ object Loading : AuthState()
+ object Success : AuthState()
+ data class Error(val message: String) : AuthState()
+}
diff --git a/app/src/main/java/com/postcard/ui/auth/LoginScreen.kt b/app/src/main/java/com/postcard/ui/auth/LoginScreen.kt
new file mode 100644
index 0000000..13ea223
--- /dev/null
+++ b/app/src/main/java/com/postcard/ui/auth/LoginScreen.kt
@@ -0,0 +1,83 @@
+package com.postcard.ui.auth
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+
+@Composable
+fun LoginScreen(
+ onNavigateToRegister: () -> Unit,
+ onLoginSuccess: () -> Unit,
+ viewModel: AuthViewModel = hiltViewModel()
+) {
+ var username by remember { mutableStateOf("") }
+ var password by remember { mutableStateOf("") }
+ val uiState by viewModel.uiState.collectAsState()
+
+ LaunchedEffect(uiState) {
+ if (uiState is AuthState.Success) {
+ onLoginSuccess()
+ }
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text("Login", style = MaterialTheme.typography.headlineMedium)
+ Spacer(modifier = Modifier.height(16.dp))
+
+ OutlinedTextField(
+ value = username,
+ onValueChange = { username = it },
+ label = { Text("Username") },
+ modifier = Modifier.fillMaxWidth()
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+
+ OutlinedTextField(
+ value = password,
+ onValueChange = { password = it },
+ label = { Text("Password") },
+ visualTransformation = PasswordVisualTransformation(),
+ modifier = Modifier.fillMaxWidth()
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+
+ if (uiState is AuthState.Error) {
+ Text(
+ text = (uiState as AuthState.Error).message,
+ color = MaterialTheme.colorScheme.error
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+
+ Button(
+ onClick = { viewModel.login(username, password) },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = uiState !is AuthState.Loading && username.isNotBlank() && password.isNotBlank()
+ ) {
+ if (uiState is AuthState.Loading) {
+ CircularProgressIndicator(modifier = Modifier.size(24.dp), color = MaterialTheme.colorScheme.onPrimary)
+ } else {
+ Text("Login")
+ }
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+
+ TextButton(onClick = {
+ viewModel.resetState()
+ onNavigateToRegister()
+ }) {
+ Text("Don't have an account? Register")
+ }
+ }
+}
diff --git a/app/src/main/java/com/postcard/ui/auth/RegisterScreen.kt b/app/src/main/java/com/postcard/ui/auth/RegisterScreen.kt
new file mode 100644
index 0000000..91fd790
--- /dev/null
+++ b/app/src/main/java/com/postcard/ui/auth/RegisterScreen.kt
@@ -0,0 +1,92 @@
+package com.postcard.ui.auth
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+
+@Composable
+fun RegisterScreen(
+ onNavigateToLogin: () -> Unit,
+ onRegisterSuccess: () -> Unit,
+ viewModel: AuthViewModel = hiltViewModel()
+) {
+ var username by remember { mutableStateOf("") }
+ var displayName by remember { mutableStateOf("") }
+ var password by remember { mutableStateOf("") }
+ val uiState by viewModel.uiState.collectAsState()
+
+ LaunchedEffect(uiState) {
+ if (uiState is AuthState.Success) {
+ onRegisterSuccess()
+ }
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text("Register", style = MaterialTheme.typography.headlineMedium)
+ Spacer(modifier = Modifier.height(16.dp))
+
+ OutlinedTextField(
+ value = username,
+ onValueChange = { username = it },
+ label = { Text("Username") },
+ modifier = Modifier.fillMaxWidth()
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+
+ OutlinedTextField(
+ value = displayName,
+ onValueChange = { displayName = it },
+ label = { Text("Display Name") },
+ modifier = Modifier.fillMaxWidth()
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+
+ OutlinedTextField(
+ value = password,
+ onValueChange = { password = it },
+ label = { Text("Password") },
+ visualTransformation = PasswordVisualTransformation(),
+ modifier = Modifier.fillMaxWidth()
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+
+ if (uiState is AuthState.Error) {
+ Text(
+ text = (uiState as AuthState.Error).message,
+ color = MaterialTheme.colorScheme.error
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+
+ Button(
+ onClick = { viewModel.register(username, displayName, password) },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = uiState !is AuthState.Loading && username.isNotBlank() && displayName.isNotBlank() && password.isNotBlank()
+ ) {
+ if (uiState is AuthState.Loading) {
+ CircularProgressIndicator(modifier = Modifier.size(24.dp), color = MaterialTheme.colorScheme.onPrimary)
+ } else {
+ Text("Register")
+ }
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+
+ TextButton(onClick = {
+ viewModel.resetState()
+ onNavigateToLogin()
+ }) {
+ Text("Already have an account? Login")
+ }
+ }
+}
diff --git a/app/src/main/java/com/postcard/ui/conversations/ConversationListScreen.kt b/app/src/main/java/com/postcard/ui/conversations/ConversationListScreen.kt
new file mode 100644
index 0000000..8ddeb51
--- /dev/null
+++ b/app/src/main/java/com/postcard/ui/conversations/ConversationListScreen.kt
@@ -0,0 +1,17 @@
+package com.postcard.ui.conversations
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+
+@Composable
+fun ConversationListScreen(
+ onLogout: () -> Unit
+) {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text("Conversation List")
+ }
+}
diff --git a/app/src/main/java/com/postcard/ui/theme/Color.kt b/app/src/main/java/com/postcard/ui/theme/Color.kt
new file mode 100644
index 0000000..0daa79b
--- /dev/null
+++ b/app/src/main/java/com/postcard/ui/theme/Color.kt
@@ -0,0 +1,11 @@
+package com.postcard.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val Purple80 = Color(0xFFD0BCFF)
+val PurpleGrey80 = Color(0xFFCCC2DC)
+val Pink80 = Color(0xFFEFB8C8)
+
+val Purple40 = Color(0xFF6650a4)
+val PurpleGrey40 = Color(0xFF625b71)
+val Pink40 = Color(0xFF7D5260)
diff --git a/app/src/main/java/com/postcard/ui/theme/Theme.kt b/app/src/main/java/com/postcard/ui/theme/Theme.kt
new file mode 100644
index 0000000..fe93c3e
--- /dev/null
+++ b/app/src/main/java/com/postcard/ui/theme/Theme.kt
@@ -0,0 +1,57 @@
+package com.postcard.ui.theme
+
+import android.app.Activity
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.core.view.WindowCompat
+
+private val DarkColorScheme = darkColorScheme(
+ primary = Purple80,
+ secondary = PurpleGrey80,
+ tertiary = Pink80
+)
+
+private val LightColorScheme = lightColorScheme(
+ primary = Purple40,
+ secondary = PurpleGrey40,
+ tertiary = Pink40
+)
+
+@Composable
+fun PostcardTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ dynamicColor: Boolean = true,
+ content: @Composable () -> Unit
+) {
+ val colorScheme = when {
+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ val context = LocalContext.current
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ }
+ darkTheme -> DarkColorScheme
+ else -> LightColorScheme
+ }
+ val view = LocalView.current
+ if (!view.isInEditMode) {
+ SideEffect {
+ val window = (view.context as Activity).window
+ window.statusBarColor = colorScheme.primary.toArgb()
+ WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
+ }
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ content = content
+ )
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..c95bb42
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ Postcard
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..cadada1
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,5 @@
+
+
+
+
diff --git a/build.gradle.kts b/build.gradle.kts
index 7fa8bbf..058fb0b 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,22 +1,8 @@
plugins {
- kotlin("jvm") version "2.3.10"
+ id("com.android.application") version "8.5.1" apply false
+ id("org.jetbrains.kotlin.android") version "2.0.20" apply false
+ id("com.google.dagger.hilt.android") version "2.51.1" apply false
+ id("com.google.devtools.ksp") version "2.0.20-1.0.24" apply false
+ kotlin("plugin.compose") version "2.0.20" apply false
+ id("org.jetbrains.kotlin.plugin.serialization") version "2.0.20" apply false
}
-
-group = "dev.jasonmross"
-version = "1.0-SNAPSHOT"
-
-repositories {
- mavenCentral()
-}
-
-dependencies {
- testImplementation(kotlin("test"))
-}
-
-kotlin {
- jvmToolchain(25)
-}
-
-tasks.test {
- useJUnitPlatform()
-}
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
index 7fc6f1f..8f2e28c 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1 +1,4 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+android.nonTransitiveRClass=true
kotlin.code.style=official
diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties
new file mode 100644
index 0000000..8e2a58b
--- /dev/null
+++ b/gradle/gradle-daemon-jvm.properties
@@ -0,0 +1,13 @@
+#This file is generated by updateDaemonJvm
+toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/536afcd1dff540251f85e5d2c80458cf/redirect
+toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect
+toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/536afcd1dff540251f85e5d2c80458cf/redirect
+toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect
+toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e99bae143b75f9a10ead10248f02055e/redirect
+toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/658299a896470fbb3103ba3a430ee227/redirect
+toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/536afcd1dff540251f85e5d2c80458cf/redirect
+toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect
+toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/248ffb1098f61659502d0c09aa348294/redirect
+toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/932015f6361ccaead0c6d9b8717ed96e/redirect
+toolchainVendor=JETBRAINS
+toolchainVersion=21
diff --git a/local.properties b/local.properties
new file mode 100644
index 0000000..4cd8c65
--- /dev/null
+++ b/local.properties
@@ -0,0 +1,8 @@
+## This file must *NOT* be checked into Version Control Systems,
+# as it contains information specific to your local configuration.
+#
+# Location of the SDK. This is only used by Gradle.
+# For customization when using a Version Control System, please read the
+# header note.
+#Sun Mar 22 11:49:10 CDT 2026
+sdk.dir=C\:\\Android
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 5f7aa41..e8840b0 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -1,4 +1,16 @@
-plugins {
- id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
}
-rootProject.name = "postcard-android"
\ No newline at end of file
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+rootProject.name = "postcard-android"
+include(":app")