├── app ├── .gitignore ├── src │ ├── main │ │ ├── ic_launcher-web.png │ │ ├── res │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── values │ │ │ │ ├── dimens.xml │ │ │ │ ├── preloaded_fonts.xml │ │ │ │ ├── urls.xml │ │ │ │ └── colors.xml │ │ │ ├── xml │ │ │ │ └── backup_descriptor.xml │ │ │ ├── drawable │ │ │ │ ├── circle.xml │ │ │ │ ├── ic_add.xml │ │ │ │ ├── ic_back.xml │ │ │ │ ├── ic_delete.xml │ │ │ │ ├── ic_type_file.xml │ │ │ │ ├── ic_close.xml │ │ │ │ ├── ic_type_video.xml │ │ │ │ ├── ic_type_archive.xml │ │ │ │ ├── ic_type_audio.xml │ │ │ │ ├── ic_open.xml │ │ │ │ ├── ic_type_image.xml │ │ │ │ ├── main_empty_icon.xml │ │ │ │ ├── ic_more.xml │ │ │ │ ├── splash.xml │ │ │ │ ├── ic_copy.xml │ │ │ │ ├── progress_webview.xml │ │ │ │ ├── notification_icon.xml │ │ │ │ ├── app_logo.xml │ │ │ │ ├── main_empty_icon_inside.xml │ │ │ │ ├── ic_share.xml │ │ │ │ ├── uploaded_icon.xml │ │ │ │ ├── ic_launcher_foreground.xml │ │ │ │ ├── ic_type_pdf.xml │ │ │ │ ├── main_background.xml │ │ │ │ ├── ic_bitcoin.xml │ │ │ │ ├── ic_blockstack.xml │ │ │ │ └── ic_paypal.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── layout │ │ │ │ ├── activity_upload.xml │ │ │ │ ├── shared_appbar.xml │ │ │ │ ├── view_loading.xml │ │ │ │ ├── view_toolbar.xml │ │ │ │ ├── activity_faq.xml │ │ │ │ ├── partial_banner.xml │ │ │ │ ├── item_doc.xml │ │ │ │ ├── activity_login.xml │ │ │ │ ├── view_doc_menu.xml │ │ │ │ ├── activity_donate.xml │ │ │ │ └── activity_main.xml │ │ │ ├── font │ │ │ │ └── questrial.xml │ │ │ ├── drawable-v23 │ │ │ │ └── splash.xml │ │ │ └── menu │ │ │ │ └── main.xml │ │ └── java │ │ │ └── app │ │ │ └── envelop │ │ │ ├── data │ │ │ ├── security │ │ │ │ ├── EncryptionKey.kt │ │ │ │ ├── EncryptedDataWrapper.kt │ │ │ │ ├── Base64Encoder.kt │ │ │ │ ├── HashGenerator.kt │ │ │ │ ├── IVGenerator.kt │ │ │ │ ├── KeyGenerator.kt │ │ │ │ ├── EncryptionSpec.kt │ │ │ │ └── Encrypter.kt │ │ │ ├── models │ │ │ │ ├── Profile.kt │ │ │ │ ├── Progress.kt │ │ │ │ ├── User.kt │ │ │ │ ├── Index.kt │ │ │ │ ├── FileType.kt │ │ │ │ └── Upload.kt │ │ │ ├── DatabaseModule.kt │ │ │ ├── GsonPreferenceConverter.kt │ │ │ ├── Converters.kt │ │ │ ├── mappers │ │ │ │ └── IndexSanitizer.kt │ │ │ ├── PreferencesModule.kt │ │ │ ├── repositories │ │ │ │ ├── UploadRepository.kt │ │ │ │ ├── UserRepository.kt │ │ │ │ └── DocRepository.kt │ │ │ ├── BlockstackLogin.kt │ │ │ ├── Database.kt │ │ │ ├── BlockstackModule.kt │ │ │ ├── IndexDatabase.kt │ │ │ └── InnerJsonObject.kt │ │ │ ├── common │ │ │ ├── di │ │ │ │ ├── PerActivity.kt │ │ │ │ ├── ViewModelKey.kt │ │ │ │ ├── ActivityModule.kt │ │ │ │ ├── ActivityViewModelProvider.kt │ │ │ │ ├── AppComponent.kt │ │ │ │ ├── ActivityComponent.kt │ │ │ │ ├── ViewModelFactory.kt │ │ │ │ ├── AppModule.kt │ │ │ │ └── ViewModelModule.kt │ │ │ ├── EnvelopSpec.kt │ │ │ ├── rx │ │ │ │ ├── RxSchedulers.kt │ │ │ │ └── RxSingleToOperation.kt │ │ │ ├── Optional.kt │ │ │ ├── FileHandler.kt │ │ │ └── Operation.kt │ │ │ ├── ui │ │ │ ├── common │ │ │ │ ├── ActivityResult.kt │ │ │ │ ├── ViewUtils.kt │ │ │ │ ├── loading │ │ │ │ │ ├── LoadingView.kt │ │ │ │ │ └── LoadingManager.kt │ │ │ │ ├── MessageManager.kt │ │ │ │ ├── DocActions.kt │ │ │ │ ├── SystemBars.kt │ │ │ │ ├── Events.kt │ │ │ │ ├── Insets.kt │ │ │ │ └── Toolbar.kt │ │ │ ├── BaseViewModel.kt │ │ │ ├── share │ │ │ │ ├── InterceptView.kt │ │ │ │ └── ShareViewModel.kt │ │ │ ├── main │ │ │ │ ├── DummyItemView.kt │ │ │ │ ├── DocItemView.kt │ │ │ │ ├── FormatRelativeDate.kt │ │ │ │ └── DocMenuViewModel.kt │ │ │ ├── BaseActivity.kt │ │ │ ├── donate │ │ │ │ └── DonateActivity.kt │ │ │ ├── login │ │ │ │ ├── LoginViewModel.kt │ │ │ │ └── LoginActivity.kt │ │ │ ├── upload │ │ │ │ ├── UploadViewModel.kt │ │ │ │ └── UploadActivity.kt │ │ │ └── faq │ │ │ │ └── FaqActivity.kt │ │ │ ├── domain │ │ │ ├── UserService.kt │ │ │ ├── DocLinkBuilder.kt │ │ │ ├── DocIdGenerator.kt │ │ │ ├── GetDocService.kt │ │ │ ├── LogoutService.kt │ │ │ ├── LoginService.kt │ │ │ ├── PreUploadService.kt │ │ │ ├── UpdateDocRemotely.kt │ │ │ ├── IndexService.kt │ │ │ └── DeleteDocService.kt │ │ │ └── App.kt │ ├── debug │ │ ├── res │ │ │ └── values │ │ │ │ └── strings.xml │ │ └── AndroidManifest.xml │ ├── test │ │ └── java │ │ │ └── app │ │ │ └── envelop │ │ │ ├── data │ │ │ ├── security │ │ │ │ ├── TestBase64Encoder.kt │ │ │ │ ├── IVGeneratorTest.kt │ │ │ │ ├── KeyGeneratorTest.kt │ │ │ │ └── HashGeneratorTest.kt │ │ │ ├── models │ │ │ │ ├── UserTest.kt │ │ │ │ ├── FileTypeTest.kt │ │ │ │ ├── EncryptionSpecTest.kt │ │ │ │ └── DocTest.kt │ │ │ ├── repositories │ │ │ │ └── RemoteRepositoryTest.kt │ │ │ ├── mappers │ │ │ │ └── IndexSanitizerTest.kt │ │ │ └── InnerJsonObjectTest.kt │ │ │ ├── test │ │ │ └── DocFactory.kt │ │ │ ├── domain │ │ │ ├── DocIdGeneratorTest.kt │ │ │ └── GetDocServiceTest.kt │ │ │ ├── common │ │ │ └── rx │ │ │ │ └── RxSingleToOperationKtTest.kt │ │ │ └── ui │ │ │ ├── login │ │ │ └── LoginViewModelTest.kt │ │ │ └── main │ │ │ └── DocMenuViewModelTest.kt │ └── androidTest │ │ └── java │ │ └── app │ │ └── envelop │ │ ├── AppTest.kt │ │ ├── test │ │ └── AppHelper.kt │ │ ├── data │ │ ├── security │ │ │ └── EncrypterTest.kt │ │ └── IndexDatabaseTest.kt │ │ ├── ui │ │ ├── donate │ │ │ └── DonateActivityTest.kt │ │ └── main │ │ │ └── FormatRelativeDateTest.kt │ │ └── domain │ │ └── LoginServiceTest.kt ├── proguard-rules.pro ├── lint.xml ├── jacoco.gradle └── schemas │ └── app.envelop.data.Database │ └── 3.json ├── settings.gradle ├── .editorconfig ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .codecov.yml ├── .github └── workflows │ ├── lint.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── gradle.properties ├── README.md ├── gradlew.bat └── CHANGELOG.md /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/envelop-app/envelop-android/HEAD/app/src/main/ic_launcher-web.png -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{kt,kts}] 2 | indent_style=space 3 | indent_size=2 4 | continuation_indent_size=2 5 | insert_final_newline=true 6 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/envelop-app/envelop-android/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/envelop-app/envelop-android/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/envelop-app/envelop-android/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/envelop-app/envelop-android/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/envelop-app/envelop-android/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/envelop-app/envelop-android/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/debug/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Envelop Debug 4 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/data/security/EncryptionKey.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data.security 2 | 3 | class EncryptionKey( 4 | val key: ByteArray 5 | ) 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/envelop-app/envelop-android/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/envelop-app/envelop-android/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 200dp 4 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/envelop-app/envelop-android/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/envelop-app/envelop-android/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/envelop-app/envelop-android/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Keep models 2 | -keep class app.envelop.** { *; } 3 | 4 | # Blockstack Android 5 | -keep class org.blockstack.android.sdk.** { *; } 6 | -keep class com.eclipsesource.v8.** { *; } -------------------------------------------------------------------------------- /app/src/main/res/xml/backup_descriptor.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/circle.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/values/preloaded_fonts.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | @font/questrial 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/common/di/PerActivity.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.common.di 2 | 3 | import javax.inject.Scope 4 | import kotlin.annotation.AnnotationRetention.RUNTIME 5 | 6 | @Scope 7 | @Retention(RUNTIME) 8 | annotation class PerActivity -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/ui/common/ActivityResult.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.ui.common 2 | 3 | import android.content.Intent 4 | 5 | data class ActivityResult( 6 | val requestCode: Int, 7 | val resultCode: Int, 8 | val intent: Intent? 9 | ) -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/data/security/EncryptedDataWrapper.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data.security 2 | 3 | import com.google.gson.JsonObject 4 | 5 | data class EncryptedDataWrapper( 6 | val payload: String, 7 | val encryption: JsonObject 8 | ) -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/data/models/Profile.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data.models 2 | 3 | data class Profile( 4 | val name: String?, 5 | val description: String?, 6 | val avatarImage: String?, 7 | val email: String?, 8 | val isPerson: Boolean 9 | ) -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | require_ci_to_pass: yes 3 | 4 | coverage: 5 | precision: 2 6 | round: up 7 | range: "0...100" 8 | 9 | status: 10 | project: yes 11 | patch: 12 | default: 13 | target: 20% 14 | changes: yes 15 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Wed Feb 17 15:41:42 WET 2021 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-bin.zip 7 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/common/EnvelopSpec.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.common 2 | 3 | object EnvelopSpec { 4 | 5 | // Doc 6 | const val POINTER_LENGTH = 6 // characters 7 | const val PASSCODE_LENGTH = 16 // characters 8 | 9 | // File partition 10 | const val FILE_PART_SIZE = 5_000_000L // bytes 11 | 12 | } -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_upload.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/data/models/Progress.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data.models 2 | 3 | import kotlin.math.roundToInt 4 | 5 | class Progress( 6 | private val current: Int, 7 | private val total: Int 8 | ) { 9 | 10 | private val fraction get() = current.toFloat() / total.toFloat() 11 | val percentage get() = (fraction * 100).roundToInt() 12 | 13 | } -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/common/di/ViewModelKey.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.common.di 2 | 3 | import androidx.lifecycle.ViewModel 4 | import dagger.MapKey 5 | import kotlin.reflect.KClass 6 | 7 | @MustBeDocumented 8 | @Target(AnnotationTarget.FUNCTION) 9 | @Retention(AnnotationRetention.RUNTIME) 10 | @MapKey 11 | internal annotation class ViewModelKey(val value: KClass) -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_add.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/test/java/app/envelop/data/security/TestBase64Encoder.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data.security 2 | 3 | import java.util.* 4 | 5 | class TestBase64Encoder : Base64Encoder() { 6 | override fun encode(input: ByteArray) = 7 | Base64.getEncoder().encode(input).toString(Charsets.US_ASCII) 8 | 9 | override fun decode(input: String): ByteArray = 10 | Base64.getDecoder().decode(input) 11 | } -------------------------------------------------------------------------------- /app/src/main/res/font/questrial.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/ui/BaseViewModel.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.ui 2 | 3 | import androidx.lifecycle.ViewModel 4 | import io.reactivex.disposables.CompositeDisposable 5 | 6 | abstract class BaseViewModel : ViewModel() { 7 | 8 | protected val disposables by lazy { CompositeDisposable() } 9 | 10 | public override fun onCleared() { 11 | super.onCleared() 12 | disposables.clear() 13 | } 14 | 15 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_back.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/androidTest/java/app/envelop/AppTest.kt: -------------------------------------------------------------------------------- 1 | package app.envelop 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import app.envelop.test.AppHelper.app 5 | import org.junit.Assert.assertEquals 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | @RunWith(AndroidJUnit4::class) 10 | class AppTest { 11 | 12 | @Test 13 | fun mode() { 14 | assertEquals(app.mode, App.Mode.Test) 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /app/src/androidTest/java/app/envelop/test/AppHelper.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.test 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import app.envelop.App 5 | 6 | object AppHelper { 7 | val context get() = InstrumentationRegistry.getInstrumentation().targetContext!! 8 | val app get() = context.applicationContext as App 9 | val appComponent get() = app.component 10 | val resources get() = context.resources 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_delete.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_type_file.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/data/security/Base64Encoder.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data.security 2 | 3 | import android.util.Base64 4 | import javax.inject.Inject 5 | 6 | open class Base64Encoder 7 | @Inject constructor() { 8 | 9 | open fun encode(input: ByteArray): String = 10 | Base64.encodeToString(input, Base64.NO_WRAP) 11 | 12 | open fun decode(input: String): ByteArray = 13 | Base64.decode(input, Base64.NO_WRAP) 14 | 15 | } 16 | 17 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_close.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_type_video.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_type_archive.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_type_audio.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v23/splash.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 14 | 15 | -------------------------------------------------------------------------------- /app/src/test/java/app/envelop/data/security/IVGeneratorTest.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data.security 2 | 3 | import org.junit.Assert.assertEquals 4 | import org.junit.Test 5 | 6 | class IVGeneratorTest { 7 | 8 | private val generator = IVGenerator(TestBase64Encoder()) 9 | 10 | @Test 11 | fun generate() { 12 | assertEquals(generator.generate(10).size, 10) 13 | } 14 | 15 | @Test 16 | fun generateList() { 17 | assertEquals(generator.generateList(3, 10).size, 3) 18 | } 19 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_open.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/lint.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_type_image.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/main_empty_icon.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | 16 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/ui/common/ViewUtils.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.ui.common 2 | 3 | import android.view.MenuItem 4 | import android.view.View 5 | import com.jakewharton.rxbinding3.view.clicks 6 | import io.reactivex.Observable 7 | 8 | fun Observable.throttleForClicks() = 9 | throttleFirst(1, java.util.concurrent.TimeUnit.SECONDS)!! 10 | 11 | fun View.clicksThrottled() = 12 | clicks().throttleForClicks() 13 | 14 | @Suppress("unused") 15 | fun MenuItem.clicksThrottled() = 16 | clicks().throttleForClicks() 17 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/data/DatabaseModule.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data 2 | 3 | import android.content.Context 4 | import app.envelop.App 5 | import dagger.Module 6 | import dagger.Provides 7 | import javax.inject.Singleton 8 | 9 | @Module 10 | class DatabaseModule { 11 | 12 | @Provides 13 | @Singleton 14 | fun database(context: Context, appMode: App.Mode) = 15 | Database.create(context, appMode) 16 | 17 | @Provides 18 | fun uploadRepository(database: Database) = 19 | database.uploadRepository() 20 | 21 | } -------------------------------------------------------------------------------- /app/src/main/res/layout/shared_appbar.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_more.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/ui/share/InterceptView.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.ui.share 2 | 3 | import android.content.Context 4 | import android.util.AttributeSet 5 | import android.view.MotionEvent 6 | import android.widget.FrameLayout 7 | 8 | class InterceptView 9 | @JvmOverloads constructor( 10 | context: Context, 11 | attrs: AttributeSet? = null, 12 | defStyleAttr: Int = 0 13 | ) : FrameLayout(context, attrs, defStyleAttr) { 14 | 15 | override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean { 16 | return true 17 | } 18 | } -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/common/rx/RxSchedulers.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.common.rx 2 | 3 | import io.reactivex.Observable 4 | import io.reactivex.Single 5 | import io.reactivex.android.schedulers.AndroidSchedulers 6 | import io.reactivex.schedulers.Schedulers 7 | 8 | fun Observable.observeOnUI(): Observable = 9 | observeOn(AndroidSchedulers.mainThread()) 10 | 11 | fun Single.observeOnUI() = 12 | observeOn(AndroidSchedulers.mainThread()) 13 | 14 | fun Single.observeOnIO() = 15 | observeOn(Schedulers.io()) 16 | 17 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/data/models/User.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data.models 2 | 3 | data class User( 4 | val username: String, 5 | val decentralizedId: String, 6 | val hubUrl: String, 7 | val profile: Profile? 8 | ) { 9 | 10 | val displayName 11 | get() = 12 | profile?.name?.ifBlank { null } 13 | ?: usernameShort 14 | 15 | val usernameShort = usernameShort(username) 16 | 17 | companion object { 18 | fun usernameShort(username: String) = 19 | username.replace(".id.blockstack", "") 20 | } 21 | 22 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/splash.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/common/di/ActivityModule.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.common.di 2 | 3 | import android.app.Activity 4 | import androidx.fragment.app.FragmentActivity 5 | import app.envelop.ui.BaseActivity 6 | import dagger.Module 7 | import dagger.Provides 8 | 9 | @Module 10 | class ActivityModule( 11 | private val activity: BaseActivity 12 | ) { 13 | 14 | @Provides 15 | fun baseActivity() = activity 16 | 17 | @Provides 18 | fun activity(): Activity = activity 19 | 20 | @Provides 21 | fun fragmentActivity(): FragmentActivity = activity 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/data/security/HashGenerator.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data.security 2 | 3 | import java.security.SecureRandom 4 | import javax.inject.Inject 5 | 6 | class HashGenerator 7 | @Inject constructor() { 8 | 9 | private val random by lazy { 10 | SecureRandom() 11 | } 12 | 13 | fun generate(size: Int) = 14 | (1..size).map { 15 | HASH_CHARS[random.nextInt(HASH_CHARS.length)] 16 | }.joinToString("") 17 | 18 | companion object { 19 | private const val HASH_CHARS = "AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz01234567890" 20 | } 21 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_copy.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/progress_webview.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/data/GsonPreferenceConverter.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data 2 | 3 | import app.envelop.common.Optional 4 | import com.f2prateek.rx.preferences2.Preference 5 | import com.google.gson.Gson 6 | import kotlin.reflect.KClass 7 | 8 | class GsonPreferenceConverter( 9 | private val gson: Gson, 10 | private val klass: KClass 11 | ) : Preference.Converter> { 12 | 13 | override fun deserialize(serialized: String): Optional = 14 | Optional.create(gson.fromJson(serialized, klass.java)) 15 | 16 | override fun serialize(value: Optional): String = 17 | gson.toJson(value.element()) 18 | 19 | } -------------------------------------------------------------------------------- /app/src/test/java/app/envelop/data/security/KeyGeneratorTest.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data.security 2 | 3 | import org.junit.Assert.assertEquals 4 | import org.junit.Test 5 | 6 | class KeyGeneratorTest { 7 | 8 | private val subject = KeyGenerator() 9 | 10 | @Test 11 | fun generate() { 12 | val key = subject.generate( 13 | Pbkdf2AesEncryptionSpec( 14 | salt = "envelop", 15 | iv = "" 16 | ), 17 | "1234567890" 18 | ) 19 | val base64Key = TestBase64Encoder().encode(key.key) 20 | println("Key: $base64Key") 21 | assertEquals("UO5jyuBhLcLG2roF53OWtQzhdTInmVYgxvMn3egcXqA=", base64Key) 22 | } 23 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/notification_icon.xml: -------------------------------------------------------------------------------- 1 | 3 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/domain/UserService.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.domain 2 | 3 | import app.envelop.common.Optional 4 | import app.envelop.data.models.User 5 | import app.envelop.data.repositories.UserRepository 6 | import io.reactivex.Single 7 | import javax.inject.Inject 8 | 9 | class UserService 10 | @Inject constructor( 11 | private val userRepository: UserRepository 12 | ) { 13 | 14 | fun user() = 15 | userRepository.user() 16 | 17 | fun userSingle(): Single = 18 | userRepository 19 | .user() 20 | .filter { it is Optional.Some } 21 | .map { it.element()!! } 22 | .take(1) 23 | .firstOrError() 24 | 25 | } -------------------------------------------------------------------------------- /app/src/test/java/app/envelop/data/models/UserTest.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data.models 2 | 3 | import org.junit.Assert.assertEquals 4 | import org.junit.Test 5 | 6 | class UserTest { 7 | 8 | private val baseUser = User( 9 | username = "", 10 | decentralizedId = "", 11 | hubUrl = "", 12 | profile = null 13 | ) 14 | 15 | @Test 16 | fun usernameShort() { 17 | baseUser.copy(username = "johnsmith.id.blockstack").also { 18 | assertEquals("johnsmith", it.usernameShort) 19 | } 20 | 21 | baseUser.copy(username = "johnsmith.id.example").also { 22 | assertEquals("johnsmith.id.example", it.usernameShort) 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /app/src/test/java/app/envelop/data/security/HashGeneratorTest.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data.security 2 | 3 | import org.junit.Assert.assertEquals 4 | import org.junit.Assert.assertTrue 5 | import org.junit.Test 6 | import java.util.* 7 | 8 | class HashGeneratorTest { 9 | 10 | private val generator = HashGenerator() 11 | 12 | @Test 13 | fun length() { 14 | arrayOf(1, 5, 10).forEach { 15 | assertEquals(it, generator.generate(it).length) 16 | } 17 | } 18 | 19 | @Test 20 | fun chars() { 21 | (Random().nextInt(10) + 1).also { length -> 22 | assertTrue(generator.generate(length).matches(Regex("[A-Za-z0-9]+"))) 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /app/src/test/java/app/envelop/data/models/FileTypeTest.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data.models 2 | 3 | import org.junit.Assert.assertEquals 4 | import org.junit.Test 5 | 6 | class FileTypeTest { 7 | 8 | @Test 9 | fun fromContentType() { 10 | assertEquals(FileType.Image, FileType.fromContentType("png")) 11 | assertEquals(FileType.Archive, FileType.fromContentType("zip")) 12 | assertEquals(FileType.Video, FileType.fromContentType("mov")) 13 | assertEquals(FileType.PDF, FileType.fromContentType("pdf")) 14 | assertEquals(FileType.Audio, FileType.fromContentType("mp3")) 15 | assertEquals(FileType.Default, FileType.fromContentType("any")) 16 | } 17 | 18 | } -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/common/di/ActivityViewModelProvider.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.common.di 2 | 3 | import androidx.fragment.app.FragmentActivity 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.ViewModelProvider 6 | import javax.inject.Inject 7 | 8 | @PerActivity 9 | class ActivityViewModelProvider 10 | @Inject constructor( 11 | activity: FragmentActivity, 12 | factory: ViewModelProvider.Factory 13 | ) { 14 | 15 | private val viewModelProvider: ViewModelProvider = ViewModelProvider(activity, factory) 16 | 17 | operator fun get(viewModelClass: Class): T { 18 | return viewModelProvider.get(viewModelClass) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/domain/DocLinkBuilder.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.domain 2 | 3 | import android.content.res.Resources 4 | import app.envelop.R 5 | import app.envelop.data.models.Doc 6 | import app.envelop.data.models.User 7 | import javax.inject.Inject 8 | 9 | class DocLinkBuilder 10 | @Inject constructor( 11 | private val resources: Resources 12 | ) { 13 | 14 | fun build(doc: Doc): String { 15 | val usernameShort = User.usernameShort(doc.username) 16 | return doc.passcode 17 | ?.let { pass -> resources.getString(R.string.doc_url, usernameShort, doc.id, pass) } 18 | ?: resources.getString(R.string.doc_url_old, usernameShort, doc.id) 19 | } 20 | 21 | } -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/ui/common/loading/LoadingView.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.ui.common.loading 2 | 3 | import android.content.Context 4 | import android.view.View 5 | import android.view.ViewGroup 6 | import android.widget.FrameLayout 7 | import app.envelop.R 8 | import kotlinx.android.synthetic.main.view_loading.view.* 9 | 10 | class LoadingView(context: Context) : FrameLayout(context) { 11 | 12 | init { 13 | layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) 14 | View.inflate(context, R.layout.view_loading, this) 15 | } 16 | 17 | fun setMessage(messageRes: Int) = loadingMessage.setText(messageRes) 18 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/app_logo.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/ui/main/DummyItemView.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.ui.main 2 | 3 | import android.content.Context 4 | import android.util.AttributeSet 5 | import android.view.View 6 | import com.airbnb.epoxy.ModelView 7 | 8 | @ModelView(autoLayout = ModelView.Size.MATCH_WIDTH_WRAP_HEIGHT) 9 | class DummyItemView 10 | @JvmOverloads 11 | constructor( 12 | context: Context, 13 | attrs: AttributeSet? = null, 14 | defStyleAttr: Int = 0 15 | ) : View(context, attrs, defStyleAttr) { 16 | 17 | // 1 invisible px height 18 | override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { 19 | super.onMeasure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(1, MeasureSpec.EXACTLY)) 20 | } 21 | 22 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/main_empty_icon_inside.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/data/models/Index.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data.models 2 | 3 | import com.google.gson.JsonArray 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class Index( 7 | @SerializedName("files") 8 | val jsonArray: JsonArray = JsonArray() 9 | ) { 10 | 11 | constructor(docs: List) : this( 12 | docs 13 | .map { it.toJsonObject().json } 14 | .let { jsonList -> 15 | val array = JsonArray(jsonList.size) 16 | jsonList.forEach { array.add(it) } 17 | array 18 | } 19 | ) 20 | 21 | val docs get() = jsonArray.mapNotNull { Doc.build(it.asJsonObject) } 22 | 23 | } 24 | 25 | data class UnsanitizedIndex( 26 | @SerializedName("files") 27 | val jsonArray: JsonArray = JsonArray() 28 | ) -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_share.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/domain/DocIdGenerator.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.domain 2 | 3 | import app.envelop.common.EnvelopSpec 4 | import app.envelop.data.repositories.DocRepository 5 | import app.envelop.data.security.HashGenerator 6 | import javax.inject.Inject 7 | 8 | class DocIdGenerator 9 | @Inject constructor( 10 | private val hashGenerator: HashGenerator, 11 | private val docRepository: DocRepository 12 | ) { 13 | 14 | fun generate(): String { 15 | lateinit var docId: String 16 | do { 17 | docId = hashGenerator.generate(EnvelopSpec.POINTER_LENGTH) 18 | } while (existingIds.contains(docId)) 19 | return docId 20 | } 21 | 22 | private val existingIds by lazy { 23 | docRepository.list().blockingFirst().map { it.id } 24 | } 25 | 26 | } 27 | 28 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/uploaded_icon.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/domain/GetDocService.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.domain 2 | 3 | import app.envelop.common.Optional 4 | import app.envelop.data.models.Upload 5 | import app.envelop.data.repositories.DocRepository 6 | import app.envelop.data.repositories.UploadRepository 7 | import io.reactivex.Observable 8 | import javax.inject.Inject 9 | 10 | class GetDocService 11 | @Inject constructor( 12 | private val docRepository: DocRepository, 13 | private val uploadRepository: UploadRepository 14 | ) { 15 | 16 | fun get(id: String) = 17 | docRepository 18 | .get(id) 19 | 20 | fun getUpload(docId: String): Observable> = 21 | uploadRepository 22 | .getByDocId(docId) 23 | .map { Optional.create(it.firstOrNull()) } 24 | .toObservable() 25 | 26 | } -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/data/Converters.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data 2 | 3 | import androidx.room.TypeConverter 4 | import java.util.* 5 | 6 | class Converters { 7 | @TypeConverter 8 | fun timestampToDate(value: Long?) = value?.let { Date(value) } 9 | 10 | @TypeConverter 11 | fun dateToTimestamp(date: Date?) = date?.time 12 | 13 | @TypeConverter 14 | fun stringToIntList(value: String?) = 15 | value?.split(",")?.mapNotNull { it.toIntOrNull() } ?: emptyList() 16 | 17 | @TypeConverter 18 | fun intListToString(list: List?) = 19 | list?.joinToString(",") ?: "" 20 | 21 | @TypeConverter 22 | fun stringToStringList(value: String?) = 23 | value?.split(",") 24 | 25 | @TypeConverter 26 | fun stringListToString(list: List?) = 27 | list?.joinToString(",") 28 | } -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/data/security/IVGenerator.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data.security 2 | 3 | import java.security.SecureRandom 4 | import javax.inject.Inject 5 | 6 | class IVGenerator 7 | @Inject constructor( 8 | private val base64Encoder: Base64Encoder 9 | ) { 10 | 11 | private val random by lazy { 12 | SecureRandom() 13 | } 14 | 15 | fun generate(ivSize: Int) = 16 | ByteArray(ivSize).also { 17 | random.nextBytes(it) 18 | } 19 | 20 | fun generateInBase64(ivSize: Int) = 21 | base64Encoder.encode(generate(ivSize)) 22 | 23 | fun generateList(count: Int, ivSize: Int) = 24 | (1..count).map { 25 | generate(ivSize) 26 | } 27 | 28 | fun generateListInBase64(count: Int, ivSize: Int) = 29 | generateList(count, ivSize).map { 30 | base64Encoder.encode(it) 31 | } 32 | 33 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 8 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: [push] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v1 12 | 13 | - name: Setup JDK 1.8 14 | uses: actions/setup-java@v1 15 | with: 16 | java-version: 1.8 17 | 18 | - name: Cache 19 | uses: actions/cache@v1 20 | with: 21 | path: ~/.gradle/caches 22 | key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} 23 | restore-keys: | 24 | ${{ runner.os }}-gradle- 25 | 26 | - name: Android Lint 27 | run: ./gradlew lint 28 | 29 | - name: Upload lint reports 30 | uses: actions/upload-artifact@v1 31 | if: always() 32 | with: 33 | name: lint-reports 34 | path: app/build/reports/ 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Built application files 2 | *.apk 3 | *.aab 4 | *.ap_ 5 | output.json 6 | 7 | # Files for the ART/Dalvik VM 8 | *.dex 9 | 10 | # Java class files 11 | *.class 12 | 13 | # Generated files 14 | bin/ 15 | gen/ 16 | out/ 17 | 18 | # Gradle files 19 | .gradle/ 20 | build/ 21 | 22 | # Local configuration file (sdk path, etc) 23 | local.properties 24 | 25 | # Proguard folder generated by Eclipse 26 | proguard/ 27 | 28 | # Log Files 29 | *.log 30 | 31 | # Android Studio Navigation editor temp files 32 | .navigation/ 33 | 34 | # Android Studio captures folder 35 | captures/ 36 | 37 | # IntelliJ 38 | *.iml 39 | .idea 40 | 41 | # Keystore files 42 | *.jks 43 | 44 | # External native build folder generated in Android Studio 2.2 and later 45 | .externalNativeBuild 46 | 47 | # Google Services (e.g. APIs or Firebase) 48 | google-services.json 49 | 50 | # Finder 51 | *.DS_Store 52 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/common/di/AppComponent.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.common.di 2 | 3 | import app.envelop.App 4 | import app.envelop.background.UploadBackgroundService 5 | import app.envelop.data.BlockstackModule 6 | import app.envelop.data.DatabaseModule 7 | import app.envelop.data.IndexDatabase 8 | import app.envelop.data.PreferencesModule 9 | import dagger.Component 10 | import javax.inject.Singleton 11 | 12 | @Singleton 13 | @Component( 14 | modules = [ 15 | AppModule::class, PreferencesModule::class, ViewModelModule::class, BlockstackModule::class, DatabaseModule::class 16 | ] 17 | ) 18 | interface AppComponent { 19 | 20 | fun plus(activityModule: ActivityModule): ActivityComponent 21 | 22 | fun inject(app: App) 23 | 24 | // Services 25 | 26 | fun inject(service: UploadBackgroundService) 27 | 28 | // For tests 29 | 30 | fun indexDatabase(): IndexDatabase 31 | 32 | } -------------------------------------------------------------------------------- /app/src/test/java/app/envelop/test/DocFactory.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.test 2 | 3 | import app.envelop.data.models.Doc 4 | import app.envelop.data.security.Pbkdf2AesEncryptionSpec 5 | 6 | object DocFactory { 7 | 8 | fun build() = 9 | Doc( 10 | id = "ABCDEF", 11 | name = "file.pdf", 12 | url = "UUID-UUID", 13 | size = 1_000, 14 | contentType = null, 15 | numParts = 1, 16 | username = "", 17 | encryptionSpec = null 18 | ) 19 | 20 | fun fivePartsBuild() = 21 | Doc( 22 | id = "ABCDEF", 23 | name = "file.pdf", 24 | url = "UUID-UUID", 25 | size = 5_000, 26 | contentType = null, 27 | numParts = 5, 28 | username = "", 29 | encryptionSpec = Pbkdf2AesEncryptionSpec(salt = "abc", iv = "hello"), 30 | partIVs = listOf("1", "2", "3", "4", "5"), 31 | passcode = "abc" 32 | ) 33 | 34 | } 35 | -------------------------------------------------------------------------------- /app/src/main/res/values/urls.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | envelop.app 4 | https://envelop.app 5 | https://envl.app/%1$s/%2$s 6 | https://envl.app/%1$s/%2$s!%3$s 7 | https://envelop.app/faq.html 8 | feedback@envelop.app 9 | 10 | 11 | https://commerce.coinbase.com/checkout/6b9b0397-0481-483b-a08b-d46a915d99e6 12 | https://explorer.blockstack.org/address/stacks/SP7HTEK3HGNMDDYTH7JRP890J2VB8KC181N65XCK 13 | https://www.paypal.me/blocoio 14 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/data/mappers/IndexSanitizer.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data.mappers 2 | 3 | import app.envelop.data.models.Doc 4 | import app.envelop.data.models.Index 5 | import app.envelop.data.models.UnsanitizedIndex 6 | import app.envelop.domain.UserService 7 | import javax.inject.Inject 8 | 9 | class IndexSanitizer 10 | @Inject constructor( 11 | private val userService: UserService 12 | ) { 13 | 14 | fun sanitize(unsanitizedIndex: UnsanitizedIndex) = 15 | userService 16 | .userSingle() 17 | .map { user -> 18 | Index( 19 | unsanitizedIndex 20 | .jsonArray 21 | .map { 22 | it.asJsonObject.also { docJson -> 23 | if (!docJson.has("username") || docJson.get("username").isJsonNull) { 24 | docJson.addProperty("username", user.username) 25 | } 26 | } 27 | } 28 | .map { Doc.build(it) } 29 | ) 30 | } 31 | 32 | } -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/data/PreferencesModule.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data 2 | 3 | import android.content.Context 4 | import android.content.SharedPreferences 5 | import app.envelop.App 6 | import com.f2prateek.rx.preferences2.RxSharedPreferences 7 | import dagger.Module 8 | import dagger.Provides 9 | import javax.inject.Singleton 10 | 11 | @Module 12 | class PreferencesModule { 13 | 14 | @Provides 15 | @Singleton 16 | fun sharedPreferences(context: Context, appMode: App.Mode): SharedPreferences = 17 | context.getSharedPreferences( 18 | when (appMode) { 19 | App.Mode.Normal -> PREFERENCES_NAME 20 | App.Mode.Test -> PREFERENCES_NAME + "_test" 21 | }, 22 | Context.MODE_PRIVATE 23 | ) 24 | 25 | @Provides 26 | @Singleton 27 | fun rxSharedPreferences(sharedPreferences: SharedPreferences) = 28 | RxSharedPreferences.create(sharedPreferences) 29 | 30 | companion object { 31 | private const val PREFERENCES_NAME = "envelop" 32 | } 33 | 34 | } -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/data/repositories/UploadRepository.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data.repositories 2 | 3 | import androidx.room.* 4 | import app.envelop.data.models.Upload 5 | import io.reactivex.Flowable 6 | 7 | @Dao 8 | interface UploadRepository { 9 | 10 | @Query("SELECT * FROM Upload WHERE Upload.id = :id LIMIT 1") 11 | fun get(id: Long): Flowable> 12 | 13 | @Query("SELECT * FROM Upload WHERE Upload.docId = :docId LIMIT 1") 14 | fun getByDocId(docId: String): Flowable> 15 | 16 | @Query("SELECT * FROM Upload ORDER BY Upload.id ASC") 17 | fun getAll(): Flowable> 18 | 19 | @Query("SELECT COUNT(Upload.id) FROM Upload") 20 | fun count(): Flowable 21 | 22 | @Insert(onConflict = OnConflictStrategy.REPLACE) 23 | fun save(upload: Upload) 24 | 25 | @Delete 26 | fun delete(upload: Upload) 27 | 28 | @Query("DELETE FROM Upload WHERE docId = :id") 29 | fun deleteByDocId(id: String) 30 | 31 | @Query("DELETE FROM Upload") 32 | fun deleteAll() 33 | 34 | } -------------------------------------------------------------------------------- /app/src/main/res/layout/view_loading.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 19 | 20 | 29 | 30 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/data/BlockstackLogin.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data 2 | 3 | import android.app.Activity 4 | import app.envelop.common.rx.rxSingleToOperation 5 | import org.blockstack.android.sdk.BlockstackConnect 6 | 7 | import org.blockstack.android.sdk.BlockstackSession 8 | import javax.inject.Inject 9 | import javax.inject.Provider 10 | 11 | open class BlockstackLogin 12 | @Inject constructor( 13 | private val blockstackProvider: Provider, 14 | private val blockstackConnectProvider: Provider, 15 | private val activity: Activity 16 | ) { 17 | 18 | open fun login() = blockstackConnectProvider.get().connect(activity) 19 | 20 | open fun handlePendingSignIn(token: String) = rxSingleToOperation { 21 | val result = blockstackProvider.get().handlePendingSignIn(token) 22 | if (result.hasErrors || result.value == null) { 23 | throw Error(result.error?.message) 24 | } else { 25 | result.value!! 26 | } 27 | } 28 | 29 | class Error(message: String) : Exception(message) 30 | 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/ui/common/MessageManager.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.ui.common 2 | 3 | import android.app.Activity 4 | import android.content.res.Resources 5 | import android.view.View 6 | import androidx.annotation.StringRes 7 | import com.google.android.material.snackbar.Snackbar 8 | import javax.inject.Inject 9 | 10 | class MessageManager 11 | @Inject constructor( 12 | private val activity: Activity, 13 | private val resources: Resources 14 | ) { 15 | 16 | fun showNotice(@StringRes messageRes: Int) = 17 | showNotice(resources.getString(messageRes)) 18 | 19 | private fun showNotice(message: String) = 20 | Snackbar 21 | .make(getRootView(), message, Snackbar.LENGTH_LONG) 22 | .show() 23 | 24 | fun showError(@StringRes errorRes: Int) = 25 | showError(resources.getString(errorRes)) 26 | 27 | private fun showError(error: String) = 28 | Snackbar 29 | .make(getRootView(), error, Snackbar.LENGTH_LONG) 30 | .show() 31 | 32 | private fun getRootView() = activity.findViewById(android.R.id.content) 33 | 34 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/app/envelop/data/security/EncrypterTest.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data.security 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import org.junit.Assert.assertEquals 5 | import org.junit.Test 6 | import org.junit.runner.RunWith 7 | 8 | @RunWith(AndroidJUnit4::class) 9 | class EncrypterTest { 10 | 11 | private val base64 = Base64Encoder() 12 | private val subject = Pbkdf2AesEncrypter( 13 | KeyGenerator(), 14 | base64 15 | ) 16 | 17 | @Test 18 | fun encryptAndDecrypt() { 19 | val data = "Lorem ipsum" 20 | val passcode = "very_secure_password" 21 | val spec = Pbkdf2AesEncryptionSpec( 22 | salt = "envelop", 23 | iv = "YhgzuN+x+X0aWZ7P2pAsPw==" 24 | ) 25 | 26 | val encryptResult = subject.encryptToBase64(data.toByteArray(Charsets.UTF_8), spec, passcode) 27 | assertEquals("PnUqCSCgEV8FSGQ=", encryptResult.data) 28 | 29 | val decryptResult = subject.decryptFromBase64(encryptResult.data, spec, passcode) 30 | assertEquals(data, decryptResult.toString(Charsets.UTF_8)) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/data/Database.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data 2 | 3 | import android.content.Context 4 | import androidx.room.Room 5 | import androidx.room.RoomDatabase 6 | import androidx.room.TypeConverters 7 | import app.envelop.App 8 | import app.envelop.data.models.Upload 9 | import app.envelop.data.repositories.UploadRepository 10 | 11 | @androidx.room.Database( 12 | entities = [ 13 | Upload::class 14 | ], 15 | version = 3 16 | ) 17 | @TypeConverters(Converters::class) 18 | abstract class Database : RoomDatabase() { 19 | 20 | abstract fun uploadRepository(): UploadRepository 21 | 22 | companion object { 23 | private const val NAME = "app.db" 24 | 25 | fun create(context: Context, appMode: App.Mode) = 26 | Room 27 | .databaseBuilder(context, Database::class.java, getName(appMode)) 28 | .fallbackToDestructiveMigrationFrom(1, 2) 29 | .build() 30 | 31 | private fun getName(appMode: App.Mode) = when (appMode) { 32 | App.Mode.Normal -> NAME 33 | App.Mode.Test -> "test_$NAME" 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/data/repositories/UserRepository.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data.repositories 2 | 3 | import app.envelop.common.Optional 4 | import app.envelop.data.GsonPreferenceConverter 5 | import app.envelop.data.models.User 6 | import com.f2prateek.rx.preferences2.RxSharedPreferences 7 | import com.google.gson.Gson 8 | import io.reactivex.schedulers.Schedulers 9 | import javax.inject.Inject 10 | import javax.inject.Provider 11 | 12 | open class UserRepository 13 | @Inject constructor( 14 | rxPreferencesProvider: Provider, 15 | gson: Gson 16 | ) { 17 | 18 | private val userPreference by lazy { 19 | rxPreferencesProvider 20 | .get() 21 | .getObject( 22 | KEY_USER, 23 | Optional.None, 24 | GsonPreferenceConverter(gson, User::class) 25 | ) 26 | } 27 | 28 | fun user() = userPreference.asObservable().subscribeOn(Schedulers.io())!! 29 | open fun setUser(user: User?) = userPreference.set(Optional.create(user)) 30 | 31 | companion object { 32 | private const val KEY_USER = "user" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/common/di/ActivityComponent.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.common.di 2 | 3 | import app.envelop.ui.common.Toolbar 4 | import app.envelop.ui.faq.FaqActivity 5 | import app.envelop.ui.login.LoginActivity 6 | import app.envelop.ui.main.DocItemView 7 | import app.envelop.ui.main.DocMenuView 8 | import app.envelop.ui.main.MainActivity 9 | import app.envelop.ui.share.ShareActivity 10 | import app.envelop.ui.upload.UploadActivity 11 | import dagger.Subcomponent 12 | 13 | @PerActivity 14 | @Subcomponent( 15 | modules = [ 16 | ActivityModule::class 17 | ] 18 | ) 19 | interface ActivityComponent { 20 | 21 | fun viewModelProvider(): ActivityViewModelProvider 22 | 23 | // Views 24 | 25 | fun inject(view: DocMenuView) 26 | fun inject(toolbar: Toolbar) 27 | 28 | // Activities 29 | 30 | fun inject(faqActivity: FaqActivity) 31 | fun inject(loginActivity: LoginActivity) 32 | fun inject(mainActivity: MainActivity) 33 | fun inject(shareActivity: ShareActivity) 34 | fun inject(uploadActivity: UploadActivity) 35 | fun inject(docItemView: DocItemView) 36 | } 37 | -------------------------------------------------------------------------------- /app/src/test/java/app/envelop/domain/DocIdGeneratorTest.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.domain 2 | 3 | import app.envelop.data.repositories.DocRepository 4 | import app.envelop.data.security.HashGenerator 5 | import app.envelop.test.DocFactory 6 | import com.nhaarman.mockitokotlin2.any 7 | import com.nhaarman.mockitokotlin2.mock 8 | import com.nhaarman.mockitokotlin2.whenever 9 | import io.reactivex.Observable 10 | import org.junit.Assert.assertEquals 11 | import org.junit.Test 12 | 13 | class DocIdGeneratorTest { 14 | 15 | @Test 16 | fun generate() { 17 | val hashGenerator = mock() 18 | val docRepository = mock() 19 | val subject = DocIdGenerator(hashGenerator, docRepository) 20 | 21 | whenever(hashGenerator.generate(any())).thenReturn("A", "B", "C") 22 | whenever(docRepository.list()).thenReturn( 23 | Observable.just( 24 | listOf( 25 | DocFactory.build().copy(id = "A"), 26 | DocFactory.build().copy(id = "B") 27 | ) 28 | ) 29 | ) 30 | 31 | assertEquals("C", subject.generate()) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/common/di/ViewModelFactory.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.common.di 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.ViewModelProvider 5 | import javax.inject.Inject 6 | import javax.inject.Provider 7 | import javax.inject.Singleton 8 | 9 | @Singleton 10 | class ViewModelFactory 11 | @Inject constructor( 12 | private val creators: Map, @JvmSuppressWildcards Provider> 13 | ) : ViewModelProvider.Factory { 14 | 15 | override fun create(modelClass: Class): T { 16 | var creator: Provider? = creators[modelClass] 17 | if (creator == null) { 18 | for ((key, value) in creators) { 19 | if (modelClass.isAssignableFrom(key)) { 20 | creator = value 21 | break 22 | } 23 | } 24 | } 25 | requireNotNull(creator) { "unknown model class $modelClass" } 26 | try { 27 | @Suppress("UNCHECKED_CAST") 28 | return creator.get() as T 29 | } catch (e: Exception) { 30 | throw RuntimeException(e) 31 | } 32 | 33 | } 34 | } -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/common/Optional.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.common 2 | 3 | /* 4 | * Based on https://medium.com/@davidcorsalini/ive-expanded-the-optional-sealed-class-a-bit-c30d329e0f0c 5 | */ 6 | sealed class Optional { 7 | class Some(val element: T) : Optional() 8 | object None : Optional() 9 | 10 | fun element(): T? { 11 | return when (this) { 12 | is None -> null 13 | is Some -> element 14 | } 15 | } 16 | 17 | fun map(mapper: ((T) -> K?)): Optional = 18 | when (this) { 19 | is Some -> create(mapper.invoke(element)) 20 | is None -> None 21 | } 22 | 23 | override fun equals(other: Any?): Boolean { 24 | return this === other || ( 25 | other is Optional<*> && ( 26 | this is None && other is None 27 | || element()?.equals(other.element()) == true 28 | ) 29 | ) 30 | } 31 | 32 | override fun hashCode() = javaClass.hashCode() 33 | 34 | companion object { 35 | fun create(element: T?) = 36 | element?.let { Some(it) } ?: None 37 | } 38 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Envelop 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FBC02D 4 | #f9a825 5 | #000000 6 | #000000 7 | 8 | #FFFFFF 9 | #FFFFFF 10 | #DE000000 11 | #FFFFFF 12 | @color/colorOnPrimary 13 | #99000000 14 | #F5F5F5 15 | #FF0000 16 | 17 | #00000000 18 | #D9FBC02D 19 | #D9FFFFFF 20 | 21 | @color/primary 22 | #211F6D 23 | #87000000 24 | #18000000 25 | #FDDA8B 26 | 27 | 28 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/data/models/FileType.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data.models 2 | 3 | import androidx.annotation.DrawableRes 4 | import app.envelop.R 5 | 6 | enum class FileType 7 | constructor( 8 | @DrawableRes val iconRes: Int 9 | ) { 10 | Default(R.drawable.ic_type_file), 11 | Image(R.drawable.ic_type_image), 12 | Audio(R.drawable.ic_type_audio), 13 | Video(R.drawable.ic_type_video), 14 | Archive(R.drawable.ic_type_archive), 15 | PDF(R.drawable.ic_type_pdf); 16 | 17 | companion object { 18 | private val MAPPINGS 19 | get() = mapOf( 20 | Image to arrayOf("png", "gif", "jpg", "jpeg", "svg", "tif", "tiff", "ico"), 21 | Audio to arrayOf("wav", "aac", "mp3", "oga", "weba", "midi"), 22 | Video to arrayOf("avi", "mpeg", "mpg", "mp4", "ogv", "webm", "3gp", "mov"), 23 | Archive to arrayOf("zip", "rar", "tar", "gz", "7z", "bz", "bz2", "arc"), 24 | PDF to arrayOf("pdf") 25 | ) 26 | 27 | fun fromContentType(type: String?) = 28 | type?.let { 29 | MAPPINGS.entries.firstOrNull { 30 | it.value.contains(type) 31 | }?.key 32 | } ?: Default 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/src/main/res/layout/view_toolbar.xml: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 29 | -------------------------------------------------------------------------------- /app/src/main/res/menu/main.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 10 | 11 | 17 | 22 | 27 | 32 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_type_pdf.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/common/di/AppModule.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.common.di 2 | 3 | import android.content.ContentResolver 4 | import android.content.Context 5 | import android.content.res.Resources 6 | import app.envelop.App 7 | import com.google.gson.FieldNamingPolicy 8 | import com.google.gson.Gson 9 | import com.google.gson.GsonBuilder 10 | import dagger.Module 11 | import dagger.Provides 12 | import java.util.* 13 | import javax.inject.Singleton 14 | 15 | @Module 16 | class AppModule( 17 | private val app: App 18 | ) { 19 | 20 | @Provides 21 | fun app() = app 22 | 23 | @Provides 24 | fun context() = app as Context 25 | 26 | @Provides 27 | fun appMode() = app.mode 28 | 29 | @Provides 30 | fun resources(context: Context): Resources = context.resources 31 | 32 | @Provides 33 | fun contentResolver(context: Context): ContentResolver = context.contentResolver 34 | 35 | @Provides 36 | @Singleton 37 | fun gson(): Gson = 38 | GsonBuilder() 39 | .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) 40 | .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ") 41 | .create() 42 | 43 | @Provides 44 | fun locale(): Locale = Locale.getDefault() 45 | 46 | } 47 | -------------------------------------------------------------------------------- /app/src/test/java/app/envelop/data/repositories/RemoteRepositoryTest.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data.repositories 2 | 3 | import app.envelop.common.Operation 4 | import com.google.gson.Gson 5 | import com.nhaarman.mockitokotlin2.any 6 | import com.nhaarman.mockitokotlin2.mock 7 | import com.nhaarman.mockitokotlin2.whenever 8 | import kotlinx.coroutines.GlobalScope 9 | import kotlinx.coroutines.launch 10 | import kotlinx.coroutines.runBlocking 11 | import org.blockstack.android.sdk.BlockstackSession 12 | import org.hamcrest.CoreMatchers.instanceOf 13 | import org.hamcrest.MatcherAssert.assertThat 14 | import org.junit.Test 15 | import javax.inject.Provider 16 | 17 | class RemoteRepositoryTest { 18 | 19 | @Test 20 | fun exceptionHandling() = runBlocking { 21 | val blockStackSessionMock = mock() 22 | 23 | GlobalScope.launch { 24 | whenever(blockStackSessionMock.getFile(any(), any())).thenThrow(RuntimeException()) 25 | } 26 | 27 | val remoteRepo = RemoteRepository(Provider { blockStackSessionMock }, Gson()) 28 | val result = remoteRepo.getJson("index", Unit::class, true).blockingGet() 29 | 30 | assertThat(result, instanceOf(Operation.error(RuntimeException())::class.java)) 31 | } 32 | } 33 | 34 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx1536m 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app's APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Automatically convert third-party libraries to use AndroidX 19 | android.enableJetifier=true 20 | # Kotlin code style for this project: "official" or "obsolete": 21 | kotlin.code.style=official 22 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/ui/common/DocActions.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.ui.common 2 | 3 | import android.app.Activity 4 | import android.content.ClipData 5 | import android.content.ClipboardManager 6 | import android.content.Context 7 | import android.content.Intent 8 | import android.net.Uri 9 | import app.envelop.R 10 | import javax.inject.Inject 11 | 12 | class DocActions 13 | @Inject constructor( 14 | private val activity: Activity, 15 | private val messageManager: MessageManager 16 | ) { 17 | 18 | fun copyLink(link: String) { 19 | (activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager) 20 | .setPrimaryClip(ClipData.newPlainText(link, link)) 21 | messageManager.showNotice(R.string.doc_copy_link_done) 22 | } 23 | 24 | fun share(link: String) { 25 | activity.startActivity( 26 | Intent.createChooser( 27 | Intent(Intent.ACTION_SEND).also { 28 | it.type = "text/plain" 29 | it.putExtra(Intent.EXTRA_TEXT, link) 30 | }, 31 | activity.getString(R.string.doc_share_chooser) 32 | ) 33 | ) 34 | } 35 | 36 | fun open(link: String) { 37 | activity.startActivity( 38 | Intent(Intent.ACTION_VIEW).also { 39 | it.data = Uri.parse(link) 40 | } 41 | ) 42 | } 43 | 44 | } -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_faq.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 23 | 24 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/domain/LogoutService.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.domain 2 | 3 | import app.envelop.data.repositories.DocRepository 4 | import app.envelop.data.repositories.UploadRepository 5 | import app.envelop.data.repositories.UserRepository 6 | import io.reactivex.Completable 7 | import io.reactivex.Scheduler 8 | import io.reactivex.schedulers.Schedulers 9 | import org.blockstack.android.sdk.BlockstackSession 10 | import javax.inject.Inject 11 | import javax.inject.Named 12 | import javax.inject.Provider 13 | 14 | class LogoutService 15 | @Inject constructor( 16 | private val blockstackProvider: Provider, 17 | @Named("blockstack") private val blockstackScheduler: Scheduler, 18 | private val userRepository: UserRepository, 19 | private val docRepository: DocRepository, 20 | private val uploadRepository: UploadRepository 21 | ) { 22 | 23 | private val blockstack by lazy { 24 | blockstackProvider.get() 25 | } 26 | 27 | fun logout(): Completable = 28 | 29 | Completable 30 | .fromAction { 31 | uploadRepository.deleteAll() 32 | userRepository.setUser(null) 33 | } 34 | .andThen(docRepository.deleteAll()) 35 | .subscribeOn(Schedulers.io()) 36 | .observeOn(blockstackScheduler) 37 | .doOnComplete { blockstack.signUserOut() } 38 | 39 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/app/envelop/data/IndexDatabaseTest.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import app.envelop.data.models.Doc 5 | import app.envelop.data.models.Index 6 | import app.envelop.test.AppHelper.appComponent 7 | import org.junit.After 8 | import org.junit.Before 9 | import org.junit.Test 10 | import org.junit.runner.RunWith 11 | 12 | @RunWith(AndroidJUnit4::class) 13 | class IndexDatabaseTest { 14 | 15 | private val doc = Doc( 16 | id = "ABCDEF", 17 | name = "file.pdf", 18 | url = "UUID-UUID", 19 | size = 1_000, 20 | contentType = null, 21 | numParts = 1, 22 | encryptionSpec = null, 23 | username = "username" 24 | ) 25 | 26 | private lateinit var db: IndexDatabase 27 | 28 | @Before 29 | fun setUp() { 30 | db = appComponent.indexDatabase() 31 | } 32 | 33 | @After 34 | fun tearDown() { 35 | db.delete() 36 | } 37 | 38 | @Test 39 | fun getReactively() { 40 | val test = db.get().test() 41 | repeat(3) { 42 | db.save(Index()).blockingAwait() 43 | } 44 | test.awaitCount(4) 45 | } 46 | 47 | @Test 48 | fun saveAndGet() { 49 | db.save(Index(listOf(doc))).blockingAwait() 50 | db.get().test().awaitCount(1).assertValue { 51 | it.docs.size == 1 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/ui/common/SystemBars.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("DEPRECATION") 2 | package app.envelop.ui.common 3 | 4 | import android.content.Context 5 | import android.os.Build 6 | import android.view.View 7 | import android.view.Window 8 | import androidx.core.content.ContextCompat 9 | import app.envelop.R 10 | 11 | object SystemBars { 12 | 13 | private val fullScreenModeFlags: Int 14 | get() { 15 | var flags = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or 16 | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or 17 | View.SYSTEM_UI_FLAG_LAYOUT_STABLE 18 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { 19 | flags = flags or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR 20 | } 21 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 22 | flags = flags or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR 23 | } 24 | return flags 25 | } 26 | 27 | fun Window.setSystemBarsStyle(context: Context) { 28 | decorView.systemUiVisibility = fullScreenModeFlags 29 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 30 | navigationBarColor = ContextCompat.getColor(context, R.color.transparent) 31 | } 32 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { 33 | navigationBarDividerColor = ContextCompat.getColor(context, R.color.transparent) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/src/test/java/app/envelop/common/rx/RxSingleToOperationKtTest.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.common.rx 2 | 3 | 4 | import app.envelop.common.Operation 5 | import app.envelop.common.Optional 6 | import org.junit.Assert.assertEquals 7 | import org.junit.Test 8 | import java.lang.Exception 9 | import java.net.SocketTimeoutException 10 | 11 | class RxSingleToOperationKtTest { 12 | 13 | @Test 14 | fun rxSingleOperationSuccess() { 15 | val stream = rxSingleToOperation { 16 | Optional.None 17 | } 18 | 19 | val result = stream.blockingGet() 20 | 21 | assertEquals( 22 | Operation.success(Optional.None), 23 | result 24 | ) 25 | } 26 | 27 | @Test 28 | fun rxSingleOperationSuccessValue() { 29 | val stream = rxSingleToOperation { 30 | Optional.create(true) 31 | } 32 | 33 | val result = stream.blockingGet() 34 | 35 | assertEquals( 36 | Operation.success(Optional.Some(true)), 37 | result 38 | ) 39 | } 40 | 41 | @Test 42 | fun rxSingleOperationErrorExceptionSubscribed() { 43 | val exception = SocketTimeoutException() 44 | val stream = rxSingleToOperation { 45 | throw exception 46 | } 47 | 48 | val result = stream.blockingGet() 49 | 50 | assertEquals( 51 | Operation.error(exception), 52 | result 53 | ) 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/ui/BaseActivity.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.ui 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import androidx.appcompat.app.AppCompatActivity 6 | import app.envelop.App 7 | import app.envelop.common.di.ActivityComponent 8 | import app.envelop.common.di.ActivityModule 9 | import app.envelop.ui.common.ActivityResult 10 | import app.envelop.ui.common.Finish 11 | import app.envelop.ui.common.SystemBars.setSystemBarsStyle 12 | import app.envelop.ui.common.toActivityResult 13 | import io.reactivex.subjects.PublishSubject 14 | 15 | abstract class BaseActivity : AppCompatActivity() { 16 | 17 | val component: ActivityComponent by lazy { 18 | (application as App).component.plus(ActivityModule(this)) 19 | } 20 | 21 | protected val results = PublishSubject.create() 22 | 23 | override fun onCreate(savedInstanceState: Bundle?) { 24 | super.onCreate(savedInstanceState) 25 | window.setSystemBarsStyle(this) 26 | } 27 | 28 | @Suppress("DEPRECATION") 29 | override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { 30 | super.onActivityResult(requestCode, resultCode, data) 31 | results.onNext(ActivityResult(requestCode, resultCode, data)) 32 | } 33 | 34 | protected fun finish(finish: Finish) { 35 | setResult(finish.result.toActivityResult()) 36 | finish() 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/App.kt: -------------------------------------------------------------------------------- 1 | package app.envelop 2 | 3 | import android.app.Application 4 | import app.envelop.common.di.AppComponent 5 | import app.envelop.common.di.AppModule 6 | import app.envelop.common.di.DaggerAppComponent 7 | import app.envelop.domain.DeleteDocService 8 | import io.reactivex.disposables.CompositeDisposable 9 | import io.reactivex.rxkotlin.addTo 10 | import timber.log.Timber 11 | import javax.inject.Inject 12 | 13 | class App : Application() { 14 | 15 | @Inject 16 | lateinit var deleteDocService: DeleteDocService 17 | 18 | enum class Mode { Normal, Test } 19 | 20 | val mode: Mode by lazy { 21 | try { 22 | classLoader.loadClass("app.envelop.AppTest") 23 | Mode.Test 24 | } catch (e: Exception) { 25 | Mode.Normal 26 | } 27 | } 28 | 29 | val component: AppComponent by lazy { 30 | DaggerAppComponent.builder().appModule(AppModule(this)).build() 31 | } 32 | 33 | private val disposables = CompositeDisposable() 34 | 35 | override fun onCreate() { 36 | super.onCreate() 37 | component.inject(this) 38 | 39 | if (BuildConfig.DEBUG) { 40 | Timber.plant(Timber.DebugTree()) 41 | } 42 | 43 | deleteDocService 44 | .deletePending() 45 | .subscribe() 46 | .addTo(disposables) 47 | } 48 | 49 | override fun onTerminate() { 50 | super.onTerminate() 51 | disposables.clear() 52 | } 53 | } -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/data/security/KeyGenerator.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data.security 2 | 3 | import org.spongycastle.crypto.PBEParametersGenerator.PKCS5PasswordToUTF8Bytes 4 | import org.spongycastle.crypto.digests.SHA256Digest 5 | import org.spongycastle.crypto.generators.PKCS5S2ParametersGenerator 6 | import org.spongycastle.crypto.params.KeyParameter 7 | import timber.log.Timber 8 | import javax.inject.Inject 9 | 10 | class KeyGenerator 11 | @Inject constructor() { 12 | private val generator by lazy { 13 | PKCS5S2ParametersGenerator(SHA256Digest()) 14 | } 15 | 16 | @Synchronized 17 | fun generate(spec: EncryptionSpec, passcode: String): EncryptionKey { 18 | if (spec !is Pbkdf2AesEncryptionSpec) throw UnsupportedOperationException("Spec not supported") 19 | return generate(spec, passcode) 20 | } 21 | 22 | @Synchronized 23 | private fun generate(spec: Pbkdf2AesEncryptionSpec, passcode: String): EncryptionKey { 24 | val time = System.currentTimeMillis() 25 | val key = EncryptionKey( 26 | generator.let { 27 | it.init(PKCS5PasswordToUTF8Bytes(passcode.toCharArray()), spec.salt.toByteArray(), spec.keyIterations) 28 | (it.generateDerivedMacParameters(Pbkdf2AesEncryptionSpec.KEY_SIZE) as KeyParameter).key 29 | } 30 | ) 31 | Timber.d("Key generation time: %d", System.currentTimeMillis() - time) 32 | return key 33 | } 34 | 35 | } -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/ui/common/loading/LoadingManager.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.ui.common.loading 2 | 3 | import android.app.Activity 4 | import android.app.AlertDialog 5 | import android.view.WindowManager 6 | import app.envelop.R 7 | import app.envelop.ui.common.LoadingState 8 | import javax.inject.Inject 9 | 10 | class LoadingManager 11 | @Inject constructor( 12 | private val activity: Activity 13 | ) { 14 | 15 | private var dialog: AlertDialog? = null 16 | 17 | private fun show(messageRes: Int) { 18 | dialog?.let { hide() } 19 | dialog = AlertDialog.Builder(activity) 20 | .setView( 21 | LoadingView(activity).also { 22 | it.setMessage(messageRes) 23 | } 24 | ) 25 | .setCancelable(false) 26 | .show().also { dialog -> 27 | // Set the correct width for the dialog 28 | dialog.window?.let { window -> 29 | window.attributes = WindowManager.LayoutParams().also { 30 | it.copyFrom(window.attributes) 31 | it.width = activity.resources.getDimension(R.dimen.progress_dialog_width).toInt() 32 | } 33 | } 34 | } 35 | } 36 | 37 | fun hide() { 38 | dialog?.dismiss() 39 | dialog = null 40 | } 41 | 42 | fun apply(state: LoadingState, messageRes: Int) { 43 | when (state) { 44 | LoadingState.Loading -> show(messageRes) 45 | LoadingState.Idle -> hide() 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/common/di/ViewModelModule.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.common.di 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.ViewModelProvider 5 | import app.envelop.ui.share.ShareViewModel 6 | import app.envelop.ui.login.LoginViewModel 7 | import app.envelop.ui.main.DocMenuViewModel 8 | import app.envelop.ui.main.MainViewModel 9 | import app.envelop.ui.upload.UploadViewModel 10 | import dagger.Binds 11 | import dagger.Module 12 | import dagger.multibindings.IntoMap 13 | 14 | @Module 15 | abstract class ViewModelModule { 16 | 17 | @Binds 18 | abstract fun bindViewModelFactory(factory: ViewModelFactory): ViewModelProvider.Factory 19 | 20 | @Binds 21 | @IntoMap 22 | @ViewModelKey(MainViewModel::class) 23 | abstract fun mainViewModel(viewModel: MainViewModel): ViewModel 24 | 25 | @Binds 26 | @IntoMap 27 | @ViewModelKey(LoginViewModel::class) 28 | abstract fun loginViewModel(viewModel: LoginViewModel): ViewModel 29 | 30 | @Binds 31 | @IntoMap 32 | @ViewModelKey(UploadViewModel::class) 33 | abstract fun uploadViewModel(viewModel: UploadViewModel): ViewModel 34 | 35 | @Binds 36 | @IntoMap 37 | @ViewModelKey(ShareViewModel::class) 38 | abstract fun docUploadedViewModel(viewModel: ShareViewModel): ViewModel 39 | 40 | @Binds 41 | @IntoMap 42 | @ViewModelKey(DocMenuViewModel::class) 43 | abstract fun docMenuViewModel(viewModel: DocMenuViewModel): ViewModel 44 | 45 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/main_background.xml: -------------------------------------------------------------------------------- 1 | 9 | 16 | 23 | 30 | 37 | 44 | 45 | -------------------------------------------------------------------------------- /app/src/test/java/app/envelop/domain/GetDocServiceTest.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.domain 2 | 3 | import app.envelop.common.Optional 4 | import app.envelop.data.models.Upload 5 | import app.envelop.data.repositories.DocRepository 6 | import app.envelop.data.repositories.UploadRepository 7 | import app.envelop.test.DocFactory 8 | import com.nhaarman.mockitokotlin2.any 9 | import com.nhaarman.mockitokotlin2.mock 10 | import com.nhaarman.mockitokotlin2.whenever 11 | import io.reactivex.Flowable 12 | import io.reactivex.Observable 13 | import org.junit.Assert.assertEquals 14 | import org.junit.Test 15 | 16 | 17 | class GetDocServiceTest { 18 | val docRepositoryMock = mock() 19 | val uploadRepositoryMock = mock() 20 | 21 | val docService = GetDocService(docRepositoryMock, uploadRepositoryMock) 22 | 23 | @Test 24 | fun get() { 25 | val value = Observable.just(Optional.create(DocFactory.build())) 26 | whenever(docRepositoryMock.get(any())).thenReturn(value) 27 | 28 | val result = docService.get("") 29 | 30 | assertEquals( 31 | result, 32 | value 33 | ) 34 | } 35 | 36 | @Test 37 | fun getUpload() { 38 | val uploadObject = Upload() 39 | val value = Flowable.just(listOf(uploadObject)) 40 | whenever(uploadRepositoryMock.getByDocId(any())).thenReturn(value) 41 | 42 | val result = docService.getUpload("") 43 | .blockingFirst() 44 | .element() 45 | 46 | assertEquals( 47 | uploadObject, 48 | result 49 | ) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/ui/main/DocItemView.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.ui.main 2 | 3 | import android.content.Context 4 | import android.util.AttributeSet 5 | import android.widget.FrameLayout 6 | import app.envelop.R 7 | import app.envelop.data.models.Doc 8 | import app.envelop.ui.BaseActivity 9 | import com.airbnb.epoxy.CallbackProp 10 | import com.airbnb.epoxy.ModelProp 11 | import com.airbnb.epoxy.ModelView 12 | import kotlinx.android.synthetic.main.item_doc.view.* 13 | import javax.inject.Inject 14 | 15 | @ModelView(autoLayout = ModelView.Size.MATCH_WIDTH_WRAP_HEIGHT) 16 | class DocItemView 17 | @JvmOverloads 18 | constructor( 19 | context: Context, 20 | attrs: AttributeSet? = null, 21 | defStyleAttr: Int = 0 22 | ) : FrameLayout(context, attrs, defStyleAttr) { 23 | 24 | @Inject 25 | lateinit var getDate: FormatRelativeDate 26 | 27 | init { 28 | (context as BaseActivity).component.inject(this) 29 | inflate(context, R.layout.item_doc, this) 30 | } 31 | 32 | @ModelProp 33 | fun setItem(doc: Doc) { 34 | icon.contentDescription = doc.contentType 35 | icon.setImageResource(doc.fileType.iconRes) 36 | name.text = doc.name 37 | size.text = doc.humanSize 38 | uploadDate.text = if (doc.uploaded) { 39 | getDate.format(doc.createdAt) 40 | } else { 41 | resources.getString(R.string.uploading) 42 | } 43 | } 44 | 45 | @CallbackProp 46 | fun setClickListener(listener: (() -> Unit)?) { 47 | getChildAt(0).setOnClickListener { 48 | listener?.invoke() 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/ui/main/FormatRelativeDate.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.ui.main 2 | 3 | import android.content.res.Resources 4 | import app.envelop.R 5 | import java.text.SimpleDateFormat 6 | import java.util.* 7 | import javax.inject.Inject 8 | import kotlin.math.roundToInt 9 | 10 | class FormatRelativeDate 11 | @Inject constructor( 12 | private val resources: Resources, 13 | private val locale: Locale 14 | ) { 15 | 16 | private val shortDateFormat by lazy { SimpleDateFormat("MMM d", locale) } 17 | private val fullDateFormat by lazy { SimpleDateFormat("dd/MM/yyyy", locale) } 18 | 19 | fun format(date: Date): String { 20 | val now = Calendar.getInstance() 21 | val calendar = Calendar.getInstance().also { it.time = date } 22 | 23 | return if (calendar.get(Calendar.YEAR) == now.get(Calendar.YEAR)) { 24 | // Same year 25 | if (calendar.get(Calendar.DAY_OF_YEAR) == now.get(Calendar.DAY_OF_YEAR)) { 26 | // Same day 27 | val diff = now.timeInMillis - calendar.timeInMillis 28 | val min = (diff / 1000 / 60).toInt() 29 | val hour = (min / 60f).roundToInt() 30 | 31 | when { 32 | min < 1 -> resources.getString(R.string.now) 33 | min < 60 -> resources.getQuantityString(R.plurals.min, min, min) 34 | else -> resources.getQuantityString(R.plurals.hour, hour, hour) 35 | } 36 | } else { 37 | shortDateFormat.format(calendar.time) 38 | } 39 | } else { 40 | fullDateFormat.format(calendar.time) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/src/test/java/app/envelop/data/mappers/IndexSanitizerTest.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data.mappers 2 | 3 | import app.envelop.data.models.UnsanitizedIndex 4 | import app.envelop.data.models.User 5 | import app.envelop.domain.UserService 6 | import com.google.gson.JsonParser 7 | import com.nhaarman.mockitokotlin2.mock 8 | import com.nhaarman.mockitokotlin2.whenever 9 | import io.reactivex.Single 10 | import org.junit.Assert.assertEquals 11 | import org.junit.Before 12 | import org.junit.Test 13 | 14 | class IndexSanitizerTest { 15 | 16 | private val username = "johnsmith" 17 | private val userService = mock() 18 | private val subject = IndexSanitizer(userService) 19 | 20 | @Before 21 | fun setUp() { 22 | whenever(userService.userSingle()).thenReturn( 23 | Single.just( 24 | User(username = username, decentralizedId = "", hubUrl = "", profile = null) 25 | ) 26 | ) 27 | } 28 | 29 | @Test 30 | fun sanitize() { 31 | val result = subject.sanitize( 32 | UnsanitizedIndex( 33 | JsonParser.parseString( 34 | """ 35 | [{ 36 | "id": "ABCDEF", 37 | "name": "file.pdf", 38 | "url": "UUID-UUID", 39 | "size": 1000, 40 | "created_at": "2019-10-10T12:34:56", 41 | "content_type": "pdf", 42 | "version": 2, 43 | "num_parts": 2, 44 | "part_ivs":["abc","abc"] 45 | }] 46 | """ 47 | ).asJsonArray 48 | ) 49 | ).blockingGet() 50 | 51 | 52 | assertEquals(username, result.docs.first().username) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/common/rx/RxSingleToOperation.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.common.rx 2 | 3 | import app.envelop.common.Operation 4 | import io.reactivex.Single 5 | import kotlinx.coroutines.CoroutineScope 6 | import kotlinx.coroutines.Dispatchers 7 | import kotlinx.coroutines.Job 8 | import kotlinx.coroutines.rx2.rxSingle 9 | import java.lang.Exception 10 | import kotlin.coroutines.ContinuationInterceptor 11 | import kotlin.coroutines.CoroutineContext 12 | import kotlin.coroutines.EmptyCoroutineContext 13 | 14 | /** 15 | * Creates cold [single][Single] that will run a given [block] in a coroutine and emits its result wrapped in [Operation] 16 | * The Value is wrapped so it can handle Exceptions even when those are launch after unsubscribing avoiding of propagation 17 | * Every time the returned observable is subscribed, it starts a new coroutine. 18 | * Unsubscribing cancels running coroutine. 19 | * Coroutine context can be specified with [context] argument. 20 | * If the context does not have any dispatcher nor any other [ContinuationInterceptor], then [Dispatchers.Default] is used. 21 | * Method throws [IllegalArgumentException] if provided [context] contains a [Job] instance. 22 | */ 23 | public fun rxSingleToOperation( 24 | context: CoroutineContext = EmptyCoroutineContext, 25 | block: suspend CoroutineScope.() -> T 26 | ): Single> { 27 | return rxSingle(context) { 28 | try { 29 | Operation.success(block.invoke(this)) 30 | } catch (e:Exception) { 31 | return@rxSingle Operation.error(e) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/ui/donate/DonateActivity.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.ui.donate 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.net.Uri 6 | import android.os.Bundle 7 | import app.envelop.R 8 | import app.envelop.ui.BaseActivity 9 | import app.envelop.ui.common.Insets.addSystemWindowInsetToPadding 10 | import kotlinx.android.synthetic.main.activity_donate.* 11 | import kotlinx.android.synthetic.main.shared_appbar.* 12 | 13 | class DonateActivity : BaseActivity() { 14 | 15 | override fun onCreate(savedInstanceState: Bundle?) { 16 | super.onCreate(savedInstanceState) 17 | setContentView(R.layout.activity_donate) 18 | 19 | toolbar.addSystemWindowInsetToPadding(top = true) 20 | toolbar.enableNavigation() 21 | 22 | donateCrypto.setOnClickListener { openDonateCrypto() } 23 | donateStacks.setOnClickListener { openDonateStacksLink() } 24 | donatePaypal.setOnClickListener { openDonatePaypalLink() } 25 | } 26 | 27 | private fun openDonateCrypto() { 28 | startActivity( 29 | Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.donate_crypto_link))) 30 | ) 31 | } 32 | 33 | private fun openDonateStacksLink() { 34 | startActivity( 35 | Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.donate_stack_link))) 36 | ) 37 | } 38 | 39 | private fun openDonatePaypalLink() { 40 | startActivity( 41 | Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.donate_paypal_link))) 42 | ) 43 | } 44 | 45 | companion object { 46 | fun getIntent(context: Context) = Intent(context, DonateActivity::class.java) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/src/test/java/app/envelop/data/InnerJsonObjectTest.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data 2 | 3 | import com.google.gson.JsonObject 4 | import org.junit.Assert.* 5 | import org.junit.Test 6 | import java.text.SimpleDateFormat 7 | import java.util.* 8 | import kotlin.time.days 9 | 10 | class InnerJsonObjectTest { 11 | 12 | class TestJsonKey( 13 | override val value: String 14 | ) : JsonKey 15 | 16 | @Test 17 | fun listString() { 18 | val key = TestJsonKey("values") 19 | val value = listOf("AAA", "BBB", "CCC") 20 | val subject = InnerJsonObject(JsonObject()) 21 | 22 | subject.set(key, value) 23 | assertEquals(value, subject.getListString(key)) 24 | 25 | subject.set(key, null as List?) 26 | assertNull(subject.optListString(key)) 27 | } 28 | 29 | @Test 30 | fun emptyCreatedDateFallback() { 31 | val key = TestJsonKey("created_at") 32 | val subject = InnerJsonObject(JsonObject()) 33 | assertNotNull(subject.getDate(key)) 34 | } 35 | 36 | @Test 37 | fun getDate() { 38 | val key = TestJsonKey("created_at") 39 | val value = "2020-09-08T10:30:00" 40 | val subject = InnerJsonObject(JsonObject()) 41 | 42 | subject.set(key, value) 43 | 44 | val result = subject.getDate(key) 45 | val resultCalendar = Calendar.getInstance().also { it.time = result } 46 | 47 | assertEquals(8, resultCalendar.get(Calendar.DAY_OF_MONTH)) 48 | assertEquals(8, resultCalendar.get(Calendar.MONTH)) 49 | assertEquals(2020, resultCalendar.get(Calendar.YEAR)) 50 | assertEquals(10, resultCalendar.get(Calendar.HOUR)) 51 | assertEquals(30, resultCalendar.get(Calendar.MINUTE)) 52 | assertEquals(0, resultCalendar.get(Calendar.SECOND)) 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_bitcoin.xml: -------------------------------------------------------------------------------- 1 | 8 | 16 | 17 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_blockstack.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Envelop](https://envelop.app/images/logo.svg) 2 | 3 | # Android app 🤖 4 | 5 | DISCLAIMER: This project is no longer being maintained 6 | 7 | ![Tests status](https://github.com/envelop-app/envelop-android/workflows/test/badge.svg) 8 | ![Lint status](https://github.com/envelop-app/envelop-android/workflows/lint/badge.svg) 9 | [![codecov](https://codecov.io/gh/envelop-app/envelop-android/branch/master/graph/badge.svg)](https://codecov.io/gh/envelop-app/envelop-android) 10 | 11 | ![Envelop - Share and upload private files easily](https://envelop.app/images/og-image.png) 12 | 13 | Share private files easily, without losing their ownership. 14 | 15 | With [Blockstack](https://blockstack.org), you decide where your files are stored. 16 | Use the default storage (unlimited), or setup your own storage. 17 | 18 | ![Product Hunt](https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=161086&theme=light) 19 | 20 | ## Features 21 | 22 | - Upload a file (from the file browser or from other apps) 23 | - Get a short URL to share, so other can download your file 24 | - Delete files you no longer need or want to share 25 | 26 | ## Contribute 27 | 28 | ### Setup 29 | 30 | 1. Open the project on Android Studio 31 | 2. Let gradle sync 32 | 3. Press Run to install it on a device or emulator 33 | 34 | ### Guide 35 | 36 | 1. Fork it `https://github.com/envelop-app/envelop-android` 37 | 2. Create your feature branch `git checkout -b my-new-feature` 38 | 3. Commit your changes `git commit -am 'Add some feature'` 39 | 4. Push to the branch `git push origin my-new-feature` 40 | 5. Create a new Pull Request 41 | 42 | ## License 43 | 44 | Envelop is under MIT License. 45 | 46 | --- 47 | 48 | Created by [bloco.io](https://www.bloco.io) and [@joaodiogocosta](https://twitter.com/joaodiogocosta). 49 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/ui/common/Events.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.ui.common 2 | 3 | import android.app.Activity 4 | import android.view.View 5 | import androidx.core.view.isVisible 6 | import io.reactivex.subjects.BehaviorSubject 7 | import io.reactivex.subjects.PublishSubject 8 | 9 | object Click 10 | 11 | fun PublishSubject.click() = onNext(Click) 12 | 13 | object Refresh 14 | 15 | fun PublishSubject.refresh() = onNext(Refresh) 16 | 17 | data class Finish( 18 | val result: Result = Result.Canceled 19 | ) { 20 | enum class Result { 21 | Canceled, Ok 22 | } 23 | } 24 | 25 | fun Finish.Result.toActivityResult() = 26 | when (this) { 27 | Finish.Result.Canceled -> Activity.RESULT_CANCELED 28 | Finish.Result.Ok -> Activity.RESULT_OK 29 | } 30 | 31 | fun PublishSubject.finish(result: Finish.Result = Finish.Result.Canceled) = 32 | onNext(Finish(result)) 33 | 34 | fun BehaviorSubject.finish(result: Finish.Result = Finish.Result.Canceled) = 35 | onNext(Finish(result)) 36 | 37 | object Open 38 | 39 | fun PublishSubject.open() = onNext(Open) 40 | 41 | sealed class LoadingState { 42 | object Loading : LoadingState() 43 | object Idle : LoadingState() 44 | } 45 | 46 | fun BehaviorSubject.loading() = onNext(LoadingState.Loading) 47 | fun BehaviorSubject.idle() = onNext(LoadingState.Idle) 48 | 49 | sealed class VisibleState { 50 | object Visible : VisibleState() 51 | object Hidden : VisibleState() 52 | } 53 | 54 | fun BehaviorSubject.next(isVisible: Boolean) = 55 | onNext(if (isVisible) VisibleState.Visible else VisibleState.Hidden) 56 | 57 | fun View.setVisible(state: VisibleState) { 58 | isVisible = state == VisibleState.Visible 59 | } 60 | 61 | fun BehaviorSubject.next() = onNext(Unit) 62 | fun PublishSubject.next() = onNext(Unit) 63 | -------------------------------------------------------------------------------- /app/jacoco.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'jacoco' 2 | 3 | jacoco { 4 | toolVersion '0.8.5' 5 | } 6 | 7 | task jacocoAndroidTestReport(type: JacocoReport) { 8 | 9 | sourceDirectories.from = files(["$project.projectDir/src/main/java"]) 10 | classDirectories.from = files([ 11 | fileTree( 12 | dir: project.buildDir, 13 | includes: [ 14 | "intermediates/javac/debug/**", 15 | "tmp/kotlin-classes/debug/**" 16 | ], 17 | excludes: [ 18 | 'android/**/*.*', 19 | '**/R.class', 20 | '**/R$*.class', 21 | '**/BuildConfig.*', 22 | '**/Manifest*.*', 23 | '**/*Test*.*', 24 | // Dagger 25 | '**/*Module.*', 26 | '**/*Module*Factory.*', 27 | '**/*Module$Companion.*', 28 | '**/*Dagger*.*', 29 | '**/*MembersInjector*.*', 30 | '**/*_Provide*Factory*.*', 31 | '**/*_Factory.*', 32 | // Epoxy 33 | '**/*ViewModel_.*', 34 | '**/EpoxyModelKotlinExtensionsKt.*', 35 | ] 36 | ) 37 | ]) 38 | 39 | executionData.from = fileTree(dir: project.buildDir, includes: [ 40 | 'jacoco/testDebugUnitTest.exec', 41 | 'outputs/code_coverage/debugAndroidTest/connected/**/*.ec' 42 | ]) 43 | 44 | if (project.hasProperty('codeCoverageDataLocation')) { 45 | executionData.from += fileTree(dir: codeCoverageDataLocation, includes: ['**/*.ec']) 46 | } 47 | 48 | reports { 49 | html.enabled true 50 | html.destination file("${buildDir}/reports/coverage") 51 | xml.enabled true 52 | xml.destination file("${buildDir}/reports/coverage.xml") 53 | csv.enabled false 54 | } 55 | 56 | doLast { 57 | println "Wrote HTML coverage report to ${reports.html.destination}/index.html" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/src/androidTest/java/app/envelop/ui/donate/DonateActivityTest.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.ui.donate 2 | 3 | import android.content.Intent 4 | import android.net.Uri 5 | import androidx.test.espresso.Espresso.onView 6 | import androidx.test.espresso.action.ViewActions.click 7 | import androidx.test.espresso.intent.Intents.intended 8 | import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction 9 | import androidx.test.espresso.intent.matcher.IntentMatchers.hasData 10 | import androidx.test.espresso.intent.rule.IntentsTestRule 11 | import androidx.test.espresso.matcher.ViewMatchers.withId 12 | import androidx.test.ext.junit.runners.AndroidJUnit4 13 | import app.envelop.R 14 | import app.envelop.test.AppHelper 15 | import org.hamcrest.CoreMatchers.allOf 16 | import org.junit.Rule 17 | import org.junit.Test 18 | import org.junit.runner.RunWith 19 | 20 | @RunWith(AndroidJUnit4::class) 21 | class DonateActivityTest { 22 | 23 | @get:Rule 24 | val intentTestRule = IntentsTestRule(DonateActivity::class.java) 25 | private val context = AppHelper.context 26 | 27 | @Test 28 | fun donateCryptoTest() { 29 | val cryptoUri = Uri.parse(context.getString(R.string.donate_crypto_link)) 30 | onView(withId(R.id.donateCrypto)).perform(click()) 31 | intended(allOf(hasAction(Intent.ACTION_VIEW), hasData(cryptoUri))) 32 | } 33 | 34 | @Test 35 | fun donateStacksTest() { 36 | val stacksUri = Uri.parse(context.getString(R.string.donate_stack_link)) 37 | onView(withId(R.id.donateStacks)).perform(click()) 38 | intended(allOf(hasAction(Intent.ACTION_VIEW), hasData(stacksUri))) 39 | } 40 | 41 | @Test 42 | fun donatePaypalTest() { 43 | val stacksUri = Uri.parse(context.getString(R.string.donate_paypal_link)) 44 | onView(withId(R.id.donatePaypal)).perform(click()) 45 | intended(allOf(hasAction(Intent.ACTION_VIEW), hasData(stacksUri))) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/src/test/java/app/envelop/ui/login/LoginViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.ui.login 2 | 3 | import app.envelop.common.Operation 4 | import app.envelop.domain.LoginService 5 | import app.envelop.ui.common.Finish 6 | import app.envelop.ui.common.LoadingState 7 | import com.nhaarman.mockitokotlin2.any 8 | import com.nhaarman.mockitokotlin2.doReturn 9 | import com.nhaarman.mockitokotlin2.mock 10 | import com.nhaarman.mockitokotlin2.whenever 11 | import io.reactivex.Single 12 | import org.junit.Assert.assertEquals 13 | import org.junit.Test 14 | 15 | 16 | class LoginViewModelTest { 17 | 18 | val loginService = mock() 19 | val loginViewModel = LoginViewModel().also { 20 | it.loginService = loginService 21 | } 22 | 23 | @Test 24 | fun isLoggingInLoadingState() { 25 | 26 | val valueStream = loginViewModel.isLoggingIn().test() 27 | loginViewModel.loginClick() 28 | 29 | assertEquals( 30 | valueStream.values()[valueStream.values().size - 2], 31 | LoadingState.Loading 32 | ) 33 | 34 | assertEquals( 35 | valueStream.values().last(), 36 | LoadingState.Idle 37 | ) 38 | } 39 | 40 | @Test 41 | fun errorsAuth() { 42 | whenever(loginService.finishLogin(any())).doReturn( 43 | Single.just(Operation.error(LoginService.UsernameMissing(""))) 44 | ) 45 | 46 | val errorStream = loginViewModel.errors().test() 47 | loginViewModel.authDataReceived("") 48 | 49 | errorStream.assertValue(LoginViewModel.Error.UsernameMissing) 50 | } 51 | 52 | @Test 53 | fun finishToMain() { 54 | whenever(loginService.finishLogin(any())).doReturn(Single.just(Operation())) 55 | 56 | val finishStream = loginViewModel.finishToMain().test() 57 | loginViewModel.authDataReceived("") 58 | 59 | val finishResult = finishStream.values().first() 60 | assertEquals(finishResult.result, Finish.Result.Ok) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /app/src/main/res/layout/partial_banner.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 13 | 14 | 24 | 25 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/ui/common/Insets.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("DEPRECATION") 2 | package app.envelop.ui.common 3 | 4 | import android.view.View 5 | import android.view.ViewGroup 6 | import androidx.core.view.* 7 | 8 | object Insets { 9 | fun View.addSystemWindowInsetToMargin( 10 | left: Boolean = false, 11 | top: Boolean = false, 12 | right: Boolean = false, 13 | bottom: Boolean = false 14 | ) { 15 | val (initialLeft, initialTop, initialRight, initialBottom) = 16 | listOf(marginLeft, marginTop, marginRight, marginBottom) 17 | 18 | ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets -> 19 | view.updateLayoutParams { 20 | (this as? ViewGroup.MarginLayoutParams)?.let { 21 | updateMargins( 22 | left = initialLeft + (if (left) insets.systemWindowInsetLeft else 0), 23 | top = initialTop + (if (top) insets.systemWindowInsetTop else 0), 24 | right = initialRight + (if (right) insets.systemWindowInsetRight else 0), 25 | bottom = initialBottom + (if (bottom) insets.systemWindowInsetBottom else 0) 26 | ) 27 | } 28 | } 29 | insets 30 | } 31 | } 32 | 33 | fun View.addSystemWindowInsetToPadding( 34 | left: Boolean = false, 35 | top: Boolean = false, 36 | right: Boolean = false, 37 | bottom: Boolean = false 38 | ) { 39 | val (initialLeft, initialTop, initialRight, initialBottom) = 40 | listOf(paddingLeft, paddingTop, paddingRight, paddingBottom) 41 | 42 | ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets -> 43 | view.updatePadding( 44 | left = initialLeft + (if (left) insets.systemWindowInsetLeft else 0), 45 | top = initialTop + (if (top) insets.systemWindowInsetTop else 0), 46 | right = initialRight + (if (right) insets.systemWindowInsetRight else 0), 47 | bottom = initialBottom + (if (bottom) insets.systemWindowInsetBottom else 0) 48 | ) 49 | insets 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/data/BlockstackModule.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data 2 | 3 | import android.content.Context 4 | import android.content.res.Resources 5 | import android.os.Handler 6 | import android.os.HandlerThread 7 | import androidx.preference.PreferenceManager 8 | import app.envelop.R 9 | import dagger.Module 10 | import dagger.Provides 11 | import io.reactivex.Scheduler 12 | import io.reactivex.schedulers.Schedulers 13 | import org.blockstack.android.sdk.* 14 | import org.blockstack.android.sdk.model.BlockstackConfig 15 | import java.net.URI 16 | import javax.inject.Named 17 | import javax.inject.Singleton 18 | 19 | @Module 20 | class BlockstackModule { 21 | 22 | @Provides 23 | fun blockstackConfig(resources: Resources) = 24 | BlockstackConfig( 25 | URI(resources.getString(R.string.blockstack_app_url)), 26 | "/redirect", 27 | "/manifest.json", 28 | arrayOf(BaseScope.StoreWrite.scope, BaseScope.PublishData.scope) 29 | ) 30 | 31 | @Provides 32 | @Singleton 33 | fun blockstackSessionStore(context: Context): ISessionStore = 34 | SessionStore(PreferenceManager.getDefaultSharedPreferences(context)) 35 | 36 | @Provides 37 | @Singleton 38 | @Named("blockstack") 39 | fun blockstackScheduler(): Scheduler { 40 | val handlerThread = HandlerThread("BlockstackService").apply { start() } 41 | val handler = Handler(handlerThread.looper) 42 | return Schedulers.from { 43 | handler.post(it) 44 | } 45 | } 46 | 47 | @Provides 48 | @Singleton 49 | fun blockstackSession(config: BlockstackConfig, sessionStore: ISessionStore) = 50 | BlockstackSession(sessionStore, config) 51 | 52 | @Provides 53 | @Singleton 54 | fun blockstackSignIn(config: BlockstackConfig, sessionStore: ISessionStore) = 55 | BlockstackSignIn(sessionStore, config) 56 | 57 | @Provides 58 | @Singleton 59 | fun blockstackConnect(config: BlockstackConfig, sessionStore: ISessionStore) = 60 | BlockstackConnect.config(config, sessionStore, allowSignUp = false) 61 | } 62 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/data/security/EncryptionSpec.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data.security 2 | 3 | import app.envelop.data.InnerJsonObject 4 | import app.envelop.data.JsonKey 5 | 6 | interface EncryptionSpec { 7 | fun toJson(baseJson: InnerJsonObject = InnerJsonObject()): InnerJsonObject 8 | } 9 | 10 | data class Pbkdf2AesEncryptionSpec( 11 | val keyIterations: Int = DEFAULT_KEY_ITERATIONS, 12 | val salt: String, 13 | val iv: String 14 | ) : EncryptionSpec { 15 | 16 | override fun toJson(baseJson: InnerJsonObject) = 17 | baseJson.copy().also { json -> 18 | json.set( 19 | Key.Type, 20 | TYPE 21 | ) 22 | json.set( 23 | Key.Params, 24 | json.optObjectOrEmpty(Key.Params).also { paramsJson -> 25 | paramsJson.set(Key.KeyIterations, keyIterations) 26 | paramsJson.set(Key.Salt, salt) 27 | paramsJson.set(Key.IV, iv) 28 | } 29 | ) 30 | } 31 | 32 | companion object { 33 | private const val TYPE = "PBKDF2/AES" 34 | const val DEFAULT_KEY_ITERATIONS = 10_000 35 | const val KEY_SIZE = 256 // bits 36 | const val IV_SIZE = 16 // bytes 37 | 38 | fun fromJson(json: InnerJsonObject) = 39 | if (json.optString(Key.Type) == TYPE) { 40 | json.optObject(Key.Params) 41 | ?.let { paramsJson -> 42 | val keyIterations = paramsJson.optInt(Key.KeyIterations) 43 | val salt = paramsJson.optString(Key.Salt) 44 | val iv = paramsJson.optString(Key.IV) 45 | 46 | if (keyIterations != null && salt != null && iv != null) { 47 | Pbkdf2AesEncryptionSpec(keyIterations, salt, iv) 48 | } else null 49 | } 50 | } else null 51 | } 52 | 53 | enum class Key(override val value: String) : JsonKey { 54 | Type("type"), Params("params"), 55 | KeyIterations("key_iterations"), Salt("salt"), IV("iv") 56 | } 57 | } 58 | 59 | object EncryptionSpecProvider { 60 | 61 | fun getSpec(json: InnerJsonObject?) = 62 | json?.let { 63 | Pbkdf2AesEncryptionSpec.fromJson(json) 64 | } 65 | 66 | } -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/domain/LoginService.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.domain 2 | 3 | import app.envelop.common.Operation 4 | import app.envelop.common.di.PerActivity 5 | import app.envelop.common.mapIfSuccessful 6 | import app.envelop.data.BlockstackLogin 7 | import app.envelop.data.models.Profile 8 | import app.envelop.data.models.User 9 | import app.envelop.data.repositories.UserRepository 10 | import io.reactivex.Single 11 | import org.blockstack.android.sdk.model.UserData 12 | import javax.inject.Inject 13 | 14 | @PerActivity 15 | class LoginService 16 | @Inject constructor( 17 | private val blockstackLogin: BlockstackLogin, 18 | private val userRepository: UserRepository 19 | ) { 20 | 21 | 22 | fun login() = blockstackLogin.login() 23 | 24 | 25 | fun finishLogin(response: String?): Single> { 26 | if (response == null) { 27 | return Single.just(Operation.error(Error("Invalid response"))) 28 | } 29 | return blockstackLogin.handlePendingSignIn(response) 30 | .mapIfSuccessful { userData -> 31 | if (userData.containsValidUsername()) { 32 | userRepository.setUser(userData.toUser()) 33 | } else { 34 | throw UsernameMissing("Invalid Username") 35 | } 36 | }.onErrorReturn { Operation.error(it) } 37 | } 38 | 39 | private fun UserData?.containsValidUsername() = 40 | this?.json?.optString("username")?.let { username -> 41 | (!username.isBlank() && username != "null") 42 | } ?: false 43 | 44 | private fun UserData.toUser() = 45 | User( 46 | username = json.getString("username"), 47 | decentralizedId = decentralizedID, 48 | hubUrl = hubUrl, 49 | profile = profile?.let { 50 | Profile( 51 | name = it.name, 52 | description = it.description, 53 | avatarImage = it.avatarImage, 54 | email = it.email, 55 | isPerson = it.isPerson() 56 | ) 57 | } 58 | ) 59 | 60 | class Error(message: String?) : Exception(message) 61 | class UsernameMissing(message: String?) : Exception(message) 62 | 63 | } 64 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/ui/login/LoginViewModel.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.ui.login 2 | 3 | import app.envelop.domain.LoginService 4 | import app.envelop.domain.LoginService.UsernameMissing 5 | import app.envelop.ui.BaseViewModel 6 | import app.envelop.ui.common.* 7 | import io.reactivex.rxkotlin.addTo 8 | import io.reactivex.subjects.BehaviorSubject 9 | import io.reactivex.subjects.PublishSubject 10 | import timber.log.Timber 11 | import javax.inject.Inject 12 | 13 | class LoginViewModel 14 | @Inject constructor() : BaseViewModel() { 15 | 16 | lateinit var loginService: LoginService // this must be provided by the activity 17 | 18 | private val loginClicks = PublishSubject.create() 19 | private val authDataReceived = PublishSubject.create() 20 | 21 | private val isLoggingIn = BehaviorSubject.create() 22 | private val errors = PublishSubject.create() 23 | private val finishToMain = PublishSubject.create() 24 | 25 | init { 26 | 27 | loginClicks 28 | .doOnNext { isLoggingIn.loading() } 29 | .subscribe { 30 | loginService.login() 31 | isLoggingIn.idle() 32 | } 33 | .addTo(disposables) 34 | 35 | authDataReceived 36 | .doOnNext { isLoggingIn.loading() } 37 | .flatMapSingle { loginService.finishLogin(it) } 38 | .subscribe { 39 | isLoggingIn.idle() 40 | if (it.isSuccessful) { 41 | finishToMain.finish(Finish.Result.Ok) 42 | } else { 43 | Timber.w(it.throwable()) 44 | errors.onNext(if (it.throwable() is UsernameMissing) Error.UsernameMissing else Error.LoginError) 45 | } 46 | } 47 | .addTo(disposables) 48 | 49 | } 50 | 51 | // Inputs 52 | 53 | fun loginClick() = loginClicks.click() 54 | fun authDataReceived(value: String?) = authDataReceived.onNext(value ?: "") 55 | 56 | // Outputs 57 | 58 | fun isLoggingIn() = isLoggingIn.hide()!! 59 | fun errors() = errors.hide()!! 60 | fun finishToMain() = finishToMain.hide()!! 61 | 62 | enum class Error { 63 | LoginError, UsernameMissing 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/domain/PreUploadService.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.domain 2 | 3 | import android.content.Context 4 | import android.net.Uri 5 | import app.envelop.background.UploadBackgroundService 6 | import app.envelop.common.* 7 | import app.envelop.data.models.Doc 8 | import app.envelop.data.models.Upload 9 | import app.envelop.data.repositories.DocRepository 10 | import app.envelop.data.repositories.UploadRepository 11 | import io.reactivex.Completable 12 | import io.reactivex.Single 13 | import javax.inject.Inject 14 | 15 | class PreUploadService 16 | @Inject constructor( 17 | private val context: Context, 18 | private val docBuilder: DocBuilder, 19 | private val docRepository: DocRepository, 20 | private val uploadRepository: UploadRepository, 21 | private val fileHandler: FileHandler, 22 | private val updateDocRemotely: UpdateDocRemotely 23 | ) { 24 | 25 | fun prepareUpload(fileUri: Uri): Single> = 26 | docBuilder 27 | .build(fileUri) 28 | .flatMapIfSuccessful { docRepository.save(it).toSingleDefault(Operation.success(it)) } 29 | .flatMapIfSuccessful { updateDocRemotely.update(it) } 30 | .flatMapIfSuccessful { doc -> 31 | fileHandler 32 | .saveFileLocally(fileUri) 33 | .doIfSuccessful { localFileUri -> uploadRepository.save(doc.toUpload(localFileUri)) } 34 | .mapIfSuccessful { doc } 35 | } 36 | .doIfSuccessful { startBackgroundService() } 37 | 38 | fun startBackgroundIfNeeded(): Completable = 39 | uploadRepository 40 | .count() 41 | .take(1) 42 | .filter { it > 0 } 43 | .doOnNext { startBackgroundService() } 44 | .ignoreElements() 45 | 46 | private fun startBackgroundService() { 47 | UploadBackgroundService.getIntent(context).also { intent -> 48 | context.startService(intent) 49 | } 50 | } 51 | 52 | private fun Doc.toUpload(fileUri: Uri) = 53 | Upload( 54 | docId = id, 55 | fileUriPath = fileUri.toString(), 56 | partSize = EnvelopSpec.FILE_PART_SIZE 57 | ) 58 | 59 | class Error(message: String?) : Exception(message) 60 | 61 | } 62 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/data/repositories/DocRepository.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data.repositories 2 | 3 | import app.envelop.common.Optional 4 | import app.envelop.data.IndexDatabase 5 | import app.envelop.data.models.Doc 6 | import app.envelop.data.models.Index 7 | import io.reactivex.Completable 8 | import io.reactivex.Observable 9 | import io.reactivex.Single 10 | import javax.inject.Inject 11 | import javax.inject.Singleton 12 | 13 | @Singleton 14 | class DocRepository 15 | @Inject constructor( 16 | private val indexDb: IndexDatabase, 17 | private val uploadRepository: UploadRepository 18 | ) { 19 | 20 | fun list(): Observable> = 21 | loadDocs() 22 | 23 | fun listVisible(): Observable> = 24 | loadDocs().map { list -> list.filter { !it.deleted } } 25 | 26 | fun listDeleted(): Observable> = 27 | loadDocs().map { list -> list.filter { it.deleted } } 28 | 29 | fun countDeleted(): Observable = 30 | loadDocs().map { list -> list.count { it.deleted } } 31 | 32 | fun get(id: String): Observable> = 33 | loadDocs().map { list -> Optional.create(list.firstOrNull { it.id == id }) } 34 | 35 | fun save(doc: Doc) = 36 | loadDocs() 37 | .take(1) 38 | .singleOrError() 39 | .map { list -> list.filter { it.id != doc.id } + doc } 40 | .saveDocs() 41 | 42 | fun delete(doc: Doc): Completable = 43 | deleteDoc(doc) 44 | .doOnComplete { uploadRepository.deleteByDocId(doc.id) } 45 | 46 | private fun deleteDoc(doc: Doc) = 47 | loadDocs() 48 | .take(1) 49 | .singleOrError() 50 | .map { list -> list.filter { it.id != doc.id } } 51 | .saveDocs() 52 | 53 | fun deleteAll() = 54 | indexDb.delete() 55 | 56 | fun replace(docs: List) = 57 | Single 58 | .fromCallable { docs } 59 | .saveDocs() 60 | 61 | private fun loadDocs() = 62 | indexDb 63 | .get() 64 | .map { it.docs } 65 | 66 | private fun Single>.saveDocs() = 67 | flatMapCompletable { list -> 68 | indexDb.save( 69 | Index( 70 | list.sortedBy { it.createdAt }.reversed() 71 | ) 72 | ) 73 | } 74 | 75 | } -------------------------------------------------------------------------------- /app/src/test/java/app/envelop/ui/main/DocMenuViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.ui.main 2 | 3 | import app.envelop.common.Optional 4 | import app.envelop.domain.DeleteDocService 5 | import app.envelop.domain.DocLinkBuilder 6 | import app.envelop.domain.GetDocService 7 | import app.envelop.test.DocFactory 8 | import com.nhaarman.mockitokotlin2.any 9 | import com.nhaarman.mockitokotlin2.mock 10 | import com.nhaarman.mockitokotlin2.whenever 11 | import io.reactivex.Observable 12 | import org.junit.Before 13 | import org.junit.Test 14 | 15 | class DocMenuViewModelTest { 16 | 17 | private val getDocService = mock() 18 | private val deleteDocService = mock() 19 | private val docLinkBuilder = mock() 20 | 21 | private lateinit var viewModel: DocMenuViewModel 22 | 23 | @Before 24 | fun setUp() { 25 | viewModel = DocMenuViewModel(getDocService, deleteDocService, docLinkBuilder) 26 | whenever(docLinkBuilder.build(any())).thenReturn("") 27 | whenever(getDocService.get(any())).thenReturn(Observable.just(Optional.Some(DocFactory.build()))) 28 | } 29 | 30 | @Test 31 | fun doc() { 32 | val doc = DocFactory.build() 33 | whenever(getDocService.get(any())).thenReturn(Observable.just(Optional.Some(doc))) 34 | 35 | viewModel.docIdReceived("DOC") 36 | 37 | viewModel.doc().test().assertValue(doc) 38 | } 39 | 40 | @Test 41 | fun docDeleted() { 42 | val doc = DocFactory.build().copy(deleted = true) 43 | whenever(getDocService.get(any())).thenReturn(Observable.just(Optional.Some(doc))) 44 | 45 | viewModel.docIdReceived("DOC") 46 | 47 | viewModel.finish().test().awaitCount(1) 48 | } 49 | 50 | @Test 51 | fun docDoesNotExist() { 52 | whenever(getDocService.get(any())).thenReturn(Observable.just(Optional.None)) 53 | 54 | viewModel.docIdReceived("DOC") 55 | 56 | viewModel.finish().test().awaitCount(1) 57 | } 58 | 59 | @Test 60 | fun docLink() { 61 | val link = "https://envl.app/user/abced" 62 | whenever(docLinkBuilder.build(any())).thenReturn(link) 63 | 64 | viewModel.docIdReceived("DOC") 65 | 66 | viewModel.link().test().assertValue(link) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/domain/UpdateDocRemotely.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.domain 2 | 3 | import app.envelop.common.Operation 4 | import app.envelop.common.flatMapIfSuccessful 5 | import app.envelop.common.mapIfSuccessful 6 | import app.envelop.data.models.Doc 7 | import app.envelop.data.repositories.RemoteRepository 8 | import app.envelop.data.security.EncryptedDataWrapper 9 | import app.envelop.data.security.Encrypter 10 | import app.envelop.data.security.EncrypterProvider 11 | import com.google.gson.Gson 12 | import io.reactivex.Single 13 | import io.reactivex.schedulers.Schedulers 14 | import javax.inject.Inject 15 | 16 | class UpdateDocRemotely 17 | @Inject constructor( 18 | private val indexService: IndexService, 19 | private val remoteRepository: RemoteRepository, 20 | private val encrypterProvider: EncrypterProvider, 21 | private val gson: Gson 22 | ) { 23 | 24 | fun update(doc: Doc): Single> = 25 | upload(doc) 26 | .flatMapIfSuccessful { indexService.uploadKeepingDoc(doc) } 27 | .mapIfSuccessful { doc } 28 | 29 | fun delete(doc: Doc) = 30 | remoteRepository.deleteFile(doc.id) 31 | .flatMap { 32 | if (it.isSuccessful || it.is404) { 33 | indexService.uploadIgnoringDoc(doc) 34 | } else { 35 | Single.just(it) 36 | } 37 | } 38 | .mapIfSuccessful { doc } 39 | 40 | private fun upload(doc: Doc) = 41 | encrypterProvider.get(doc.encryptionSpec) 42 | ?.let { encryptAndUpload(it, doc) } 43 | ?: remoteRepository.uploadJson(doc.id, doc, false) 44 | 45 | private fun encryptAndUpload(encrypter: Encrypter, doc: Doc) = 46 | Single 47 | .fromCallable { gson.toJson(doc.toJsonObject().json) } 48 | .map { encrypter.encryptToBase64(it.toByteArray(), doc.encryptionSpec!!, doc.passcode!!) } 49 | .flatMap { 50 | remoteRepository.uploadJson( 51 | doc.id, 52 | EncryptedDataWrapper( 53 | payload = it.data, 54 | encryption = doc.encryptionSpecToJsonObject()!!.json 55 | ), 56 | false 57 | ) 58 | } 59 | .mapIfSuccessful { doc } 60 | .subscribeOn(Schedulers.io()) 61 | 62 | } 63 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/ui/share/ShareViewModel.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.ui.share 2 | 3 | import app.envelop.common.Optional 4 | import app.envelop.data.models.Doc 5 | import app.envelop.data.models.Progress 6 | import app.envelop.data.models.UploadWithDoc 7 | import app.envelop.domain.DocLinkBuilder 8 | import app.envelop.domain.GetDocService 9 | import app.envelop.ui.BaseViewModel 10 | import app.envelop.ui.common.Finish 11 | import app.envelop.ui.common.finish 12 | import io.reactivex.rxkotlin.Observables 13 | import io.reactivex.rxkotlin.addTo 14 | import io.reactivex.subjects.BehaviorSubject 15 | import io.reactivex.subjects.PublishSubject 16 | import javax.inject.Inject 17 | 18 | class ShareViewModel 19 | @Inject constructor( 20 | getDocService: GetDocService, 21 | docLinkBuilder: DocLinkBuilder 22 | ) : BaseViewModel() { 23 | 24 | private val docIdReceived = PublishSubject.create() 25 | 26 | private val doc = BehaviorSubject.create() 27 | private val progress = BehaviorSubject.create>() 28 | private val link = BehaviorSubject.create() 29 | private val finish = BehaviorSubject.create() 30 | 31 | init { 32 | docIdReceived 33 | .flatMap { getDocService.get(it) } 34 | .subscribe { 35 | when (it) { 36 | is Optional.Some -> doc.onNext(it.element) 37 | is Optional.None -> finish.finish() 38 | } 39 | } 40 | .addTo(disposables) 41 | 42 | Observables 43 | .combineLatest( 44 | docIdReceived.flatMap { getDocService.getUpload(it) }, 45 | doc 46 | ) { uploadOpt, doc -> 47 | Optional.create( 48 | uploadOpt.element()?.let { UploadWithDoc(it, doc).progress } 49 | ) 50 | } 51 | .subscribe { progress.onNext(it) } 52 | .addTo(disposables) 53 | 54 | doc 55 | .map { docLinkBuilder.build(it) } 56 | .subscribe(link::onNext) 57 | .addTo(disposables) 58 | } 59 | 60 | // Inputs 61 | 62 | fun docIdReceived(value: String) = docIdReceived.onNext(value) 63 | 64 | // Outputs 65 | 66 | fun doc() = doc.hide()!! 67 | fun progress() = progress.hide()!! 68 | fun link() = link.hide()!! 69 | fun finish() = finish.hide()!! 70 | 71 | } -------------------------------------------------------------------------------- /app/schemas/app.envelop.data.Database/3.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 3, 5 | "identityHash": "14d4d61c0d3a842ff6eec9f665e2dbfb", 6 | "entities": [ 7 | { 8 | "tableName": "Upload", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `docId` TEXT NOT NULL, `fileUriPath` TEXT NOT NULL, `partsUploaded` TEXT NOT NULL, `partSize` INTEGER NOT NULL)", 10 | "fields": [ 11 | { 12 | "fieldPath": "id", 13 | "columnName": "id", 14 | "affinity": "INTEGER", 15 | "notNull": true 16 | }, 17 | { 18 | "fieldPath": "docId", 19 | "columnName": "docId", 20 | "affinity": "TEXT", 21 | "notNull": true 22 | }, 23 | { 24 | "fieldPath": "fileUriPath", 25 | "columnName": "fileUriPath", 26 | "affinity": "TEXT", 27 | "notNull": true 28 | }, 29 | { 30 | "fieldPath": "partsUploaded", 31 | "columnName": "partsUploaded", 32 | "affinity": "TEXT", 33 | "notNull": true 34 | }, 35 | { 36 | "fieldPath": "partSize", 37 | "columnName": "partSize", 38 | "affinity": "INTEGER", 39 | "notNull": true 40 | } 41 | ], 42 | "primaryKey": { 43 | "columnNames": [ 44 | "id" 45 | ], 46 | "autoGenerate": true 47 | }, 48 | "indices": [ 49 | { 50 | "name": "index_Upload_docId", 51 | "unique": true, 52 | "columnNames": [ 53 | "docId" 54 | ], 55 | "createSql": "CREATE UNIQUE INDEX `index_Upload_docId` ON `${TABLE_NAME}` (`docId`)" 56 | } 57 | ], 58 | "foreignKeys": [] 59 | } 60 | ], 61 | "views": [], 62 | "setupQueries": [ 63 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", 64 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '14d4d61c0d3a842ff6eec9f665e2dbfb')" 65 | ] 66 | } 67 | } -------------------------------------------------------------------------------- /app/src/test/java/app/envelop/data/models/EncryptionSpecTest.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data.models 2 | 3 | import app.envelop.data.security.Pbkdf2AesEncryptionSpec 4 | import app.envelop.data.toInnerJsonObject 5 | import com.google.gson.Gson 6 | import com.google.gson.JsonObject 7 | import com.google.gson.JsonParser 8 | import org.junit.Assert.* 9 | import org.junit.Test 10 | 11 | class EncryptionSpecTest { 12 | 13 | @Test 14 | fun unknownType() { 15 | assertNull( 16 | Pbkdf2AesEncryptionSpec.fromJson( 17 | Gson().fromJson( 18 | """ 19 | { 20 | "type": "unknown", 21 | "params": {} 22 | } 23 | """, JsonObject::class.java 24 | ).toInnerJsonObject() 25 | ) 26 | ) 27 | } 28 | 29 | @Test 30 | fun pbkdf2Aes_parsing() { 31 | val spec = Pbkdf2AesEncryptionSpec.fromJson( 32 | JsonParser.parseString( 33 | """ 34 | { 35 | "type": "PBKDF2/AES", 36 | "params": { 37 | "key_iterations": 10000, 38 | "salt": "f98cnhd132", 39 | "iv": "5432n7542n754n78n7985==" 40 | } 41 | } 42 | """ 43 | ).toInnerJsonObject()!! 44 | ) 45 | 46 | assertNotNull(spec!!) 47 | assertEquals(10000, spec.keyIterations) 48 | assertEquals("f98cnhd132", spec.salt) 49 | assertEquals("5432n7542n754n78n7985==", spec.iv) 50 | } 51 | 52 | @Test 53 | fun pbkdf2Aes_keepUnknownValues() { 54 | val baseJson = JsonParser.parseString( 55 | """ 56 | { 57 | "type": "PBKDF2/AES", 58 | "params": { 59 | "key_iterations": 10000, 60 | "salt": "f98cnhd132", 61 | "iv": "5432n7542n754n78n7985==", 62 | "new_key": "new_value" 63 | } 64 | } 65 | """ 66 | ).toInnerJsonObject()!! 67 | val spec = Pbkdf2AesEncryptionSpec.fromJson(baseJson)!! 68 | 69 | assertEquals( 70 | "new_value", 71 | spec 72 | .toJson(baseJson) 73 | .json 74 | .getAsJsonObject("params") 75 | .getAsJsonPrimitive("new_key") 76 | .asString 77 | ) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/domain/IndexService.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.domain 2 | 3 | import app.envelop.common.Operation 4 | import app.envelop.common.Optional 5 | import app.envelop.common.flatMapCompletableIfSuccessful 6 | import app.envelop.common.flatMapIfSuccessful 7 | import app.envelop.data.mappers.IndexSanitizer 8 | import app.envelop.data.models.Doc 9 | import app.envelop.data.models.Index 10 | import app.envelop.data.models.UnsanitizedIndex 11 | import app.envelop.data.repositories.DocRepository 12 | import app.envelop.data.repositories.RemoteRepository 13 | import io.reactivex.Single 14 | import io.reactivex.schedulers.Schedulers 15 | import javax.inject.Inject 16 | 17 | class IndexService 18 | @Inject constructor( 19 | private val docRepository: DocRepository, 20 | private val remoteRepository: RemoteRepository, 21 | private val indexSanitizer: IndexSanitizer 22 | ) { 23 | 24 | fun download(docsToKeep: List = emptyList(), docToIgnore: List = emptyList()) = 25 | remoteRepository 26 | .getJson(INDEX_FILE_NAME, UnsanitizedIndex::class, true) 27 | .flatMapIfSuccessful { opt -> 28 | when (opt) { 29 | is Optional.Some -> indexSanitizer.sanitize(opt.element).map { Operation.success(it.docs) } 30 | is Optional.None -> Single.just(Operation.success(emptyList())) 31 | } 32 | } 33 | .flatMapCompletableIfSuccessful { docsReceived -> 34 | val docsToIgnoreIds = (docToIgnore + docsToKeep).map { it.id } 35 | docRepository.replace( 36 | docsReceived.filterNot { docsToIgnoreIds.contains(it.id) } 37 | + docsToKeep 38 | ) 39 | } 40 | 41 | fun uploadKeepingDoc(docToUpload: Doc) = 42 | download(docsToKeep = listOf(docToUpload)) 43 | .flatMapIfSuccessful { upload() } 44 | 45 | fun uploadIgnoringDoc(docToIgnore: Doc) = 46 | download(docToIgnore = listOf(docToIgnore)) 47 | .flatMapIfSuccessful { upload() } 48 | 49 | fun get() = 50 | docRepository.listVisible() 51 | 52 | private fun upload() = 53 | docRepository 54 | .list() 55 | .firstOrError() 56 | .flatMap { remoteRepository.uploadJson(INDEX_FILE_NAME, Index(it), true) } 57 | .subscribeOn(Schedulers.io()) 58 | 59 | companion object { 60 | private const val INDEX_FILE_NAME = "index" 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /app/src/androidTest/java/app/envelop/ui/main/FormatRelativeDateTest.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.ui.main 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import app.envelop.R 5 | import app.envelop.test.AppHelper.resources 6 | import org.hamcrest.CoreMatchers.equalTo 7 | import org.hamcrest.MatcherAssert.assertThat 8 | import org.junit.Test 9 | import org.junit.runner.RunWith 10 | import java.util.* 11 | 12 | @RunWith(AndroidJUnit4::class) 13 | class FormatRelativeDateTest { 14 | 15 | private val localeList = 16 | listOf(Locale.getDefault(), Locale.GERMANY, Locale.CHINA, Locale.US, Locale.UK) 17 | 18 | @Test 19 | fun format_otherYear() { 20 | localeList.forEach { 21 | val getDate = FormatRelativeDate(resources, it) 22 | assertThat(getDate.format(Date(1558359368000)), equalTo("20/05/2019")) 23 | } 24 | } 25 | 26 | @Test 27 | fun format_thisYear() { 28 | val calendar = Calendar.getInstance().also { it.set(Calendar.DAY_OF_YEAR, 1) } 29 | 30 | assertThat(FormatRelativeDate(resources, Locale.US).format(calendar.time), equalTo("Jan 1")) 31 | assertThat(FormatRelativeDate(resources, Locale.GERMANY).format(calendar.time), equalTo("Jan. 1")) 32 | assertThat(FormatRelativeDate(resources, Locale.ITALY).format(calendar.time), equalTo("gen 1")) 33 | } 34 | 35 | @Test 36 | fun format_twoHoursAgo() { 37 | val calendar = Calendar.getInstance().also { it.add(Calendar.HOUR, -2) } 38 | val expectedString = resources.getQuantityString(R.plurals.hour, 2, 2) 39 | 40 | localeList.forEach { 41 | val getDate = FormatRelativeDate(resources, it) 42 | assertThat(getDate.format(calendar.time), equalTo(expectedString)) 43 | } 44 | } 45 | 46 | @Test 47 | fun format_tenMinutesAgo() { 48 | val calendar = Calendar.getInstance().also { it.add(Calendar.MINUTE, -10) } 49 | val expectedString = resources.getQuantityString(R.plurals.min, 10, 10) 50 | 51 | localeList.forEach { 52 | val getDate = FormatRelativeDate(resources, it) 53 | assertThat(getDate.format(calendar.time), equalTo(expectedString)) 54 | } 55 | } 56 | 57 | @Test 58 | fun format_now() { 59 | val expectedString = resources.getString(R.string.now) 60 | 61 | localeList.forEach { 62 | val getDate = FormatRelativeDate(resources, it) 63 | assertThat(getDate.format(Date()), equalTo(expectedString)) 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /app/src/test/java/app/envelop/data/models/DocTest.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data.models 2 | 3 | import app.envelop.test.DocFactory 4 | import com.google.gson.Gson 5 | import com.google.gson.JsonObject 6 | import org.junit.Assert.assertEquals 7 | import org.junit.Test 8 | import java.util.* 9 | 10 | class DocTest { 11 | 12 | private val baseDoc = DocFactory.build() 13 | 14 | @Test 15 | fun humanSize() { 16 | Locale.setDefault(Locale.US) 17 | assertEquals("0 B", baseDoc.copy(size = 0).humanSize) 18 | assertEquals("1.0 kB", baseDoc.copy(size = 1_000).humanSize) 19 | assertEquals("1.2 kB", baseDoc.copy(size = 1_234).humanSize) 20 | assertEquals("10.0 MB", baseDoc.copy(size = 10_000_000).humanSize) 21 | } 22 | 23 | @Test 24 | fun fromJson() { 25 | Doc.build( 26 | Gson().fromJson( 27 | """ 28 | { 29 | "id": "ABCDEF", 30 | "name": "file.pdf", 31 | "url": "UUID-UUID", 32 | "size": 1000, 33 | "created_at": "2019-10-10T12:34:56", 34 | "content_type": "pdf", 35 | "version": 2, 36 | "num_parts": 2, 37 | "part_ivs": ["abc","abc"], 38 | "username": "username" 39 | } 40 | """, JsonObject::class.java 41 | ).asJsonObject 42 | ).run { 43 | assertEquals("ABCDEF", id) 44 | assertEquals("file.pdf", name) 45 | assertEquals("UUID-UUID", url) 46 | assertEquals(1_000, size) 47 | assertEquals( 48 | 10, 49 | Calendar.getInstance().also { it.time = createdAt }.get(Calendar.DAY_OF_MONTH) 50 | ) 51 | assertEquals("pdf", contentType) 52 | assertEquals(2, version) 53 | assertEquals(2, numParts) 54 | assertEquals(2, partIVs?.size) 55 | } 56 | } 57 | 58 | @Test 59 | fun keepUnknownFields() { 60 | val doc = Doc.build( 61 | Gson().fromJson( 62 | """ 63 | { 64 | "id": "ABCDEF", 65 | "name": "file.pdf", 66 | "url": "UUID-UUID", 67 | "size": 1000, 68 | "created_at": "2019-10-10T12:34:56", 69 | "num_parts": 2, 70 | "username": "username", 71 | "new_field": "new_value" 72 | } 73 | """, JsonObject::class.java 74 | ).asJsonObject 75 | ) 76 | assertEquals( 77 | "new_value", 78 | doc.toJsonObject().json.getAsJsonPrimitive("new_field").asString 79 | ) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/ui/upload/UploadViewModel.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.ui.upload 2 | 3 | import android.net.Uri 4 | import app.envelop.common.Optional 5 | import app.envelop.domain.PreUploadService 6 | import app.envelop.domain.UserService 7 | import app.envelop.ui.BaseViewModel 8 | import app.envelop.ui.common.* 9 | import app.envelop.ui.common.Finish.Result.Canceled 10 | import app.envelop.ui.common.Finish.Result.Ok 11 | import app.envelop.ui.share.ShareActivity 12 | import io.reactivex.rxkotlin.addTo 13 | import io.reactivex.subjects.BehaviorSubject 14 | import io.reactivex.subjects.PublishSubject 15 | import timber.log.Timber 16 | import javax.inject.Inject 17 | 18 | class UploadViewModel 19 | @Inject constructor( 20 | userService: UserService, 21 | preUploadService: PreUploadService 22 | ) : BaseViewModel() { 23 | 24 | private val fileToUploadReceived = PublishSubject.create() 25 | 26 | private val isPreparingUpload = BehaviorSubject.create() 27 | private val error = PublishSubject.create() 28 | private val openLogin = PublishSubject.create() 29 | private val openDoc = PublishSubject.create() 30 | private val finish = PublishSubject.create() 31 | 32 | init { 33 | 34 | userService 35 | .user() 36 | .filter { it is Optional.None } 37 | .subscribe { 38 | openLogin.open() 39 | finish.finish(Canceled) 40 | } 41 | .addTo(disposables) 42 | 43 | fileToUploadReceived 44 | .doOnNext { isPreparingUpload.loading() } 45 | .flatMapSingle { preUploadService.prepareUpload(it) } 46 | .subscribe { 47 | isPreparingUpload.idle() 48 | if (it.isSuccessful) { 49 | openDoc.onNext(ShareActivity.Extras(it.result())) 50 | finish.finish(Ok) 51 | } else { 52 | Timber.w(it.throwable()) 53 | error.onNext(Error.UploadError) 54 | finish.finish(Canceled) 55 | } 56 | } 57 | } 58 | 59 | // Inputs 60 | 61 | fun fileToUploadReceived(value: Uri) = fileToUploadReceived.onNext(value) 62 | 63 | // Outputs 64 | 65 | fun isPreparingUpload() = isPreparingUpload.hide()!! 66 | fun error() = error.hide()!! 67 | fun openLogin() = openLogin.hide()!! 68 | fun openDoc() = openDoc.hide()!! 69 | fun finish() = finish.hide()!! 70 | 71 | sealed class Error { 72 | object UploadError : Error() 73 | } 74 | } -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/data/models/Upload.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data.models 2 | 3 | import android.net.Uri 4 | import androidx.room.Entity 5 | import androidx.room.Index 6 | import androidx.room.PrimaryKey 7 | import app.envelop.data.security.Pbkdf2AesEncryptionSpec 8 | 9 | @Entity( 10 | indices = [ 11 | Index(value = ["docId"], unique = true) 12 | ] 13 | ) 14 | data class Upload( 15 | @PrimaryKey(autoGenerate = true) val id: Long = 0, 16 | val docId: String = "", 17 | val fileUriPath: String = "", 18 | val partsUploaded: List = emptyList(), 19 | val partSize: Long = 0 20 | ) { 21 | 22 | val fileUri get() = Uri.parse(fileUriPath)!! 23 | 24 | } 25 | 26 | data class UploadWithDoc( 27 | val upload: Upload, 28 | val doc: Doc 29 | ) { 30 | 31 | val name get() = doc.name 32 | val progress get() = Progress(partsUploadedCount, totalParts) 33 | private val partsUploadedCount get() = upload.partsUploaded.size 34 | private val totalParts get() = doc.calculateParts(upload.partSize) 35 | 36 | val passcode get() = doc.passcode!! 37 | val baseEncryptionSpec get() = doc.encryptionSpec!! 38 | 39 | val missingParts 40 | get() = 41 | (0 until totalParts) 42 | .filterNot { upload.partsUploaded.contains(it) } 43 | .map { part -> 44 | val spec = 45 | (baseEncryptionSpec as Pbkdf2AesEncryptionSpec).copy(iv = doc.partIVs!![part]) 46 | UploadPart( 47 | part = part, 48 | fileUri = upload.fileUriPath, 49 | baseUrl = doc.url, 50 | partSize = upload.partSize, 51 | passcode = passcode, 52 | encryptionSpec = spec, 53 | onlyOnePart = totalParts == 1 54 | ) 55 | } 56 | 57 | } 58 | 59 | data class UploadPart( 60 | val part: Int, 61 | val fileUri: String, 62 | val baseUrl: String, 63 | val partSize: Long, 64 | val passcode: String, 65 | val encryptionSpec: Pbkdf2AesEncryptionSpec, 66 | val onlyOnePart: Boolean 67 | ) { 68 | 69 | val partStart get() = part * partSize 70 | 71 | val destinationUrl 72 | get() = if (onlyOnePart) { 73 | baseUrl 74 | } else { 75 | "$baseUrl.part$part" 76 | } 77 | 78 | } 79 | 80 | sealed class UploadState { 81 | 82 | data class Uploading( 83 | val fileCount: Int, 84 | val nextUpload: UploadWithDoc 85 | ) : UploadState() 86 | 87 | object Idle : UploadState() 88 | 89 | } 90 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/common/FileHandler.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.common 2 | 3 | import android.content.ContentResolver 4 | import android.content.Context 5 | import android.net.Uri 6 | import androidx.core.net.toUri 7 | import io.reactivex.Single 8 | import io.reactivex.schedulers.Schedulers 9 | import java.io.ByteArrayOutputStream 10 | import java.io.FileOutputStream 11 | import java.io.InputStream 12 | import javax.inject.Inject 13 | import kotlin.math.min 14 | 15 | class FileHandler 16 | @Inject constructor( 17 | private val context: Context, 18 | private val contentResolver: ContentResolver 19 | ) { 20 | 21 | fun saveFileLocally(fileUri: Uri) = 22 | Single 23 | .fromCallable { 24 | val filename = generateFileName() 25 | contentResolver.openInputStream(fileUri)?.use { input -> 26 | context.openFileOutput(filename, Context.MODE_PRIVATE).use { output -> 27 | copy(input, output) 28 | } 29 | } 30 | context.getFileStreamPath(filename).toUri() 31 | } 32 | .toOperation() 33 | .subscribeOn(Schedulers.io()) 34 | 35 | fun deleteLocalFile(fileUri: Uri) { 36 | context.deleteFile(fileUri.lastPathSegment) 37 | } 38 | 39 | fun localFileToByteArray(fileUri: String, start: Long = 0, maxLength: Long = Long.MAX_VALUE) = 40 | context.openFileInput(Uri.parse(fileUri).lastPathSegment) 41 | ?.use { inputStream -> 42 | 43 | val byteBuffer = ByteArrayOutputStream() 44 | val bufferSize = 1024L 45 | val buffer = ByteArray(bufferSize.toInt()) 46 | var transferredLength = 0L 47 | 48 | inputStream.skip(start) 49 | 50 | var len: Int 51 | while (true) { 52 | len = inputStream.read(buffer) 53 | len = min(len.toLong(), maxLength - transferredLength).toInt() 54 | if (len < 1) break 55 | byteBuffer.write(buffer, 0, len) 56 | transferredLength += len 57 | } 58 | 59 | byteBuffer.toByteArray() 60 | } 61 | 62 | private fun generateFileName() = "file_${System.currentTimeMillis()}" 63 | 64 | private fun copy(input: InputStream, output: FileOutputStream) { 65 | val buffer = ByteArray(4 * 1024) // or other buffer size 66 | var read: Int 67 | while (true) { 68 | read = input.read(buffer) 69 | if (read == -1) break 70 | output.write(buffer, 0, read) 71 | } 72 | output.flush() 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/ui/common/Toolbar.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.ui.common 2 | 3 | import android.content.Context 4 | import android.util.AttributeSet 5 | import android.view.Menu 6 | import android.view.View 7 | import android.widget.FrameLayout 8 | import androidx.annotation.DrawableRes 9 | import androidx.annotation.MenuRes 10 | import androidx.annotation.StringRes 11 | import androidx.core.content.res.ResourcesCompat 12 | import app.envelop.R 13 | import app.envelop.ui.BaseActivity 14 | import com.jakewharton.rxbinding3.appcompat.itemClicks 15 | import io.reactivex.Observable 16 | import io.reactivex.subjects.PublishSubject 17 | import kotlinx.android.synthetic.main.view_toolbar.view.* 18 | 19 | class Toolbar 20 | @JvmOverloads constructor( 21 | context: Context, 22 | attrs: AttributeSet? = null, 23 | defStyleAttr: Int = 0 24 | ) : FrameLayout(context, attrs, defStyleAttr) { 25 | 26 | val menu: Menu get() = innerToolbar.menu 27 | 28 | private val activity get() = (context as BaseActivity) 29 | private val itemClicks = PublishSubject.create() 30 | 31 | init { 32 | activity.component.inject(this) 33 | View.inflate(context, R.layout.view_toolbar, this) 34 | innerToolbar.title = "" 35 | setTitle(activity.title.toString()) 36 | } 37 | 38 | fun setTitle(@StringRes titleRes: Int) { 39 | title.setText(titleRes) 40 | } 41 | 42 | private fun setTitle(titleStr: String) { 43 | title.text = titleStr 44 | } 45 | 46 | fun enableNavigation( 47 | @DrawableRes iconRes: Int = R.drawable.ic_back, 48 | @StringRes descriptionRes: Int = R.string.back, 49 | callback: () -> Unit = { activity.onBackPressed() } 50 | ) { 51 | innerToolbar.navigationIcon = ResourcesCompat.getDrawable(resources, iconRes, context.theme) 52 | innerToolbar.navigationContentDescription = resources.getString(descriptionRes) 53 | innerToolbar.setNavigationOnClickListener { callback.invoke() } 54 | } 55 | 56 | fun setupMenu(@MenuRes menuRes: Int) { 57 | activity.menuInflater.inflate(menuRes, innerToolbar.menu) 58 | 59 | innerToolbar 60 | .itemClicks() 61 | .map { it.itemId } 62 | .subscribe(itemClicks::onNext) 63 | } 64 | 65 | fun itemClicks(itemId: Int? = null): Observable = 66 | itemClicks.filter { itemId == null || it == itemId } 67 | 68 | @Suppress("unused") 69 | fun itemClicksThrottled(itemId: Int? = null) = 70 | itemClicks(itemId).throttleForClicks() 71 | 72 | } 73 | 74 | -------------------------------------------------------------------------------- /app/src/androidTest/java/app/envelop/domain/LoginServiceTest.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.domain 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import app.envelop.common.Operation 5 | import app.envelop.data.BlockstackLogin 6 | import app.envelop.data.repositories.UserRepository 7 | import com.nhaarman.mockitokotlin2.any 8 | import com.nhaarman.mockitokotlin2.mock 9 | import com.nhaarman.mockitokotlin2.verify 10 | import com.nhaarman.mockitokotlin2.whenever 11 | import io.reactivex.Single 12 | import org.blockstack.android.sdk.model.UserData 13 | import org.json.JSONObject 14 | import org.junit.Test 15 | import org.junit.runner.RunWith 16 | 17 | @RunWith(AndroidJUnit4::class) 18 | class LoginServiceTest { 19 | 20 | val blockstackLoginMock = mock() 21 | val userRepositoryMock = mock() 22 | 23 | val finishLoginService = LoginService(blockstackLoginMock, userRepositoryMock) 24 | 25 | 26 | @Test 27 | fun login() { 28 | val result = finishLoginService.login() 29 | verify(blockstackLoginMock).login() 30 | } 31 | 32 | @Test 33 | fun finishLogin() { 34 | whenever(blockstackLoginMock.handlePendingSignIn(any())).thenReturn( 35 | Single.just( 36 | Operation( 37 | UserData( 38 | JSONObject() 39 | .put("username", "JohnDoe.blockstack.id") 40 | .put("decentralizedID", "abc") 41 | .put("hubUrl", "abc") 42 | ) 43 | ) 44 | ) 45 | ) 46 | 47 | val result = finishLoginService.finishLogin("/token1/token2/token3/token4").blockingGet() 48 | assert(result.isSuccessful) 49 | } 50 | 51 | @Test 52 | fun finishLoginUnverifiedUsername() { 53 | whenever(blockstackLoginMock.handlePendingSignIn(any())).thenReturn( 54 | Single.just(Operation(UserData(JSONObject().put("username", "null")))) 55 | ) 56 | 57 | val result = finishLoginService.finishLogin("/token1/token2/token3/token4").blockingGet() 58 | assert(result.isError) 59 | } 60 | 61 | @Test 62 | fun finishLoginNoUsername() { 63 | whenever(blockstackLoginMock.handlePendingSignIn(any())).thenReturn( 64 | Single.just(Operation(UserData(JSONObject()))) 65 | ) 66 | 67 | val result = finishLoginService.finishLogin("/token1/token2/token3/token4").blockingGet() 68 | assert(result.isError) 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_paypal.xml: -------------------------------------------------------------------------------- 1 | 6 | 14 | 22 | 28 | 29 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /app/src/main/res/layout/item_doc.xml: -------------------------------------------------------------------------------- 1 | 2 | 15 | 16 | 21 | 22 | 30 | 31 | 39 | 40 | 50 | 51 | 59 | 60 | 61 | 69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/common/Operation.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.common 2 | 3 | import io.reactivex.Completable 4 | import io.reactivex.Observable 5 | import io.reactivex.Single 6 | import io.reactivex.SingleSource 7 | 8 | data class Operation( 9 | private val result: T? = null, 10 | private val throwable: Throwable? = null 11 | ) { 12 | 13 | val isSuccessful get() = throwable == null 14 | val isError get() = !isSuccessful 15 | 16 | fun result() = result!! 17 | 18 | fun throwable() = throwable!! 19 | 20 | @Suppress("unused") 21 | fun mapResult(map: ((T) -> V)) = 22 | Operation( 23 | result?.let { map.invoke(it) }, 24 | throwable 25 | ) 26 | 27 | val is404 get() = throwable().message?.contains("404") == true 28 | 29 | companion object { 30 | fun success() = Operation(Unit) 31 | fun success(result: T) = Operation(result = result) 32 | fun error(throwable: Throwable) = Operation( 33 | throwable = throwable 34 | ) 35 | } 36 | } 37 | 38 | fun Single>.flatMapIfSuccessful(mapper: ((T) -> SingleSource>)): Single> = 39 | flatMap { 40 | if (it.isSuccessful) { 41 | mapper.invoke(it.result()) 42 | } else { 43 | Single.just(Operation.error(it.throwable())) 44 | } 45 | } 46 | 47 | fun Single>.mapIfSuccessful(mapper: ((T) -> R)): Single> = 48 | map { 49 | if (it.isSuccessful) { 50 | Operation.success(mapper.invoke(it.result())) 51 | } else { 52 | Operation.error(it.throwable()) 53 | } 54 | } 55 | 56 | @Suppress("unused") 57 | fun Observable>.doIfSuccessful(mapper: ((T) -> Unit)): Observable> = 58 | doOnNext { if (it.isSuccessful) mapper.invoke(it.result()) } 59 | 60 | fun Observable>.doIfError(mapper: ((Throwable) -> Unit)): Observable> = 61 | doOnNext { if (it.isError) mapper.invoke(it.throwable()) } 62 | 63 | fun Single>.doIfSuccessful(mapper: ((T) -> Unit)) = 64 | doOnSuccess { if (it.isSuccessful) mapper.invoke(it.result()) } 65 | 66 | @Suppress("unused") 67 | fun Single>.doIfError(mapper: ((Throwable) -> Unit)) = 68 | doOnSuccess { if (it.isError) mapper.invoke(it.throwable()) } 69 | 70 | fun Single>.flatMapCompletableIfSuccessful(mapper: ((T) -> Completable)) = 71 | flatMap { 72 | if (it.isSuccessful) { 73 | mapper.invoke(it.result()).toSingleDefault(it) 74 | } else { 75 | Single.just(it) 76 | } 77 | } 78 | 79 | fun Single.toOperation() = 80 | map { Operation.success(it) } 81 | .onErrorReturn { Operation.error(it) } 82 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/ui/faq/FaqActivity.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.ui.faq 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.net.Uri 7 | import android.os.Bundle 8 | import android.webkit.* 9 | import androidx.core.view.isVisible 10 | import app.envelop.R 11 | import app.envelop.ui.common.Insets.addSystemWindowInsetToPadding 12 | import app.envelop.ui.common.SystemBars.setSystemBarsStyle 13 | import app.envelop.ui.BaseActivity 14 | import app.envelop.ui.common.MessageManager 15 | import kotlinx.android.synthetic.main.activity_faq.* 16 | import kotlinx.android.synthetic.main.shared_appbar.* 17 | import javax.inject.Inject 18 | 19 | class FaqActivity : BaseActivity() { 20 | 21 | @Inject 22 | lateinit var messageManager: MessageManager 23 | 24 | @SuppressLint("SetJavaScriptEnabled") 25 | override fun onCreate(savedInstanceState: Bundle?) { 26 | super.onCreate(savedInstanceState) 27 | component.inject(this) 28 | setContentView(R.layout.activity_faq) 29 | 30 | toolbar.addSystemWindowInsetToPadding(top = true) 31 | toolbar.enableNavigation() 32 | 33 | faq.settings.javaScriptEnabled = true 34 | faq.loadUrl(getString(R.string.faq_url)) 35 | faq.webChromeClient = CustomChromeClient() 36 | faq.webViewClient = CustomWebViewClient() 37 | } 38 | 39 | inner class CustomChromeClient : WebChromeClient() { 40 | override fun onProgressChanged(view: WebView?, newProgress: Int) { 41 | progressBar.progress = newProgress 42 | super.onProgressChanged(view, newProgress) 43 | } 44 | } 45 | 46 | inner class CustomWebViewClient : WebViewClient() { 47 | private var hadErrorLoadingWebView = false 48 | 49 | override fun onReceivedError( 50 | view: WebView, 51 | request: WebResourceRequest, 52 | error: WebResourceError 53 | ) { 54 | hadErrorLoadingWebView = true 55 | messageManager.showError(R.string.faq_host_not_found) 56 | faq.isVisible = false 57 | progressBar.isVisible = false 58 | super.onReceivedError(view, request, error) 59 | } 60 | 61 | override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { 62 | openBrowser(request.url) 63 | return true 64 | } 65 | 66 | override fun onPageFinished(view: WebView?, url: String?) { 67 | super.onPageFinished(view, url) 68 | if (!hadErrorLoadingWebView) { 69 | faq.isVisible = true 70 | progressBar.isVisible = true 71 | } 72 | } 73 | 74 | private fun openBrowser(url: Uri) { 75 | val intent = Intent(Intent.ACTION_VIEW, url) 76 | startActivity(intent) 77 | } 78 | } 79 | 80 | companion object { 81 | fun getIntent(context: Context) = Intent(context, FaqActivity::class.java) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/data/IndexDatabase.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data 2 | 3 | import android.content.Context 4 | import app.envelop.App 5 | import app.envelop.data.mappers.IndexSanitizer 6 | import app.envelop.data.models.Index 7 | import app.envelop.data.models.UnsanitizedIndex 8 | import com.google.gson.Gson 9 | import com.google.gson.JsonParseException 10 | import io.reactivex.Completable 11 | import io.reactivex.Observable 12 | import io.reactivex.Single 13 | import io.reactivex.schedulers.Schedulers 14 | import io.reactivex.subjects.BehaviorSubject 15 | import timber.log.Timber 16 | import java.io.FileNotFoundException 17 | import java.io.FileReader 18 | import java.io.OutputStreamWriter 19 | import javax.inject.Inject 20 | import javax.inject.Singleton 21 | 22 | @Singleton 23 | class IndexDatabase 24 | @Inject constructor( 25 | private val appMode: App.Mode, 26 | private val context: Context, 27 | private val gson: Gson, 28 | private val indexSanitizer: IndexSanitizer 29 | ) { 30 | 31 | private val indexSubject = BehaviorSubject.create() 32 | 33 | fun get(): Observable = 34 | Completable 35 | .defer { 36 | if (!indexSubject.hasValue()) { 37 | loadIndex().doOnSuccess { indexSubject.onNext(it) }.ignoreElement() 38 | } else { 39 | Completable.complete() 40 | } 41 | } 42 | .andThen(indexSubject.hide()) 43 | .subscribeOn(Schedulers.io()) 44 | 45 | fun save(index: Index) = 46 | Completable 47 | .fromAction { 48 | storeIndex(index) 49 | indexSubject.onNext(index) 50 | } 51 | .subscribeOn(Schedulers.io()) 52 | 53 | fun delete() = 54 | Completable 55 | .fromAction { 56 | context.deleteFile(indexFileName) 57 | indexSubject.onNext(Index()) 58 | } 59 | .subscribeOn(Schedulers.io()) 60 | 61 | private fun loadIndex() = 62 | Single 63 | .fromCallable { 64 | try { 65 | context.openFileInput(indexFileName).use { 66 | gson.fromJson(FileReader(it.fd), UnsanitizedIndex::class.java) ?: UnsanitizedIndex() 67 | } 68 | } catch (exception: FileNotFoundException) { 69 | UnsanitizedIndex() 70 | } catch (exception: JsonParseException) { 71 | Timber.w(exception) 72 | UnsanitizedIndex() 73 | } 74 | } 75 | .flatMap { indexSanitizer.sanitize(it) } 76 | 77 | private fun storeIndex(index: Index) = 78 | context.openFileOutput(indexFileName, Context.MODE_PRIVATE).use { 79 | OutputStreamWriter(it).use { writer -> 80 | writer.write(gson.toJson(index)) 81 | } 82 | } 83 | 84 | private val indexFileName 85 | get() = 86 | when (appMode) { 87 | App.Mode.Normal -> BASE_INDEX_FILE 88 | App.Mode.Test -> "$BASE_INDEX_FILE.test" 89 | } 90 | 91 | companion object { 92 | private const val BASE_INDEX_FILE = "index.db" 93 | } 94 | } -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_login.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 19 | 20 | 27 | 28 | 36 | 37 | 46 | 47 | 48 | 49 | 64 | 65 | 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v1 12 | 13 | - name: Setup JDK 1.8 14 | uses: actions/setup-java@v1 15 | with: 16 | java-version: 1.8 17 | 18 | - name: Cache 19 | uses: actions/cache@v1 20 | with: 21 | path: ~/.gradle/caches 22 | key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} 23 | restore-keys: | 24 | ${{ runner.os }}-gradle- 25 | 26 | - name: Build debug APK 27 | run: ./gradlew assembleDebug 28 | 29 | - name: Run unit tests 30 | run: ./gradlew testDebug 31 | 32 | - name: Upload Unit Test results 33 | if: ${{ always() }} 34 | uses: actions/upload-artifact@v1 35 | with: 36 | name: tests-results 37 | path: app/build/reports/tests/testDebugUnitTest/ 38 | 39 | - name: Build androidTest APK 40 | run: ./gradlew :app:assembleDebugAndroidTest 41 | 42 | - name: Install Google Cloud 43 | run: | 44 | sudo echo ${{ secrets.GCLOUD_AUTH }} | base64 --decode > gcloud-service-key.json 45 | sudo gcloud auth activate-service-account --key-file=gcloud-service-key.json 46 | sudo gcloud --quiet config set project ${{ secrets.GOOGLE_PROJECT_ID }} 47 | 48 | - name: Run instrumentation tests with Firebase Test Lab 49 | run: | 50 | sudo gcloud firebase test android run \ 51 | --app app/build/outputs/apk/debug/app-debug.apk \ 52 | --test app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk \ 53 | --device model=Pixel2,version=28,locale=en,orientation=portrait \ 54 | --directories-to-pull /sdcard \ 55 | --environment-variables coverage=true,coverageFile="/sdcard/coverage.ec" \ 56 | --results-bucket cloud-test-${{ secrets.GOOGLE_PROJECT_ID }} 57 | 58 | - name: Setup python 59 | uses: actions/setup-python@v1 60 | with: 61 | python-version: '3.x' 62 | architecture: 'x64' 63 | 64 | - name: Download test results data 65 | run: | 66 | pip install -U crcmod 67 | mkdir app/test_results 68 | sudo gsutil -m cp -r -U `sudo gsutil ls gs://cloud-test-${{ secrets.GOOGLE_PROJECT_ID }} | tail -1` app/test_results/ | true 69 | 70 | - name: Generate code coverage report 71 | run: ./gradlew jacocoAndroidTestReport -PcodeCoverageDataLocation=test_results 72 | 73 | - uses: codecov/codecov-action@v1.0.3 74 | with: 75 | token: ${{ secrets.CODECOV_TOKEN }} 76 | file: app/build/reports/coverage.xml 77 | 78 | - name: Upload code coverage reports 79 | uses: actions/upload-artifact@v1 80 | with: 81 | name: code-coverage 82 | path: app/build/reports/ 83 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/ui/upload/UploadActivity.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.ui.upload 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.net.Uri 6 | import android.os.Bundle 7 | import android.os.Parcelable 8 | import app.envelop.R 9 | import app.envelop.common.rx.observeOnUI 10 | import app.envelop.ui.BaseActivity 11 | import app.envelop.ui.common.MessageManager 12 | import app.envelop.ui.common.loading.LoadingManager 13 | import app.envelop.ui.login.LoginActivity 14 | import app.envelop.ui.share.ShareActivity 15 | import com.trello.rxlifecycle3.android.lifecycle.kotlin.bindToLifecycle 16 | import javax.inject.Inject 17 | 18 | class UploadActivity : BaseActivity() { 19 | 20 | @Inject 21 | lateinit var loadingManager: LoadingManager 22 | @Inject 23 | lateinit var messageManager: MessageManager 24 | 25 | private val viewModel by lazy { 26 | component.viewModelProvider()[UploadViewModel::class.java] 27 | } 28 | 29 | private val uriReceived by lazy { 30 | intent?.getParcelableExtra( 31 | if (intent?.action == Intent.ACTION_SEND) { 32 | Intent.EXTRA_STREAM 33 | } else { 34 | EXTRA_FILE_URI 35 | } 36 | ) as? Uri? 37 | } 38 | 39 | override fun onCreate(savedInstanceState: Bundle?) { 40 | super.onCreate(savedInstanceState) 41 | component.inject(this) 42 | setContentView(R.layout.activity_upload) 43 | 44 | viewModel 45 | .isPreparingUpload() 46 | .bindToLifecycle(this) 47 | .observeOnUI() 48 | .subscribe { loadingManager.apply(it, R.string.preparing_upload) } 49 | 50 | viewModel 51 | .error() 52 | .bindToLifecycle(this) 53 | .observeOnUI() 54 | .subscribe { 55 | messageManager.showError( 56 | when (it) { 57 | UploadViewModel.Error.UploadError -> R.string.upload_error 58 | } 59 | ) 60 | } 61 | 62 | viewModel 63 | .openLogin() 64 | .bindToLifecycle(this) 65 | .observeOnUI() 66 | .subscribe { startActivity(LoginActivity.getIntent(this)) } 67 | 68 | viewModel 69 | .openDoc() 70 | .bindToLifecycle(this) 71 | .observeOnUI() 72 | .subscribe { startActivity(ShareActivity.getIntent(this, it)) } 73 | 74 | viewModel 75 | .finish() 76 | .bindToLifecycle(this) 77 | .observeOnUI() 78 | .subscribe { finish(it) } 79 | 80 | uriReceived?.let { 81 | viewModel.fileToUploadReceived(it) 82 | } ?: finish() 83 | } 84 | 85 | override fun onStop() { 86 | super.onStop() 87 | loadingManager.hide() 88 | } 89 | 90 | companion object { 91 | private const val EXTRA_FILE_URI = "file_uri" 92 | 93 | fun getIntent(context: Context, extras: Extras) = 94 | Intent(context, UploadActivity::class.java).also { 95 | it.putExtra(EXTRA_FILE_URI, extras.fileUri) 96 | } 97 | } 98 | 99 | data class Extras( 100 | val fileUri: Uri 101 | ) 102 | } 103 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/ui/login/LoginActivity.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.ui.login 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.net.Uri 6 | import android.os.Bundle 7 | import androidx.core.view.isVisible 8 | import app.envelop.R 9 | import app.envelop.common.rx.observeOnUI 10 | import app.envelop.domain.LoginService 11 | import app.envelop.ui.BaseActivity 12 | import app.envelop.ui.common.Insets.addSystemWindowInsetToPadding 13 | import app.envelop.ui.common.MessageManager 14 | import app.envelop.ui.common.clicksThrottled 15 | import app.envelop.ui.common.loading.LoadingManager 16 | import app.envelop.ui.main.MainActivity 17 | import com.trello.rxlifecycle3.android.lifecycle.kotlin.bindToLifecycle 18 | import kotlinx.android.synthetic.main.activity_login.* 19 | import kotlinx.android.synthetic.main.partial_banner.* 20 | import javax.inject.Inject 21 | 22 | class LoginActivity : BaseActivity() { 23 | 24 | @Inject 25 | lateinit var loginService: LoginService 26 | 27 | @Inject 28 | lateinit var loadingManager: LoadingManager 29 | 30 | @Inject 31 | lateinit var messageManager: MessageManager 32 | 33 | private val viewModel by lazy { 34 | component.viewModelProvider()[LoginViewModel::class.java].also { 35 | it.loginService = loginService 36 | } 37 | } 38 | 39 | override fun onCreate(savedInstanceState: Bundle?) { 40 | super.onCreate(savedInstanceState) 41 | component.inject(this) 42 | setContentView(R.layout.activity_login) 43 | 44 | container.addSystemWindowInsetToPadding(top = true, bottom = true) 45 | 46 | bannerBtn2 47 | .clicksThrottled() 48 | .bindToLifecycle(this) 49 | .subscribe { banner.isVisible = false } 50 | 51 | login 52 | .clicksThrottled() 53 | .bindToLifecycle(this) 54 | .subscribe { viewModel.loginClick() } 55 | 56 | viewModel 57 | .isLoggingIn() 58 | .bindToLifecycle(this) 59 | .observeOnUI() 60 | .subscribe { 61 | loadingManager.apply(it, R.string.login_progress) 62 | } 63 | 64 | viewModel 65 | .errors() 66 | .bindToLifecycle(this) 67 | .observeOnUI() 68 | .subscribe { 69 | messageManager.showError( 70 | when (it) { 71 | LoginViewModel.Error.UsernameMissing -> R.string.login_username_error 72 | else -> R.string.login_error 73 | } 74 | ) 75 | } 76 | 77 | viewModel 78 | .finishToMain() 79 | .bindToLifecycle(this) 80 | .observeOnUI() 81 | .subscribe { 82 | startActivity(MainActivity.getIntent(this)) 83 | finish(it) 84 | } 85 | 86 | if (intent?.action == Intent.ACTION_VIEW) { 87 | viewModel.authDataReceived(intent.data?.getQueryParameter("authResponse")) 88 | } 89 | } 90 | 91 | override fun onStop() { 92 | super.onStop() 93 | loadingManager.hide() 94 | } 95 | 96 | companion object { 97 | fun getIntent(context: Context) = Intent(context, LoginActivity::class.java) 98 | } 99 | 100 | } 101 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/data/security/Encrypter.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data.security 2 | 3 | import javax.crypto.Cipher 4 | import javax.crypto.spec.IvParameterSpec 5 | import javax.crypto.spec.SecretKeySpec 6 | import javax.inject.Inject 7 | 8 | class EncrypterProvider 9 | @Inject constructor( 10 | private val pbkdf2AesEncrypter: Pbkdf2AesEncrypter 11 | ) { 12 | 13 | fun get(spec: EncryptionSpec?) = 14 | when (spec) { 15 | is Pbkdf2AesEncryptionSpec -> pbkdf2AesEncrypter 16 | else -> null 17 | } 18 | 19 | fun getOrError(spec: EncryptionSpec?): Encrypter = 20 | when (spec) { 21 | is Pbkdf2AesEncryptionSpec -> pbkdf2AesEncrypter 22 | else -> throw UnsupportedOperationException("Unsupported encryption spec") 23 | } 24 | 25 | } 26 | 27 | abstract class Encrypter( 28 | private val keyGenerator: KeyGenerator, 29 | private val base64: Base64Encoder 30 | ) { 31 | 32 | // Encrypt 33 | 34 | abstract fun encrypt(input: ByteArray, spec: EncryptionSpec, key: EncryptionKey): Result 35 | 36 | private fun encrypt(input: ByteArray, spec: EncryptionSpec, passcode: String) = 37 | encrypt(input, spec, keyGenerator.generate(spec, passcode)) 38 | 39 | fun encryptToBase64(input: ByteArray, spec: EncryptionSpec, passcode: String) = 40 | encrypt(input, spec, passcode) 41 | .toBase64(base64) 42 | 43 | // Decrypt 44 | 45 | abstract fun decrypt(input: ByteArray, spec: EncryptionSpec, key: EncryptionKey): ByteArray 46 | 47 | fun decryptFromBase64(input: String, spec: EncryptionSpec, passcode: String) = 48 | decrypt( 49 | input = base64.decode(input), 50 | spec = spec, 51 | key = keyGenerator.generate(spec, passcode) 52 | ) 53 | 54 | class Result( 55 | val data: ByteArray 56 | ) { 57 | fun toBase64(base64: Base64Encoder) = ResultBase64( 58 | base64.encode(data) 59 | ) 60 | } 61 | 62 | class ResultBase64( 63 | val data: String 64 | ) 65 | 66 | } 67 | 68 | 69 | class Pbkdf2AesEncrypter 70 | @Inject constructor( 71 | keyGenerator: KeyGenerator, 72 | private val base64: Base64Encoder 73 | ) : Encrypter(keyGenerator, base64) { 74 | 75 | private val cipher by lazy { 76 | Cipher.getInstance("AES/CTR/NoPadding") 77 | } 78 | 79 | // Encrypt 80 | 81 | @Synchronized 82 | override fun encrypt(input: ByteArray, spec: EncryptionSpec, key: EncryptionKey) = 83 | with(cipher) { 84 | init( 85 | Cipher.ENCRYPT_MODE, 86 | buildAESKey(key), 87 | IvParameterSpec(base64.decode((spec as Pbkdf2AesEncryptionSpec).iv)) 88 | ) 89 | Result(doFinal(input)) 90 | } 91 | 92 | // Decrypt 93 | 94 | @Synchronized 95 | override fun decrypt(input: ByteArray, spec: EncryptionSpec, key: EncryptionKey): ByteArray = 96 | with(cipher) { 97 | init( 98 | Cipher.DECRYPT_MODE, 99 | buildAESKey(key), 100 | IvParameterSpec(base64.decode((spec as Pbkdf2AesEncryptionSpec).iv)) 101 | ) 102 | doFinal(input) 103 | } 104 | 105 | // Helpers 106 | 107 | private fun buildAESKey(key: EncryptionKey) = 108 | SecretKeySpec(key.key, "AES") 109 | 110 | } -------------------------------------------------------------------------------- /app/src/main/res/layout/view_doc_menu.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 20 | 21 | 33 | 34 | 39 | 40 | 51 | 52 | 60 | 61 | 62 | 63 | 68 | 69 | 75 | 76 | 82 | 83 | 89 | 90 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/data/InnerJsonObject.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.data 2 | 3 | import com.google.gson.JsonArray 4 | import com.google.gson.JsonElement 5 | import com.google.gson.JsonObject 6 | import com.google.gson.JsonPrimitive 7 | import java.text.SimpleDateFormat 8 | import java.util.* 9 | 10 | @Suppress("unused") 11 | data class InnerJsonObject( 12 | val json: JsonObject = JsonObject() 13 | ) { 14 | 15 | private val dateTimeFormat by lazy { 16 | SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ", Locale.getDefault()).also { 17 | it.timeZone = TimeZone.getTimeZone("UTC") 18 | } 19 | } 20 | private val fallbackDateTimeFormat by lazy { 21 | SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault()) 22 | } 23 | 24 | fun getObject(key: JsonKey) = optObject(key)!! 25 | fun optObject(key: JsonKey) = (json.get(key.value) as? JsonObject)?.toInnerJsonObject() 26 | fun optObjectOrEmpty(key: JsonKey) = optObject(key) ?: InnerJsonObject() 27 | fun set(key: JsonKey, value: InnerJsonObject?) = json.add(key.value, value?.json) 28 | 29 | fun getString(key: JsonKey) = optString(key)!! 30 | fun optString(key: JsonKey) = (json.get(key.value) as? JsonPrimitive)?.asString 31 | fun set(key: JsonKey, value: String?) = json.addProperty(key.value, value) 32 | 33 | fun getLong(key: JsonKey) = optLong(key)!! 34 | private fun optLong(key: JsonKey) = (json.get(key.value) as? JsonPrimitive)?.asLong 35 | fun set(key: JsonKey, value: Long?) = json.addProperty(key.value, value) 36 | 37 | fun getInt(key: JsonKey) = optInt(key)!! 38 | fun optInt(key: JsonKey) = (json.get(key.value) as? JsonPrimitive)?.asInt 39 | fun set(key: JsonKey, value: Int?) = json.addProperty(key.value, value) 40 | 41 | fun getBoolean(key: JsonKey) = optBoolean(key)!! 42 | fun optBoolean(key: JsonKey) = (json.get(key.value) as? JsonPrimitive)?.asBoolean 43 | fun set(key: JsonKey, value: Boolean?) = json.addProperty(key.value, value) 44 | 45 | fun getDate(key: JsonKey): Date = optDate(key) ?: Date() 46 | private fun optDate(key: JsonKey): Date? = optString(key)?.let { parseDateWithFallback(it) } 47 | fun set(key: JsonKey, value: Date?) = set(key, value?.let { dateTimeFormat.format(it) }) 48 | 49 | fun getListString(key: JsonKey) = optListString(key)!! 50 | fun optListString(key: JsonKey): List? = 51 | (json.get(key.value) as? JsonArray) 52 | ?.mapNotNull { (it as? JsonPrimitive)?.asString } 53 | 54 | fun set(key: JsonKey, value: List?) = 55 | json.add( 56 | key.value, 57 | value?.let { list -> 58 | JsonArray(list.size).also { jsonArray -> 59 | list.forEach { jsonArray.add(it) } 60 | } 61 | } 62 | ) 63 | 64 | override fun toString() = "InnerJsonObject(${hashCode()})" 65 | 66 | fun clone() = 67 | copy(json = json.deepCopy()) 68 | 69 | private fun parseDateWithFallback(value: String) = 70 | try { 71 | dateTimeFormat.parse(value) 72 | } catch (e: Throwable) { 73 | fallbackDateTimeFormat.parse(value) 74 | } 75 | 76 | } 77 | 78 | fun JsonObject.toInnerJsonObject() = 79 | InnerJsonObject(this) 80 | 81 | fun JsonElement.toInnerJsonObject() = 82 | (this as? JsonObject)?.let { InnerJsonObject(this) } 83 | 84 | interface JsonKey { 85 | val value: String 86 | } 87 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_donate.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | 13 | 18 | 19 | 31 | 32 | 39 | 40 | 49 | 50 | 60 | 61 | 71 | 72 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 22 | 23 | 29 | 30 | 36 | 37 | 38 | 51 | 52 | 60 | 61 | 67 | 68 | 69 | 78 | 79 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/ui/main/DocMenuViewModel.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.ui.main 2 | 3 | import app.envelop.common.Optional 4 | import app.envelop.data.models.Doc 5 | import app.envelop.domain.DeleteDocService 6 | import app.envelop.domain.DocLinkBuilder 7 | import app.envelop.domain.GetDocService 8 | import app.envelop.ui.BaseViewModel 9 | import app.envelop.ui.common.* 10 | import io.reactivex.rxkotlin.addTo 11 | import io.reactivex.subjects.BehaviorSubject 12 | import io.reactivex.subjects.PublishSubject 13 | import javax.inject.Inject 14 | 15 | class DocMenuViewModel 16 | @Inject constructor( 17 | getDocService: GetDocService, 18 | deleteDocService: DeleteDocService, 19 | docLinkBuilder: DocLinkBuilder 20 | ) : BaseViewModel() { 21 | 22 | private val docIdReceived = PublishSubject.create() 23 | private val deleteClicks = PublishSubject.create() 24 | private val deleteConfirmClicks = PublishSubject.create() 25 | 26 | private val doc = BehaviorSubject.create() 27 | private val link = BehaviorSubject.create() 28 | private val openDeleteConfirm = PublishSubject.create() 29 | private val openCannotDeleteConfirm = PublishSubject.create() 30 | private val isDeleting = BehaviorSubject.create() 31 | private val errors = BehaviorSubject.create() 32 | private val finish = BehaviorSubject.create() 33 | 34 | init { 35 | docIdReceived 36 | .switchMap { getDocService.get(it) } 37 | .subscribe { 38 | if (it is Optional.Some && !it.element.deleted) { 39 | doc.onNext(it.element) 40 | } 41 | } 42 | .addTo(disposables) 43 | 44 | docIdReceived 45 | .switchMap { getDocService.get(it) } 46 | .subscribe { 47 | if (it is Optional.None || it.element()?.deleted == true) { 48 | finish.finish() 49 | } 50 | } 51 | .addTo(disposables) 52 | 53 | doc 54 | .map { docLinkBuilder.build(it) } 55 | .subscribe(link::onNext) 56 | .addTo(disposables) 57 | 58 | deleteClicks 59 | .flatMap { doc.take(1) } 60 | .subscribe { 61 | if (it.canEdit) { 62 | openDeleteConfirm.open() 63 | } else { 64 | openCannotDeleteConfirm.open() 65 | } 66 | } 67 | .addTo(disposables) 68 | 69 | deleteConfirmClicks 70 | .doOnNext { isDeleting.loading() } 71 | .flatMap { doc.take(1) } 72 | .flatMapSingle { deleteDocService.markAsDeleted(it) } 73 | .subscribe { 74 | isDeleting.idle() 75 | if (!it.isSuccessful) { 76 | errors.onNext(Error.DeleteError) 77 | } 78 | finish.finish() 79 | } 80 | .addTo(disposables) 81 | } 82 | 83 | // Inputs 84 | 85 | fun docIdReceived(value: String) = docIdReceived.onNext(value) 86 | fun deleteClicked() = deleteClicks.click() 87 | fun deleteConfirmClicked() = deleteConfirmClicks.click() 88 | 89 | // Outputs 90 | 91 | fun doc() = doc.hide()!! 92 | fun link() = link.hide()!! 93 | fun openDeleteConfirm() = openDeleteConfirm.hide()!! 94 | fun openCannotDeleteConfirm() = openCannotDeleteConfirm.hide()!! 95 | fun isDeleting() = isDeleting.hide()!! 96 | fun errors() = errors.hide()!! 97 | fun finish() = finish.hide()!! 98 | 99 | sealed class Error { 100 | object DeleteError : Error() 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [1.4.0] - 2021-09-22 9 | ### Added 10 | - Sunset the app 11 | 12 | ## [1.3.1] - 2021-02-17 13 | ### Fixed 14 | - Crash with Android 30+ 15 | 16 | ## [1.3.0] - 2020-10-16 17 | ### Added 18 | - Blockstack v0.6 SDK, and updated login flow with Blockstack Connect 19 | 20 | ## [1.2.10] - 2020-09-07 21 | 22 | ### Fixed 23 | - Protected against potential Nullpointer exceptions on getDate from json object 24 | 25 | ## [1.2.9] - 2020-09-07 26 | 27 | ### Fixed 28 | - Error Message for Invalid Username 29 | - Socket Timeout Exceptions and GaiExceptions on unsubscribed RxSingles 30 | 31 | ## [1.2.8] - 2019-02-05 32 | 33 | ### Added 34 | - Support for gesture navigation and edge-to-edge rendering 35 | 36 | ### Fixed 37 | - File bottom-sheet menu position glitches 38 | - API21 crash on FAQ page 39 | 40 | ## [1.2.7] - 2019-02-03 41 | 42 | ### Fixed 43 | - Fix API21 crashes with scroll events call 44 | - Fix API21 stretched splash screen 45 | 46 | ## [1.2.6] - 2019-01-29 47 | 48 | ### Fixed 49 | - Fix crashes related with the file menu fragment 50 | 51 | ## [1.2.5] - 2019-01-20 52 | 53 | ### Fixed 54 | - `Context.startForegroundService` was causing ANRs issues, so it was replaced by `Context.startService()` 55 | - `getRelativeDateTimeStringUpload()` was returning wrong date formats for some Locales 56 | 57 | ## [1.2.4] - 2019-12-24 58 | 59 | ### Changed 60 | - Update to blockstack-android 0.5.0 61 | 62 | ## [1.2.3] - 2019-12-23 63 | 64 | ### Fixed 65 | - IllegalState crash with bottom sheet fragment 66 | 67 | ## [1.2.2] - 2019-11-21 68 | 69 | ### Added 70 | - Donations screen 71 | - FAQ screen 72 | 73 | ### Changed 74 | - Additional instructions on the initial sign in screen 75 | 76 | ### Fixed 77 | - Crash when dismissing file bottom menu 78 | - Date format changed to avoid compatibility issues with older Android API versions 79 | - Lint errors and warnings 80 | 81 | ## [1.2.1] - 2019-08-09 82 | 83 | ### Changed 84 | - Protect against duplicate doc ids 85 | 86 | ### Fixed 87 | - Added fallback for browsers that don't finish the login process correctly 88 | 89 | ## [1.2.0] - 2019-08-05 90 | 91 | ### Added 92 | - Now all files are encrypted in the storage. You don't need to trust your storage provider to use Envelop. 93 | 94 | ## [1.1.2] - 2019-07-16 95 | 96 | ### Changed 97 | - Prepare the app for the next features coming up to the Web and Android apps: 98 | - Store unknown file data as well, instead of dropping it. 99 | - Block file editions if the file was uploaded with a more recent version of the spec. 100 | 101 | ## [1.1.1] - 2019-07-15 102 | 103 | ### Added 104 | - File upload progress on the share screen 105 | - File sharing recommendations on the share screen 106 | 107 | ### Fixed 108 | - Handle files without name and/or without extension 109 | 110 | ## [1.1.0] - 2019-07-12 111 | 112 | ### Added 113 | - Large file upload - now you can upload files larger than 25 MB, through file partition (seamless) 114 | - Feedback button to quickly send an email to the Envelop team 115 | 116 | ## [1.0.1] - 2019-06-28 117 | 118 | ### Added 119 | - PDF file icon 120 | 121 | ### Changed 122 | - Video file icon. 123 | 124 | ## [1.0.0] - 2019-06-17 125 | 126 | - Initial version 127 | -------------------------------------------------------------------------------- /app/src/main/java/app/envelop/domain/DeleteDocService.kt: -------------------------------------------------------------------------------- 1 | package app.envelop.domain 2 | 3 | import app.envelop.common.FileHandler 4 | import app.envelop.common.Operation 5 | import app.envelop.common.doIfError 6 | import app.envelop.common.rx.observeOnIO 7 | import app.envelop.data.models.Doc 8 | import app.envelop.data.repositories.DocRepository 9 | import app.envelop.data.repositories.RemoteRepository 10 | import app.envelop.data.repositories.UploadRepository 11 | import io.reactivex.Completable 12 | import io.reactivex.Observable 13 | import io.reactivex.Single 14 | import io.reactivex.functions.BiFunction 15 | import io.reactivex.schedulers.Schedulers 16 | import timber.log.Timber 17 | import java.util.concurrent.TimeUnit 18 | import javax.inject.Inject 19 | 20 | class DeleteDocService 21 | @Inject constructor( 22 | private val docRepository: DocRepository, 23 | private val uploadRepository: UploadRepository, 24 | private val remoteRepository: RemoteRepository, 25 | private val updateDocRemotely: UpdateDocRemotely, 26 | private val fileHandler: FileHandler 27 | ) { 28 | 29 | fun markAsDeleted(doc: Doc) = 30 | Single 31 | .fromCallable { doc.copy(deleted = true) } 32 | .subscribeOn(Schedulers.io()) 33 | .observeOnIO() 34 | .flatMap { docRepository.save(it).toSingleDefault(it) } 35 | .flatMap { updateDocRemotely.update(it) } 36 | .flatMap { op -> 37 | if (op.isError) { 38 | doc.copy(deleted = false).let { docRepository.save(it).toSingleDefault(op) } 39 | } else Single.just(op) 40 | } 41 | 42 | fun deletePending(): Completable = 43 | docRepository 44 | .countDeleted() 45 | .filter { it > 0 } 46 | .flatMap { getFilesToDelete() } 47 | .distinctUntilChanged() 48 | .filter { it.isNotEmpty() } 49 | .map { it.first() } 50 | .concatMapSingle { delete(it) } 51 | .doIfError { Timber.w(it, "Delete error") } 52 | .ignoreElements() 53 | 54 | private fun delete(doc: Doc) = 55 | remoteRepository 56 | .getFilesList(prefix = doc.url) 57 | .doOnSuccess { if (it.isError) throw DeleteError(it.throwable()) } 58 | .flatMapObservable { Observable.fromIterable(it.result()) } 59 | // We need to throttle the deletes to avoid doing too many requests 60 | .wait(DELETE_THROTTLE, TimeUnit.MILLISECONDS) 61 | .concatMapSingle { remoteRepository.deleteFile(it) } 62 | // If we can't delete one part and it's not a 404 (already deleted), break the chain 63 | .doOnNext { if (!it.isSuccessful && !it.is404) throw DeleteError(it.throwable()) } 64 | .ignoreElements() 65 | .andThen(deleteLocalUploadFileIfNeeded(doc)) 66 | .andThen(docRepository.delete(doc)) 67 | .andThen(updateDocRemotely.delete(doc)) 68 | .onErrorReturn { Operation.error(it) } 69 | 70 | private fun deleteLocalUploadFileIfNeeded(doc: Doc): Completable = 71 | uploadRepository 72 | .getByDocId(doc.id) 73 | .take(1) 74 | .toObservable() 75 | .doOnNext { if (it.isNotEmpty()) fileHandler.deleteLocalFile(it.first().fileUri) } 76 | .ignoreElements() 77 | 78 | private fun getFilesToDelete() = 79 | docRepository.listDeleted().take(1) 80 | 81 | private fun Observable.wait(delay: Long, unit: TimeUnit): Observable = 82 | zipWith(Observable.interval(delay, unit), BiFunction { item, _ -> item }) 83 | 84 | class DeleteError(throwable: Throwable? = null) : Exception(throwable) 85 | 86 | companion object { 87 | private const val DELETE_THROTTLE = 1000L // ms 88 | } 89 | 90 | } --------------------------------------------------------------------------------