diff --git a/app/build.gradle b/app/build.gradle
index ef26b022..39c165c0 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -98,7 +98,11 @@ dependencies {
// Relaynet
implementation 'tech.relaycorp:relaynet:1.15.6'
- implementation 'tech.relaycorp:relaynet-cogrpc:1.0.2'
+ implementation 'tech.relaycorp:relaynet-cogrpc:1.1.0'
+
+ // Android TLS support for Netty
+ implementation "io.netty:netty-handler:4.1.50.Final"
+ implementation 'org.conscrypt:conscrypt-android:2.4.0'
// ORM
def room_version = "2.2.5"
diff --git a/app/schemas/tech.relaycorp.gateway.data.database.AppDatabase/1.json b/app/schemas/tech.relaycorp.gateway.data.database.AppDatabase/1.json
index 4d165ee3..abd7ae7e 100644
--- a/app/schemas/tech.relaycorp.gateway.data.database.AppDatabase/1.json
+++ b/app/schemas/tech.relaycorp.gateway.data.database.AppDatabase/1.json
@@ -2,33 +2,87 @@
"formatVersion": 1,
"database": {
"version": 1,
- "identityHash": "f4d34def0a467a21b391ecb53e2175d2",
+ "identityHash": "9ddb31ac692f375edbe5f934ce68bf51",
"entities": [
{
- "tableName": "Message",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "tableName": "Parcel",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`recipientAddress` TEXT NOT NULL, `senderAddress` TEXT NOT NULL, `messageId` TEXT NOT NULL, `creationTimeUtc` INTEGER NOT NULL, `expirationTimeUtc` INTEGER NOT NULL, `storagePath` TEXT NOT NULL, `size` INTEGER NOT NULL, PRIMARY KEY(`senderAddress`, `messageId`))",
"fields": [
{
- "fieldPath": "id",
- "columnName": "id",
+ "fieldPath": "recipientAddress",
+ "columnName": "recipientAddress",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "senderAddress",
+ "columnName": "senderAddress",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "messageId",
+ "columnName": "messageId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "creationTimeUtc",
+ "columnName": "creationTimeUtc",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "expirationTimeUtc",
+ "columnName": "expirationTimeUtc",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "storagePath",
+ "columnName": "storagePath",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "size",
+ "columnName": "size",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
- "id"
+ "senderAddress",
+ "messageId"
],
"autoGenerate": false
},
- "indices": [],
+ "indices": [
+ {
+ "name": "index_Parcel_recipientAddress",
+ "unique": false,
+ "columnNames": [
+ "recipientAddress"
+ ],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_Parcel_recipientAddress` ON `${TABLE_NAME}` (`recipientAddress`)"
+ },
+ {
+ "name": "index_Parcel_expirationTimeUtc",
+ "unique": false,
+ "columnNames": [
+ "expirationTimeUtc"
+ ],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_Parcel_expirationTimeUtc` ON `${TABLE_NAME}` (`expirationTimeUtc`)"
+ }
+ ],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
- "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f4d34def0a467a21b391ecb53e2175d2')"
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9ddb31ac692f375edbe5f934ce68bf51')"
]
}
}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 108dcfa5..925868bc 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,17 +3,23 @@
xmlns:tools="http://schemas.android.com/tools"
package="tech.relaycorp.gateway">
+
+
+
+
+ tools:ignore="GoogleAppIndexingWarning,UnusedAttribute">
@@ -21,6 +27,14 @@
+
+
diff --git a/app/src/main/java/tech/relaycorp/gateway/App.kt b/app/src/main/java/tech/relaycorp/gateway/App.kt
index d2b35fb2..692046c4 100644
--- a/app/src/main/java/tech/relaycorp/gateway/App.kt
+++ b/app/src/main/java/tech/relaycorp/gateway/App.kt
@@ -3,9 +3,11 @@ package tech.relaycorp.gateway
import android.app.Application
import android.os.Build
import android.os.StrictMode
+import org.conscrypt.Conscrypt
import tech.relaycorp.gateway.common.Logging
import tech.relaycorp.gateway.common.di.AppComponent
import tech.relaycorp.gateway.common.di.DaggerAppComponent
+import java.security.Security
import java.util.logging.Level
import java.util.logging.LogManager
@@ -29,6 +31,7 @@ class App : Application() {
override fun onCreate() {
super.onCreate()
component.inject(this)
+ setupTLSProvider()
setupLogger()
setupStrictMode()
}
@@ -74,5 +77,9 @@ class App : Application() {
}
}
+ private fun setupTLSProvider() {
+ Security.insertProviderAt(Conscrypt.newProvider(), 1)
+ }
+
enum class Mode { Normal, Test }
}
diff --git a/app/src/main/java/tech/relaycorp/gateway/AppModule.kt b/app/src/main/java/tech/relaycorp/gateway/AppModule.kt
index 6b47d8a9..5a933979 100644
--- a/app/src/main/java/tech/relaycorp/gateway/AppModule.kt
+++ b/app/src/main/java/tech/relaycorp/gateway/AppModule.kt
@@ -2,6 +2,8 @@ package tech.relaycorp.gateway
import android.content.Context
import android.content.res.Resources
+import android.net.ConnectivityManager
+import android.net.wifi.WifiManager
import dagger.Module
import dagger.Provides
@@ -21,4 +23,12 @@ class AppModule(
@Provides
fun resources(): Resources = app.resources
+
+ @Provides
+ fun connectivityManager() =
+ app.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+
+ @Provides
+ fun wifiManager() =
+ app.getSystemService(Context.WIFI_SERVICE) as WifiManager
}
diff --git a/app/src/main/java/tech/relaycorp/gateway/background/CourierConnectionObserver.kt b/app/src/main/java/tech/relaycorp/gateway/background/CourierConnectionObserver.kt
new file mode 100644
index 00000000..ffdb2eaf
--- /dev/null
+++ b/app/src/main/java/tech/relaycorp/gateway/background/CourierConnectionObserver.kt
@@ -0,0 +1,63 @@
+package tech.relaycorp.gateway.background
+
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import android.net.wifi.WifiManager
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class CourierConnectionObserver
+@Inject constructor(
+ connectivityManager: ConnectivityManager,
+ internal val wifiManager: WifiManager
+) {
+
+ internal val state =
+ MutableStateFlow(CourierConnectionState.Disconnected)
+
+ private val networkRequest =
+ NetworkRequest.Builder()
+ .addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
+ .build()
+
+ private val networkCallback = object : ConnectivityManager.NetworkCallback() {
+ override fun onAvailable(network: Network) {
+ // TODO: check if there's an actual courier server listening at the right port
+ val hotspotSourceIpAddress =
+ wifiManager.dhcpInfo?.serverAddress?.toIpAddressString()
+
+ state.value =
+ hotspotSourceIpAddress
+ ?.let { CourierConnectionState.ConnectedWithCourier("https://$it:21473") }
+ ?: CourierConnectionState.ConnectedWithUnknown
+ }
+
+ override fun onUnavailable() {
+ state.value = CourierConnectionState.Disconnected
+ }
+
+ override fun onLost(network: Network) {
+ state.value = CourierConnectionState.Disconnected
+ }
+ }
+
+ init {
+ connectivityManager.registerNetworkCallback(networkRequest, networkCallback)
+ }
+
+ fun observe(): Flow = state
+
+ private fun Int.toIpAddressString() =
+ String.format(
+ "%d.%d.%d.%d",
+ this and 0xff,
+ this shr 8 and 0xff,
+ this shr 16 and 0xff,
+ this shr 24 and 0xff
+ )
+}
diff --git a/app/src/main/java/tech/relaycorp/gateway/background/CourierConnectionState.kt b/app/src/main/java/tech/relaycorp/gateway/background/CourierConnectionState.kt
new file mode 100644
index 00000000..2d40b050
--- /dev/null
+++ b/app/src/main/java/tech/relaycorp/gateway/background/CourierConnectionState.kt
@@ -0,0 +1,7 @@
+package tech.relaycorp.gateway.background
+
+sealed class CourierConnectionState {
+ data class ConnectedWithCourier(val address: String) : CourierConnectionState()
+ object ConnectedWithUnknown : CourierConnectionState()
+ object Disconnected : CourierConnectionState()
+}
diff --git a/app/src/main/java/tech/relaycorp/gateway/common/di/ActivityComponent.kt b/app/src/main/java/tech/relaycorp/gateway/common/di/ActivityComponent.kt
index c625c178..8769bf0b 100644
--- a/app/src/main/java/tech/relaycorp/gateway/common/di/ActivityComponent.kt
+++ b/app/src/main/java/tech/relaycorp/gateway/common/di/ActivityComponent.kt
@@ -2,6 +2,8 @@ package tech.relaycorp.gateway.common.di
import dagger.Subcomponent
import tech.relaycorp.gateway.ui.main.MainActivity
+import tech.relaycorp.gateway.ui.sync.CourierConnectionActivity
+import tech.relaycorp.gateway.ui.sync.CourierSyncActivity
@PerActivity
@Subcomponent
@@ -9,5 +11,7 @@ interface ActivityComponent {
// Activities
+ fun inject(activity: CourierConnectionActivity)
+ fun inject(activity: CourierSyncActivity)
fun inject(activity: MainActivity)
}
diff --git a/app/src/main/java/tech/relaycorp/gateway/data/DataModule.kt b/app/src/main/java/tech/relaycorp/gateway/data/DataModule.kt
index f0086e5f..e72fe3ac 100644
--- a/app/src/main/java/tech/relaycorp/gateway/data/DataModule.kt
+++ b/app/src/main/java/tech/relaycorp/gateway/data/DataModule.kt
@@ -7,6 +7,7 @@ import dagger.Module
import dagger.Provides
import tech.relaycorp.gateway.App
import tech.relaycorp.gateway.data.database.AppDatabase
+import tech.relaycorp.relaynet.cogrpc.client.CogRPCClient
import javax.inject.Named
import javax.inject.Singleton
@@ -23,6 +24,11 @@ class DataModule {
Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
}.build()
+ @Provides
+ @Singleton
+ fun storedParcelDao(database: AppDatabase) =
+ database.parcelRepository()
+
@Provides
@Named("preferences_name")
fun preferencesName(appMode: App.Mode) =
@@ -37,4 +43,7 @@ class DataModule {
@Named("preferences_name") preferencesName: String
): SharedPreferences =
context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
+
+ @Provides
+ fun cogRPCClientBuilder(): CogRPCClient.Builder = CogRPCClient.Builder
}
diff --git a/app/src/main/java/tech/relaycorp/gateway/data/database/AppDatabase.kt b/app/src/main/java/tech/relaycorp/gateway/data/database/AppDatabase.kt
index b375cde1..3cc0c241 100644
--- a/app/src/main/java/tech/relaycorp/gateway/data/database/AppDatabase.kt
+++ b/app/src/main/java/tech/relaycorp/gateway/data/database/AppDatabase.kt
@@ -2,10 +2,21 @@ package tech.relaycorp.gateway.data.database
import androidx.room.Database
import androidx.room.RoomDatabase
-import tech.relaycorp.gateway.data.model.ExampleModel
+import androidx.room.TypeConverters
+import tech.relaycorp.gateway.data.model.StoredParcel
+import tech.relaycorp.gateway.data.repos.ParcelRepository
@Database(
- entities = [ExampleModel::class],
+ entities = [StoredParcel::class],
version = 1
)
-abstract class AppDatabase : RoomDatabase()
+@TypeConverters(
+ value = [
+ DateConverter::class,
+ MessageConverter::class,
+ StorageSizeConverter::class
+ ]
+)
+abstract class AppDatabase : RoomDatabase(){
+ abstract fun parcelRepository(): ParcelRepository
+}
diff --git a/app/src/main/java/tech/relaycorp/gateway/data/database/DateConverter.kt b/app/src/main/java/tech/relaycorp/gateway/data/database/DateConverter.kt
new file mode 100644
index 00000000..0ffe4aa5
--- /dev/null
+++ b/app/src/main/java/tech/relaycorp/gateway/data/database/DateConverter.kt
@@ -0,0 +1,12 @@
+package tech.relaycorp.gateway.data.database
+
+import androidx.room.TypeConverter
+import java.util.Date
+
+class DateConverter {
+ @TypeConverter
+ fun toDate(dateLong: Long) = Date(dateLong)
+
+ @TypeConverter
+ fun fromDate(date: Date) = date.time
+}
diff --git a/app/src/main/java/tech/relaycorp/gateway/data/database/MessageConverter.kt b/app/src/main/java/tech/relaycorp/gateway/data/database/MessageConverter.kt
new file mode 100644
index 00000000..a06aa092
--- /dev/null
+++ b/app/src/main/java/tech/relaycorp/gateway/data/database/MessageConverter.kt
@@ -0,0 +1,33 @@
+package tech.relaycorp.gateway.data.database
+
+import androidx.room.TypeConverter
+import tech.relaycorp.gateway.data.model.MessageAddress
+import tech.relaycorp.gateway.data.model.MessageId
+import tech.relaycorp.gateway.data.model.PrivateMessageAddress
+import tech.relaycorp.gateway.data.model.PublicMessageAddress
+
+class MessageConverter {
+ @TypeConverter
+ fun toAddress(value: String) = MessageAddress.of(value)
+
+ @TypeConverter
+ fun fromAddress(address: MessageAddress) = address.value
+
+ @TypeConverter
+ fun toPrivateAddress(value: String) = PrivateMessageAddress(value)
+
+ @TypeConverter
+ fun fromPrivateAddress(address: PrivateMessageAddress) = address.value
+
+ @TypeConverter
+ fun toPublicAddress(value: String) = PublicMessageAddress(value)
+
+ @TypeConverter
+ fun fromPublicAddress(address: PublicMessageAddress) = address.value
+
+ @TypeConverter
+ fun toId(value: String) = MessageId(value)
+
+ @TypeConverter
+ fun fromId(id: MessageId) = id.value
+}
diff --git a/app/src/main/java/tech/relaycorp/gateway/data/database/StorageSizeConverter.kt b/app/src/main/java/tech/relaycorp/gateway/data/database/StorageSizeConverter.kt
new file mode 100644
index 00000000..8cebfa61
--- /dev/null
+++ b/app/src/main/java/tech/relaycorp/gateway/data/database/StorageSizeConverter.kt
@@ -0,0 +1,12 @@
+package tech.relaycorp.gateway.data.database
+
+import androidx.room.TypeConverter
+import tech.relaycorp.gateway.data.model.StorageSize
+
+class StorageSizeConverter {
+ @TypeConverter
+ fun toStorageSize(value: Long) = StorageSize(value)
+
+ @TypeConverter
+ fun fromStorageSize(size: StorageSize) = size.bytes
+}
diff --git a/app/src/main/java/tech/relaycorp/gateway/data/disk/DiskExceptions.kt b/app/src/main/java/tech/relaycorp/gateway/data/disk/DiskExceptions.kt
new file mode 100644
index 00000000..4b1af71e
--- /dev/null
+++ b/app/src/main/java/tech/relaycorp/gateway/data/disk/DiskExceptions.kt
@@ -0,0 +1,8 @@
+package tech.relaycorp.gateway.data.disk
+
+class ParcelDataNotFoundException(
+ message: String? = null,
+ cause: Throwable? = null
+) : Exception(message, cause)
+
+class DiskException(cause: Throwable? = null) : Exception(cause)
diff --git a/app/src/main/java/tech/relaycorp/gateway/data/disk/DiskRepository.kt b/app/src/main/java/tech/relaycorp/gateway/data/disk/DiskRepository.kt
new file mode 100644
index 00000000..35c219e2
--- /dev/null
+++ b/app/src/main/java/tech/relaycorp/gateway/data/disk/DiskRepository.kt
@@ -0,0 +1,66 @@
+package tech.relaycorp.gateway.data.disk
+
+import android.content.Context
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.IOException
+import java.io.InputStream
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class DiskRepository
+@Inject constructor(
+ private val context: Context
+) {
+
+ @Throws(DiskException::class)
+ suspend fun writeParcel(parcel: ByteArray): String =
+ try {
+ writeParcelUnhandled(parcel)
+ } catch (e: IOException) {
+ throw DiskException(e)
+ }
+
+ @Throws(ParcelDataNotFoundException::class)
+ suspend fun readParcel(path: String): (() -> InputStream) {
+ val file = File(getOrCreateParcelDir(), path)
+ if (!file.exists()) throw ParcelDataNotFoundException("Parcel data not found on path '$path'")
+ return file::inputStream
+ }
+
+ suspend fun deleteMessage(path: String) {
+ withContext(Dispatchers.IO) {
+ val messagesDir = getOrCreateParcelDir()
+ File(messagesDir, path).delete()
+ }
+ }
+
+ private suspend fun writeParcelUnhandled(parcel: ByteArray) =
+ withContext(Dispatchers.IO) {
+ val file = createUniqueFile()
+ file.writeBytes(parcel)
+ file.name
+ }
+
+ private suspend fun getOrCreateParcelDir() =
+ withContext(Dispatchers.IO) {
+ File(context.filesDir, PARCEL_FOLDER_NAME).also {
+ if (!it.exists()) it.mkdir()
+ }
+ }
+
+ private suspend fun createUniqueFile() =
+ withContext(Dispatchers.IO) {
+ val parcelsDir = getOrCreateParcelDir()
+ // The file created isn't temporary, but it ensures a unique filename
+ File.createTempFile(PARCEL_FILE_PREFIX, "", parcelsDir)
+ }
+
+ companion object {
+ // Warning: changing this folder name will make users lose the paths to their parcel
+ private const val PARCEL_FOLDER_NAME = "parcels"
+ private const val PARCEL_FILE_PREFIX = "parcel_"
+ }
+}
diff --git a/app/src/main/java/tech/relaycorp/gateway/data/model/ExampleModel.kt b/app/src/main/java/tech/relaycorp/gateway/data/model/ExampleModel.kt
deleted file mode 100644
index 8df49476..00000000
--- a/app/src/main/java/tech/relaycorp/gateway/data/model/ExampleModel.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package tech.relaycorp.gateway.data.model
-
-import androidx.room.Entity
-import androidx.room.PrimaryKey
-
-@Entity(
- tableName = "Message"
-)
-data class ExampleModel(
- @PrimaryKey val id: Long
-)
diff --git a/app/src/main/java/tech/relaycorp/gateway/data/model/MessageAddress.kt b/app/src/main/java/tech/relaycorp/gateway/data/model/MessageAddress.kt
new file mode 100644
index 00000000..b8ae9ffb
--- /dev/null
+++ b/app/src/main/java/tech/relaycorp/gateway/data/model/MessageAddress.kt
@@ -0,0 +1,37 @@
+package tech.relaycorp.gateway.data.model
+
+sealed class MessageAddress {
+
+ val type
+ get() = when (this) {
+ is PublicMessageAddress -> Type.Public
+ is PrivateMessageAddress -> Type.Private
+ }
+ val value
+ get() = when (this) {
+ is PublicMessageAddress -> publicValue
+ is PrivateMessageAddress -> privateValue
+ }
+
+ companion object {
+ fun of(value: String) =
+ if (value.contains(":")) {
+ PublicMessageAddress(value)
+ } else {
+ PrivateMessageAddress(value)
+ }
+ }
+
+ enum class Type(val value: String) {
+ Public("public"), Private("private");
+
+ companion object {
+ fun fromValue(value: String) =
+ values().firstOrNull { it.value == value }
+ ?: throw IllegalArgumentException("Invalid MessageAddress.Type value = $value")
+ }
+ }
+}
+
+data class PublicMessageAddress(val publicValue: String) : MessageAddress()
+data class PrivateMessageAddress(val privateValue: String) : MessageAddress()
diff --git a/app/src/main/java/tech/relaycorp/gateway/data/model/MessageId.kt b/app/src/main/java/tech/relaycorp/gateway/data/model/MessageId.kt
new file mode 100644
index 00000000..a4f501e7
--- /dev/null
+++ b/app/src/main/java/tech/relaycorp/gateway/data/model/MessageId.kt
@@ -0,0 +1,3 @@
+package tech.relaycorp.gateway.data.model
+
+data class MessageId(val value: String)
diff --git a/app/src/main/java/tech/relaycorp/gateway/data/model/Parcel.kt b/app/src/main/java/tech/relaycorp/gateway/data/model/Parcel.kt
new file mode 100644
index 00000000..5764a9d8
--- /dev/null
+++ b/app/src/main/java/tech/relaycorp/gateway/data/model/Parcel.kt
@@ -0,0 +1,10 @@
+package tech.relaycorp.gateway.data.model
+
+import tech.relaycorp.relaynet.messages.Cargo
+import tech.relaycorp.relaynet.ramf.RAMFMessage
+
+// Dummy class to be replaced by the Relaynet core library
+object Parcel {
+ fun deserialize(bytes: ByteArray): RAMFMessage =
+ Cargo.Companion.deserialize(bytes)
+}
diff --git a/app/src/main/java/tech/relaycorp/gateway/data/model/ParcelDirection.kt b/app/src/main/java/tech/relaycorp/gateway/data/model/ParcelDirection.kt
new file mode 100644
index 00000000..061898f3
--- /dev/null
+++ b/app/src/main/java/tech/relaycorp/gateway/data/model/ParcelDirection.kt
@@ -0,0 +1,11 @@
+package tech.relaycorp.gateway.data.model
+
+enum class ParcelDirection(val value: String) {
+ Incoming("incoming"), Outgoing("outgoing");
+
+ companion object {
+ fun fromValue(value: String) =
+ values().firstOrNull { it.value == value }
+ ?: throw IllegalArgumentException("Invalid ParcelDirection value = $value")
+ }
+}
diff --git a/app/src/main/java/tech/relaycorp/gateway/data/model/StorageSize.kt b/app/src/main/java/tech/relaycorp/gateway/data/model/StorageSize.kt
new file mode 100644
index 00000000..4507c94d
--- /dev/null
+++ b/app/src/main/java/tech/relaycorp/gateway/data/model/StorageSize.kt
@@ -0,0 +1,26 @@
+package tech.relaycorp.gateway.data.model
+
+import kotlin.math.min
+
+data class StorageSize(
+ val bytes: Long
+) : Comparable {
+ val isZero get() = bytes == 0L
+
+ operator fun plus(size: StorageSize) = StorageSize(bytes + size.bytes)
+ operator fun minus(size: StorageSize) = StorageSize(bytes - size.bytes)
+ operator fun minus(diff: Long) = StorageSize(bytes - diff)
+ operator fun times(size: StorageSize) = StorageSize(bytes * size.bytes)
+ operator fun times(times: Int) = StorageSize(bytes * times)
+ operator fun div(divisor: StorageSize) = StorageSize(bytes / divisor.bytes)
+ operator fun div(divisor: Long) = StorageSize(bytes / divisor)
+ operator fun rem(divisor: StorageSize) = StorageSize(bytes % divisor.bytes)
+ override fun compareTo(other: StorageSize) = bytes.compareTo(other.bytes)
+
+ companion object {
+ val ZERO = StorageSize(0L)
+
+ fun min(size1: StorageSize, size2: StorageSize) =
+ StorageSize(min(size1.bytes, size2.bytes))
+ }
+}
diff --git a/app/src/main/java/tech/relaycorp/gateway/data/model/StoredParcel.kt b/app/src/main/java/tech/relaycorp/gateway/data/model/StoredParcel.kt
new file mode 100644
index 00000000..4f69ac6d
--- /dev/null
+++ b/app/src/main/java/tech/relaycorp/gateway/data/model/StoredParcel.kt
@@ -0,0 +1,21 @@
+package tech.relaycorp.gateway.data.model
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import java.util.Date
+
+@Entity(
+ tableName = "Parcel",
+ primaryKeys = ["senderAddress", "messageId"]
+)
+data class StoredParcel(
+ @ColumnInfo(index = true)
+ val recipientAddress: MessageAddress,
+ val senderAddress: MessageAddress,
+ val messageId: MessageId,
+ val creationTimeUtc: Date, // in UTC
+ @ColumnInfo(index = true)
+ val expirationTimeUtc: Date, // in UTC
+ val storagePath: String,
+ val size: StorageSize
+)
diff --git a/app/src/main/java/tech/relaycorp/gateway/data/repos/CargoRepository.kt b/app/src/main/java/tech/relaycorp/gateway/data/repos/CargoRepository.kt
new file mode 100644
index 00000000..29be282b
--- /dev/null
+++ b/app/src/main/java/tech/relaycorp/gateway/data/repos/CargoRepository.kt
@@ -0,0 +1,13 @@
+package tech.relaycorp.gateway.data.repos
+
+import tech.relaycorp.gateway.data.disk.DiskRepository
+import java.io.InputStream
+import javax.inject.Inject
+
+class CargoRepository
+@Inject constructor(
+ private val diskRepository: DiskRepository
+) {
+ // TODO: implementation
+ suspend fun store(inputStream: InputStream) {}
+}
diff --git a/app/src/main/java/tech/relaycorp/gateway/data/repos/ParcelRepository.kt b/app/src/main/java/tech/relaycorp/gateway/data/repos/ParcelRepository.kt
new file mode 100644
index 00000000..5448c61d
--- /dev/null
+++ b/app/src/main/java/tech/relaycorp/gateway/data/repos/ParcelRepository.kt
@@ -0,0 +1,27 @@
+package tech.relaycorp.gateway.data.repos
+
+import androidx.room.*
+import kotlinx.coroutines.flow.Flow
+import tech.relaycorp.gateway.data.model.MessageId
+import tech.relaycorp.gateway.data.model.PrivateMessageAddress
+import tech.relaycorp.gateway.data.model.StorageSize
+import tech.relaycorp.gateway.data.model.StoredParcel
+
+@Dao
+interface ParcelRepository {
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insert(message: StoredParcel)
+
+ @Delete
+ suspend fun delete(message: StoredParcel)
+
+ @Query("SELECT * FROM Parcel")
+ fun observeAll(): Flow>
+
+ @Query("SELECT COALESCE(SUM(Parcel.size), 0) FROM Parcel")
+ fun observeTotalSize(): Flow
+
+ @Query("SELECT * FROM Parcel WHERE senderAddress = :senderAddress AND messageId = :messageId LIMIT 1")
+ fun get(senderAddress: PrivateMessageAddress, messageId: MessageId): StoredParcel
+}
diff --git a/app/src/main/java/tech/relaycorp/gateway/domain/DeleteParcel.kt b/app/src/main/java/tech/relaycorp/gateway/domain/DeleteParcel.kt
new file mode 100644
index 00000000..0b9ce75c
--- /dev/null
+++ b/app/src/main/java/tech/relaycorp/gateway/domain/DeleteParcel.kt
@@ -0,0 +1,19 @@
+package tech.relaycorp.gateway.domain
+
+import tech.relaycorp.gateway.data.repos.ParcelRepository
+import tech.relaycorp.gateway.data.disk.DiskRepository
+import tech.relaycorp.gateway.data.model.MessageId
+import tech.relaycorp.gateway.data.model.PrivateMessageAddress
+import javax.inject.Inject
+
+class DeleteParcel
+@Inject constructor(
+ private val storesParcelRepository: ParcelRepository,
+ private val diskRepository: DiskRepository
+) {
+ suspend fun delete(senderAddress: PrivateMessageAddress, messageId: MessageId) {
+ val parcel = storesParcelRepository.get(senderAddress, messageId)
+ storesParcelRepository.delete(parcel)
+ diskRepository.deleteMessage(parcel.storagePath)
+ }
+}
diff --git a/app/src/main/java/tech/relaycorp/gateway/domain/StoreParcel.kt b/app/src/main/java/tech/relaycorp/gateway/domain/StoreParcel.kt
new file mode 100644
index 00000000..c225d64e
--- /dev/null
+++ b/app/src/main/java/tech/relaycorp/gateway/domain/StoreParcel.kt
@@ -0,0 +1,80 @@
+package tech.relaycorp.gateway.domain
+
+import tech.relaycorp.gateway.common.Logging.logger
+import tech.relaycorp.gateway.data.repos.ParcelRepository
+import tech.relaycorp.gateway.data.disk.DiskException
+import tech.relaycorp.gateway.data.disk.DiskRepository
+import tech.relaycorp.gateway.data.model.*
+import tech.relaycorp.relaynet.cogrpc.readBytesAndClose
+import tech.relaycorp.relaynet.ramf.RAMFException
+import tech.relaycorp.relaynet.ramf.RAMFMessage
+import java.io.InputStream
+import java.util.*
+import javax.inject.Inject
+
+class StoreParcel
+@Inject constructor(
+ private val parcelRepository: ParcelRepository,
+ private val diskRepository: DiskRepository
+) {
+
+ suspend fun storeParcel(parcelInputStream: InputStream): Result {
+ val parcelBytes = parcelInputStream.readBytesAndClose()
+ val parcel = try {
+ Parcel.deserialize(parcelBytes)
+ } catch (exc: RAMFException) {
+ logger.warning("Malformed Cargo received: ${exc.message}")
+ return Result.Error.Malformed
+ }
+
+ try {
+ parcel.validate()
+ } catch (exc: RAMFException) {
+ logger.warning("Invalid cargo received: ${exc.message}")
+ return Result.Error.Invalid
+ }
+
+ return storeMessage(parcel, parcelBytes)
+ }
+
+ private suspend fun storeMessage(
+ message: RAMFMessage,
+ data: ByteArray
+ ): Result {
+ val dataSize = StorageSize(data.size.toLong())
+
+ val storagePath = try {
+ diskRepository.writeParcel(data)
+ } catch (exception: DiskException) {
+ return Result.Error.CouldNotStore
+ }
+ val storedParcel = message.toStoredParcel(storagePath, dataSize)
+ parcelRepository.insert(storedParcel)
+ return Result.Success(storedParcel)
+ }
+
+ private fun RAMFMessage.toStoredParcel(
+ storagePath: String,
+ dataSize: StorageSize
+ ): StoredParcel {
+ val recipientAddress = PublicMessageAddress(recipientAddress)
+ return StoredParcel(
+ recipientAddress = recipientAddress,
+ senderAddress = PrivateMessageAddress(senderCertificate.subjectPrivateAddress),
+ messageId = MessageId(id),
+ creationTimeUtc = Date.from(creationDate.toInstant()),
+ expirationTimeUtc = Date.from(expiryDate.toInstant()),
+ size = dataSize,
+ storagePath = storagePath
+ )
+ }
+
+ sealed class Result {
+ data class Success(val parcel: StoredParcel) : Result()
+ sealed class Error : Result() {
+ object CouldNotStore : Error()
+ object Malformed : Error()
+ object Invalid : Error()
+ }
+ }
+}
diff --git a/app/src/main/java/tech/relaycorp/gateway/domain/courier/CargoCollection.kt b/app/src/main/java/tech/relaycorp/gateway/domain/courier/CargoCollection.kt
new file mode 100644
index 00000000..72ca1e33
--- /dev/null
+++ b/app/src/main/java/tech/relaycorp/gateway/domain/courier/CargoCollection.kt
@@ -0,0 +1,52 @@
+package tech.relaycorp.gateway.domain.courier
+
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import tech.relaycorp.gateway.background.CourierConnectionObserver
+import tech.relaycorp.gateway.background.CourierConnectionState
+import tech.relaycorp.gateway.common.Logging.logger
+import tech.relaycorp.gateway.data.repos.CargoRepository
+import tech.relaycorp.relaynet.cogrpc.client.CogRPCClient
+import java.util.logging.Level
+import javax.inject.Inject
+
+class CargoCollection
+@Inject constructor(
+ private val clientBuilder: CogRPCClient.Builder,
+ private val courierConnectionObserver: CourierConnectionObserver,
+ private val generateCCA: GenerateCCA,
+ private val cargoRepository: CargoRepository,
+ private val processCargo: ProcessCargo
+) {
+
+ @Throws(Disconnected::class)
+ suspend fun collect() {
+ val client =
+ getCourierAddress()?.let { clientBuilder.build(it) } ?: throw Disconnected()
+
+ try {
+ client
+ .collectCargo { generateCCAInputStream() }
+ .collect { cargoRepository.store(it) }
+ } catch (e: CogRPCClient.CCARefusedException) {
+ logger.log(Level.WARNING, "CCA refused")
+ return
+ } finally {
+ client.close()
+ }
+
+ processCargo.process()
+ }
+
+ private fun generateCCAInputStream() = generateCCA.generateByteArray().inputStream()
+
+ private suspend fun getCourierAddress() =
+ courierConnectionObserver
+ .observe()
+ .map { it as? CourierConnectionState.ConnectedWithCourier }
+ .first()
+ ?.address
+
+ class Disconnected : Exception()
+}
diff --git a/app/src/main/java/tech/relaycorp/gateway/domain/courier/CargoDelivery.kt b/app/src/main/java/tech/relaycorp/gateway/domain/courier/CargoDelivery.kt
new file mode 100644
index 00000000..106f59af
--- /dev/null
+++ b/app/src/main/java/tech/relaycorp/gateway/domain/courier/CargoDelivery.kt
@@ -0,0 +1,46 @@
+package tech.relaycorp.gateway.domain.courier
+
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import tech.relaycorp.gateway.background.CourierConnectionObserver
+import tech.relaycorp.gateway.background.CourierConnectionState
+import tech.relaycorp.gateway.domain.courier.GenerateCargo
+import tech.relaycorp.relaynet.CargoDeliveryRequest
+import tech.relaycorp.relaynet.cogrpc.client.CogRPCClient
+import java.util.*
+import javax.inject.Inject
+
+class CargoDelivery
+@Inject constructor(
+ private val clientBuilder: CogRPCClient.Builder,
+ private val courierConnectionObserver: CourierConnectionObserver,
+ private val generateCargo: GenerateCargo
+) {
+
+ suspend fun deliver() {
+ val client =
+ getCourierAddress()?.let { clientBuilder.build(it) } ?: throw Disconnected()
+
+ try {
+ client
+ .deliverCargo(generateCargoDeliveries())
+ .collect()
+ } finally {
+ client.close()
+ }
+ }
+
+ private suspend fun generateCargoDeliveries() =
+ generateCargo.generate()
+ .map { CargoDeliveryRequest(UUID.randomUUID().toString()) { it } }
+
+ private suspend fun getCourierAddress() =
+ courierConnectionObserver
+ .observe()
+ .map { it as? CourierConnectionState.ConnectedWithCourier }
+ .first()
+ ?.address
+
+ class Disconnected : Exception()
+}
diff --git a/app/src/main/java/tech/relaycorp/gateway/domain/courier/CourierSync.kt b/app/src/main/java/tech/relaycorp/gateway/domain/courier/CourierSync.kt
new file mode 100644
index 00000000..4094064a
--- /dev/null
+++ b/app/src/main/java/tech/relaycorp/gateway/domain/courier/CourierSync.kt
@@ -0,0 +1,54 @@
+package tech.relaycorp.gateway.domain.courier
+
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import tech.relaycorp.gateway.common.Logging.logger
+import tech.relaycorp.gateway.domain.courier.CargoDelivery
+import java.util.logging.Level
+import javax.inject.Inject
+import kotlin.time.seconds
+
+class CourierSync
+@Inject constructor(
+ private val cargoDelivery: CargoDelivery,
+ private val cargoCollection: CargoCollection
+) {
+
+ private val state = MutableStateFlow(State.Initial)
+ fun state(): Flow = state
+
+ suspend fun sync() {
+ try {
+ syncUnhandled()
+ } catch (e: Exception) {
+ logger.log(Level.WARNING, "Courier sync error", e)
+ state.value = State.Error
+ }
+ }
+
+ private suspend fun syncUnhandled() {
+ state.value = State.CollectingCargo
+ cargoCollection.collect()
+
+ state.value = State.Waiting
+ delay(WAIT_PERIOD)
+
+ state.value = State.DeliveringCargo
+ cargoDelivery.deliver()
+
+ state.value = State.Finished
+ }
+
+ enum class State {
+ DeliveringCargo, Waiting, CollectingCargo, Finished, Error;
+
+ companion object {
+ val Initial = DeliveringCargo
+ }
+ }
+
+ companion object {
+ private val WAIT_PERIOD = 2.seconds
+ }
+}
diff --git a/app/src/main/java/tech/relaycorp/gateway/domain/courier/GenerateCCA.kt b/app/src/main/java/tech/relaycorp/gateway/domain/courier/GenerateCCA.kt
new file mode 100644
index 00000000..c7aa95d6
--- /dev/null
+++ b/app/src/main/java/tech/relaycorp/gateway/domain/courier/GenerateCCA.kt
@@ -0,0 +1,15 @@
+package tech.relaycorp.gateway.domain.courier
+
+import tech.relaycorp.relaynet.messages.CargoCollectionAuthorization
+import javax.inject.Inject
+
+class GenerateCCA
+@Inject constructor() {
+
+ // TODO: implementation
+ fun generate() =
+ CargoCollectionAuthorization.deserialize(ByteArray(0))
+
+ // TODO: implementation
+ fun generateByteArray() = ByteArray(0)
+}
diff --git a/app/src/main/java/tech/relaycorp/gateway/domain/courier/GenerateCargo.kt b/app/src/main/java/tech/relaycorp/gateway/domain/courier/GenerateCargo.kt
new file mode 100644
index 00000000..f435c39b
--- /dev/null
+++ b/app/src/main/java/tech/relaycorp/gateway/domain/courier/GenerateCargo.kt
@@ -0,0 +1,13 @@
+package tech.relaycorp.gateway.domain.courier
+
+import tech.relaycorp.relaynet.messages.Cargo
+import java.io.InputStream
+import javax.inject.Inject
+
+class GenerateCargo
+@Inject constructor() {
+
+ // TODO: implementation
+ suspend fun generate(): Iterable = emptyList()
+
+}
diff --git a/app/src/main/java/tech/relaycorp/gateway/domain/courier/ProcessCargo.kt b/app/src/main/java/tech/relaycorp/gateway/domain/courier/ProcessCargo.kt
new file mode 100644
index 00000000..69ef808c
--- /dev/null
+++ b/app/src/main/java/tech/relaycorp/gateway/domain/courier/ProcessCargo.kt
@@ -0,0 +1,11 @@
+package tech.relaycorp.gateway.domain.courier
+
+import javax.inject.Inject
+
+class ProcessCargo
+@Inject constructor() {
+
+ // TODO: implement
+ suspend fun process() {}
+
+}
diff --git a/app/src/main/java/tech/relaycorp/gateway/ui/BaseActivity.kt b/app/src/main/java/tech/relaycorp/gateway/ui/BaseActivity.kt
index 2386726d..226f4fa7 100644
--- a/app/src/main/java/tech/relaycorp/gateway/ui/BaseActivity.kt
+++ b/app/src/main/java/tech/relaycorp/gateway/ui/BaseActivity.kt
@@ -6,6 +6,7 @@ import android.view.View
import android.view.WindowManager
import androidx.annotation.DrawableRes
import androidx.appcompat.app.AppCompatActivity
+import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.android.synthetic.main.common_app_bar.*
import tech.relaycorp.gateway.App
import tech.relaycorp.gateway.R
@@ -41,6 +42,7 @@ abstract class BaseActivity : AppCompatActivity() {
super.setContentView(layoutResID)
toolbarTitle?.text = title
appBar?.addSystemWindowInsetToPadding(top = true)
+ innerContainer?.addSystemWindowInsetToPadding(bottom = true)
}
protected fun setupNavigation(
diff --git a/app/src/main/java/tech/relaycorp/gateway/ui/common/FlowUtils.kt b/app/src/main/java/tech/relaycorp/gateway/ui/common/FlowUtils.kt
new file mode 100644
index 00000000..fb86ea28
--- /dev/null
+++ b/app/src/main/java/tech/relaycorp/gateway/ui/common/FlowUtils.kt
@@ -0,0 +1,2 @@
+package tech.relaycorp.gateway.ui.common
+
diff --git a/app/src/main/java/tech/relaycorp/gateway/ui/common/UIEvents.kt b/app/src/main/java/tech/relaycorp/gateway/ui/common/UIEvents.kt
new file mode 100644
index 00000000..86c1d755
--- /dev/null
+++ b/app/src/main/java/tech/relaycorp/gateway/ui/common/UIEvents.kt
@@ -0,0 +1,14 @@
+package tech.relaycorp.gateway.ui.common
+
+import android.view.View
+
+object Click
+object Finish
+
+enum class EnableState { Enabled, Disabled }
+
+fun Boolean.toEnableState() = if (this) EnableState.Enabled else EnableState.Disabled
+
+fun View.set(enableState: EnableState) {
+ isEnabled = (enableState == EnableState.Enabled)
+}
diff --git a/app/src/main/java/tech/relaycorp/gateway/ui/main/MainActivity.kt b/app/src/main/java/tech/relaycorp/gateway/ui/main/MainActivity.kt
index afeeea80..c832e74d 100644
--- a/app/src/main/java/tech/relaycorp/gateway/ui/main/MainActivity.kt
+++ b/app/src/main/java/tech/relaycorp/gateway/ui/main/MainActivity.kt
@@ -1,13 +1,20 @@
package tech.relaycorp.gateway.ui.main
+import android.content.Intent
import android.os.Bundle
-import androidx.appcompat.app.AppCompatActivity
+import kotlinx.android.synthetic.main.activity_main.*
import tech.relaycorp.gateway.R
+import tech.relaycorp.gateway.ui.BaseActivity
+import tech.relaycorp.gateway.ui.sync.CourierConnectionActivity
-class MainActivity : AppCompatActivity() {
+class MainActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setTitle(R.string.main_title)
setContentView(R.layout.activity_main)
+
+ syncCourier.setOnClickListener {
+ startActivity(CourierConnectionActivity.getIntent(this))
+ }
}
}
diff --git a/app/src/main/java/tech/relaycorp/gateway/ui/main/PublishChannel.kt b/app/src/main/java/tech/relaycorp/gateway/ui/main/PublishChannel.kt
new file mode 100644
index 00000000..be723833
--- /dev/null
+++ b/app/src/main/java/tech/relaycorp/gateway/ui/main/PublishChannel.kt
@@ -0,0 +1,5 @@
+package tech.relaycorp.gateway.ui.main
+
+import kotlinx.coroutines.channels.BroadcastChannel
+
+fun PublishFlow() = BroadcastChannel(1)
diff --git a/app/src/main/java/tech/relaycorp/gateway/ui/sync/CourierConnectionActivity.kt b/app/src/main/java/tech/relaycorp/gateway/ui/sync/CourierConnectionActivity.kt
new file mode 100644
index 00000000..2f643f19
--- /dev/null
+++ b/app/src/main/java/tech/relaycorp/gateway/ui/sync/CourierConnectionActivity.kt
@@ -0,0 +1,56 @@
+package tech.relaycorp.gateway.ui.sync
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.lifecycleScope
+import com.stationhead.android.shared.viewmodel.ViewModelFactory
+import kotlinx.android.synthetic.main.activity_courier_connection.*
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import tech.relaycorp.gateway.R
+import tech.relaycorp.gateway.background.CourierConnectionState
+import tech.relaycorp.gateway.ui.BaseActivity
+import javax.inject.Inject
+
+class CourierConnectionActivity : BaseActivity() {
+
+ @Inject
+ lateinit var viewModelFactory: ViewModelFactory
+
+ private val viewModel by lazy {
+ ViewModelProvider(this, viewModelFactory).get(CourierConnectionViewModel::class.java)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ component.inject(this)
+ setTitle(R.string.main_title)
+ setContentView(R.layout.activity_courier_connection)
+
+ startSync.setOnClickListener { openCourierSync() }
+
+ viewModel
+ .state
+ .onEach {
+ startSync.isEnabled = it is CourierConnectionState.ConnectedWithCourier
+ stateMessage.setText(
+ when (it) {
+ is CourierConnectionState.ConnectedWithCourier -> R.string.courier_connected
+ else -> R.string.courier_disconnected
+ }
+ )
+ }
+ .launchIn(lifecycleScope)
+ }
+
+ private fun openCourierSync() {
+ startActivity(CourierSyncActivity.getIntent(this))
+ finish()
+ }
+
+ companion object {
+ fun getIntent(context: Context) = Intent(context, CourierConnectionActivity::class.java)
+ }
+}
diff --git a/app/src/main/java/tech/relaycorp/gateway/ui/sync/CourierConnectionViewModel.kt b/app/src/main/java/tech/relaycorp/gateway/ui/sync/CourierConnectionViewModel.kt
new file mode 100644
index 00000000..7afd291b
--- /dev/null
+++ b/app/src/main/java/tech/relaycorp/gateway/ui/sync/CourierConnectionViewModel.kt
@@ -0,0 +1,22 @@
+package tech.relaycorp.gateway.ui.sync
+
+import kotlinx.coroutines.channels.sendBlocking
+import tech.relaycorp.gateway.background.CourierConnectionObserver
+import tech.relaycorp.gateway.ui.BaseViewModel
+import tech.relaycorp.gateway.ui.common.Click
+import tech.relaycorp.gateway.ui.main.PublishFlow
+import javax.inject.Inject
+
+class CourierConnectionViewModel
+@Inject constructor(
+ private val connectionObserver: CourierConnectionObserver
+) : BaseViewModel() {
+
+ // Outputs
+
+ val state get() = connectionObserver.observe()
+
+ init {
+
+ }
+}
diff --git a/app/src/main/java/tech/relaycorp/gateway/ui/sync/CourierSyncActivity.kt b/app/src/main/java/tech/relaycorp/gateway/ui/sync/CourierSyncActivity.kt
new file mode 100644
index 00000000..6ffc319b
--- /dev/null
+++ b/app/src/main/java/tech/relaycorp/gateway/ui/sync/CourierSyncActivity.kt
@@ -0,0 +1,81 @@
+package tech.relaycorp.gateway.ui.sync
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.appcompat.app.AlertDialog
+import androidx.core.view.isVisible
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.stationhead.android.shared.viewmodel.ViewModelFactory
+import kotlinx.android.synthetic.main.activity_courier_connection.*
+import kotlinx.android.synthetic.main.activity_courier_connection.stateMessage
+import kotlinx.android.synthetic.main.activity_courier_sync.*
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import tech.relaycorp.gateway.R
+import tech.relaycorp.gateway.background.CourierConnectionState
+import tech.relaycorp.gateway.domain.courier.CourierSync
+import tech.relaycorp.gateway.ui.BaseActivity
+import javax.inject.Inject
+
+class CourierSyncActivity : BaseActivity() {
+
+ @Inject
+ lateinit var viewModelFactory: ViewModelFactory
+
+ private val viewModel by lazy {
+ ViewModelProvider(this, viewModelFactory).get(CourierSyncViewModel::class.java)
+ }
+
+ private var stopConfirmDialog: AlertDialog? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ component.inject(this)
+ setTitle(R.string.main_title)
+ setContentView(R.layout.activity_courier_sync)
+
+ stop.setOnClickListener { showStopConfirmDialog() }
+ close.setOnClickListener { finish() }
+
+ viewModel
+ .state
+ .onEach { stateMessage.setText(it.toStringRes()) }
+ .map { it != CourierSync.State.Finished && it != CourierSync.State.Error }
+ .onEach { isSyncing ->
+ stop.isVisible = isSyncing
+ close.isVisible = !isSyncing
+ }
+ .launchIn(lifecycleScope)
+
+ viewModel
+ .finish
+ .onEach { finish() }
+ .launchIn(lifecycleScope)
+ }
+
+ private fun showStopConfirmDialog() {
+ stopConfirmDialog = MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.sync_stop_confirm_title)
+ .setMessage(R.string.sync_internet_stop_confirm_message)
+ .setPositiveButton(R.string.stop) { _, _ -> viewModel.stopClicked() }
+ .setNegativeButton(R.string.continue_, null)
+ .show()
+ }
+
+ private fun CourierSync.State.toStringRes() =
+ when (this) {
+ CourierSync.State.DeliveringCargo -> R.string.sync_delivering_cargo
+ CourierSync.State.Waiting -> R.string.sync_waiting
+ CourierSync.State.CollectingCargo -> R.string.sync_collecting_cargo
+ CourierSync.State.Finished -> R.string.sync_finished
+ CourierSync.State.Error -> R.string.sync_error
+ }
+
+ companion object {
+ fun getIntent(context: Context) = Intent(context, CourierSyncActivity::class.java)
+ }
+}
diff --git a/app/src/main/java/tech/relaycorp/gateway/ui/sync/CourierSyncViewModel.kt b/app/src/main/java/tech/relaycorp/gateway/ui/sync/CourierSyncViewModel.kt
new file mode 100644
index 00000000..4c29cd40
--- /dev/null
+++ b/app/src/main/java/tech/relaycorp/gateway/ui/sync/CourierSyncViewModel.kt
@@ -0,0 +1,51 @@
+package tech.relaycorp.gateway.ui.sync
+
+import kotlinx.coroutines.channels.sendBlocking
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import tech.relaycorp.gateway.domain.courier.CourierSync
+import tech.relaycorp.gateway.ui.BaseViewModel
+import tech.relaycorp.gateway.ui.common.Click
+import tech.relaycorp.gateway.ui.common.Finish
+import tech.relaycorp.gateway.ui.main.PublishFlow
+import javax.inject.Inject
+
+class CourierSyncViewModel
+@Inject constructor(
+ private val courierSync: CourierSync
+) : BaseViewModel() {
+
+ // Inputs
+
+ fun stopClicked() = stopClicks.sendBlocking(Click)
+ private val stopClicks = PublishFlow()
+
+ // Outputs
+
+ private val _state = MutableStateFlow(CourierSync.State.Initial)
+ val state = courierSync.state()
+
+ private val _finish = PublishFlow()
+ val finish get() = _finish.asFlow()
+
+ init {
+ val syncJob = ioScope.launch {
+ courierSync.sync()
+ }
+
+ val syncStateJob =
+ courierSync
+ .state()
+ .onEach { _state.value = it }
+ .launchIn(ioScope)
+
+ stopClicks
+ .asFlow()
+ .onEach {
+ syncStateJob.cancel()
+ syncJob.cancel()
+ _finish.send(Finish)
+ }
+ .launchIn(ioScope)
+ }
+}
diff --git a/app/src/main/res/layout/activity_courier_connection.xml b/app/src/main/res/layout/activity_courier_connection.xml
new file mode 100644
index 00000000..fc2bae71
--- /dev/null
+++ b/app/src/main/res/layout/activity_courier_connection.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_courier_sync.xml b/app/src/main/res/layout/activity_courier_sync.xml
new file mode 100644
index 00000000..85c85dee
--- /dev/null
+++ b/app/src/main/res/layout/activity_courier_sync.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 4fc1519e..8af79860 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -1,5 +1,6 @@
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 376a24af..6eda34f9 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -2,7 +2,30 @@
Gateway
+ Close
+ Stop
+ Continue
Relaynet Gateway
+
+
+ Sync with Courier
+
+
+ Connected
+ Not connected
+ Start Sync
+
+
+ Delivering data…
+ Waiting for the incoming data to become available…
+ Collecting data…
+ Done!
+ An error has occurred. You may try again.
+
+ Are you sure you want to interrupt the synchronization?
+
+ The sync hasn’t finished yet.
+