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. +