├── 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 |
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 | 
2 |
3 | # Android app 🤖
4 |
5 | DISCLAIMER: This project is no longer being maintained
6 |
7 | 
8 | 
9 | [](https://codecov.io/gh/envelop-app/envelop-android)
10 |
11 | 
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 | 
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 | }
--------------------------------------------------------------------------------