Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 460d421a79 | |||
| 96edbaa240 | |||
| de1566915b | |||
| a294d6f06c | |||
| 7ce017f48c | |||
| 55edc592a0 | |||
| eb5dfcbd46 | |||
| 2b55bd9e76 | |||
| eb8360f276 | |||
| 48f880ee4e | |||
| 9f5072ed3a | |||
| b1743db5af | |||
| f0b465f054 | |||
| 7bdcef8338 | |||
| c2823c1aad | |||
| 37b6a5492b | |||
| 9e632c2f58 | |||
| eaf948d3cd | |||
| 3242d3d742 | |||
| a90d724c06 | |||
| c257a730b0 | |||
| fcbd344491 | |||
| 773bb4259b | |||
| 3f6382e9c6 | |||
| 660346a856 | |||
| 93f2e0de57 | |||
| 1fbecc449a | |||
| c7d368dee6 | |||
| 5addeb8ea6 | |||
| 57364b4e14 | |||
| 4f9c44a4ad | |||
| 126bb507ba | |||
| f352303f6b | |||
| 53749eac5c | |||
| 34147063eb | |||
| edeb5ccb0d | |||
| 248ccc0606 | |||
| 726c9c974e | |||
| 087b0f4ec1 | |||
| a6cc38f4a1 | |||
| 96a4ec1738 | |||
| 42b77cab12 | |||
| a28e5d704d | |||
| b924b50ddc | |||
| 96503dd9c1 | |||
| c83504c155 | |||
| acea170854 | |||
| c6950493e6 | |||
| b71740d044 | |||
| dafe262c1c | |||
| acb1f75c5c | |||
| 03cc4f931d | |||
| 967d65b3f9 | |||
| ce6e7e2130 | |||
| 809eff7354 | |||
| 944cecce1f | |||
| 31abff58c1 | |||
| ef401378e2 | |||
| 2c0be54e61 | |||
| cb4b838082 | |||
| 6a6b40a89d | |||
| 1b7dbd91e6 | |||
| cb7a2d362f | |||
| ef2d7fe9d7 | |||
| 0bd96302e0 | |||
| e0a95a0314 | |||
| c2e7f7cbc2 | |||
| 631a2a4fe3 | |||
| 36e54f5d24 | |||
| 3506df6b22 | |||
| 2e1369d9a8 | |||
| a3495784f7 | |||
| 82c92c9031 | |||
| bd5d89c158 | |||
| 9cf96d86dd | |||
| f4c1e6a0f0 | |||
| 036d3846bc | |||
| 6696f0ff88 | |||
| ffff6a7617 | |||
| 3bd1f6e44a | |||
| a2f46b358d | |||
| a0e749e879 | |||
| e566b3c58d | |||
| acff8c1f5c | |||
| 4353fc5597 | |||
| 64b2b7424b | |||
| 2fa0dd2e59 | |||
| 2e1fa76b44 |
+32
@@ -0,0 +1,32 @@
|
||||
sudo: required
|
||||
language: android
|
||||
jdk: oraclejdk8
|
||||
dist: trusty
|
||||
|
||||
# Install Android SDK
|
||||
android:
|
||||
components:
|
||||
- tools
|
||||
- platform-tools
|
||||
- build-tools-28.0.2
|
||||
- android-28
|
||||
- extra-android-m2repository
|
||||
|
||||
before_install:
|
||||
# Hack to accept Android licenses
|
||||
- yes | sdkmanager "platforms;android-27"
|
||||
- yes | sdkmanager "platforms;android-28"
|
||||
|
||||
# Cache gradle dependencies
|
||||
# https://docs.travis-ci.com/user/languages/android/#Caching
|
||||
before_cache:
|
||||
- rm -f $HOME/.gradle/caches/modules-2/modules-2.lock
|
||||
- rm -fr $HOME/.gradle/caches/*/plugin-resolution/
|
||||
cache:
|
||||
directories:
|
||||
- $HOME/.gradle/caches/
|
||||
- $HOME/.gradle/wrapper/
|
||||
|
||||
script:
|
||||
- ./gradlew lint
|
||||
- ./gradlew assembleDebug
|
||||
@@ -0,0 +1,9 @@
|
||||
[main]
|
||||
host = https://www.transifex.com
|
||||
|
||||
[syncthing-lite.stringsxml]
|
||||
file_filter = app/src/main/res/values-<lang>/strings.xml
|
||||
source_file = app/src/main/res/values/strings.xml
|
||||
source_lang = en
|
||||
type = ANDROID
|
||||
lang_map = af_ZA: af-rZA, am_ET: am-rET, ar_AE: ar-rAE, ar_BH: ar-rBH, ar_DZ: ar-rDZ, ar_EG: ar-rEG, ar_IQ: ar-rIQ, ar_JO: ar-rJO, ar_KW: ar-rKW, ar_LB: ar-rLB, ar_LY: ar-rLY, ar_MA: ar-rMA, ar_OM: ar-rOM, ar_QA: ar-rQA, ar_SA: ar-rSA, ar_SY: ar-rSY, ar_TN: ar-rTN, ar_YE: ar-rYE, arn_CL: arn-rCL, as_IN: as-rIN, az_AZ: az-rAZ, ba_RU: ba-rRU, be_BY: be-rBY, bg_BG: bg-rBG, bn_BD: bn-rBD, bn_IN: bn-rIN, bo_CN: bo-rCN, br_FR: br-rFR, bs_BA: bs-rBA, ca_ES: ca-rES, co_FR: co-rFR, cs_CZ: cs-rCZ, cy_GB: cy-rGB, da_DK: da-rDK, de_AT: de-rAT, de_CH: de-rCH, de_DE: de-rDE, de_LI: de-rLI, de_LU: de-rLU, dsb_DE: dsb-rDE, dv_MV: dv-rMV, el_GR: el-rGR, en_AU: en-rAU, en_BZ: en-rBZ, en_CA: en-rCA, en_GB: en-rGB, en_IE: en-rIE, en_IN: en-rIN, en_JM: en-rJM, en_MY: en-rMY, en_NZ: en-rNZ, en_PH: en-rPH, en_SG: en-rSG, en_TT: en-rTT, en_US: en-rUS, en_ZA: en-rZA, en_ZW: en-rZW, es_AR: es-rAR, es_BO: es-rBO, es_CL: es-rCL, es_CO: es-rCO, es_CR: es-rCR, es_DO: es-rDO, es_EC: es-rEC, es_ES: es-rES, es_GT: es-rGT, es_HN: es-rHN, es_MX: es-rMX, es_NI: es-rNI, es_PA: es-rPA, es_PE: es-rPE, es_PR: es-rPR, es_PY: es-rPY, es_SV: es-rSV, es_US: es-rUS, es_UY: es-rUY, es_VE: es-rVE, et_EE: et-rEE, eu_ES: eu-rES, fa_IR: fa-rIR, fi_FI: fi-rFI, fil_PH: fil-rPH, fo_FO: fo-rFO, fr_BE: fr-rBE, fr_CA: fr-rCA, fr_CH: fr-rCH, fr_FR: fr-rFR, fr_LU: fr-rLU, fr_MC: fr-rMC, fy_NL: fy-rNL, ga_IE: ga-rIE, gd_GB: gd-rGB, gl_ES: gl-rES, gsw_FR: gsw-rFR, gu_IN: gu-rIN, ha_NG: ha-rNG, hi_IN: hi-rIN, hr_BA: hr-rBA, hr_HR: hr-rHR, hsb_DE: hsb-rDE, hu_HU: hu-rHU, hy_AM: hy-rAM, id_ID: id-rID, ig_NG: ig-rNG, ii_CN: ii-rCN, is_IS: is-rIS, it_CH: it-rCH, it_IT: it-rIT, iu_CA: iu-rCA, ja_JP: ja-rJP, ka_GE: ka-rGE, kk_KZ: kk-rKZ, kl_GL: kl-rGL, km_KH: km-rKH, kn_IN: kn-rIN, ko_KR: ko-rKR, kok_IN: kok-rIN, ky_KG: ky-rKG, lb_LU: lb-rLU, lo_LA: lo-rLA, lt_LT: lt-rLT, lv_LV: lv-rLV, mi_NZ: mi-rNZ, mk_MK: mk-rMK, ml_IN: ml-rIN, mn_CN: mn-rCN, mn_MN: mn-rMN, moh_CA: moh-rCA, mr_IN: mr-rIN, ms_BN: ms-rBN, ms_MY: ms-rMY, mt_MT: mt-rMT, nb_NO: nb-rNO, ne_NP: ne-rNP, nl_BE: nl-rBE, nl_NL: nl-rNL, nn_NO: nn-rNO, nso_ZA: nso-rZA, oc_FR: oc-rFR, or_IN: or-rIN, pa_IN: pa-rIN, pl_PL: pl-rPL, prs_AF: prs-rAF, ps_AF: ps-rAF, pt_BR: pt-rBR, pt_PT: pt-rPT, qut_GT: qut-rGT, quz_BO: quz-rBO, quz_EC: quz-rEC, quz_PE: quz-rPE, rm_CH: rm-rCH, ro_RO: ro-rRO, ru_RU: ru-rRU, rw_RW: rw-rRW, sa_IN: sa-rIN, sah_RU: sah-rRU, se_FI: se-rFI, se_NO: se-rNO, se_SE: se-rSE, si_LK: si-rLK, sk_SK: sk-rSK, sl_SI: sl-rSI, sma_NO: sma-rNO, sma_SE: sma-rSE, smj_NO: smj-rNO, smj_SE: smj-rSE, smn_FI: smn-rFI, sms_FI: sms-rFI, sq_AL: sq-rAL, sr_BA: sr-rBA, sr_CS: sr-rCS, sr_ME: sr-rME, sr_RS: sr-rRS, sv_FI: sv-rFI, sv_SE: sv-rSE, sw_KE: sw-rKE, syr_SY: syr-rSY, ta_IN: ta-rIN, te_IN: te-rIN, tg_TJ: tg-rTJ, th_TH: th-rTH, tk_TM: tk-rTM, tn_ZA: tn-rZA, tr_TR: tr-rTR, tt_RU: tt-rRU, tzm_DZ: tzm-rDZ, ug_CN: ug-rCN, uk_UA: uk-rUA, ur_PK: ur-rPK, uz_UZ: uz-rUZ, vi_VN: vi-rVN, wo_SN: wo-rSN, xh_ZA: xh-rZA, yo_NG: yo-rNG, zh_CN: zh-rCN, zh_HK: zh-rHK, zh_MO: zh-rMO, zh_SG: zh-rSG, zh_TW: zh-rTW, zu_ZA: zu-rZA, no_NO: no-rNO, he_IL: iw-rIL, he: iw, id:in
|
||||
@@ -1,33 +1,39 @@
|
||||
# Syncthing Lite
|
||||
|
||||
[](https://travis-ci.org/syncthing/syncthing-lite)
|
||||
[](https://www.mozilla.org/MPL/2.0/)
|
||||
|
||||
This project is an Android app, that works as a client for a [Syncthing][1] share (accessing
|
||||
Syncthing devices in the same way a client-server file sharing app access its proprietary server).
|
||||
Syncthing devices in the same way a client-server file sharing app accesses its proprietary server).
|
||||
|
||||
This is a client-oriented implementation, designed to work online by downloading and
|
||||
uploading files from an active device on the network (instead of synchronizing a local copy of
|
||||
the entire repository). This is quite different from the way the [syncthing-android][2] works,
|
||||
and its useful from those devices that cannot or wish not to download the entire repository (for
|
||||
and it's useful for those devices that cannot or do not wish to download the entire repository (for
|
||||
example, mobile devices with limited storage available, wishing to access a syncthing share).
|
||||
|
||||
This project is based on [syncthing-java][3], a java implementation of Syncthing protocols.
|
||||
|
||||
Due to the behaviour of this App and the [behaviour of the Syncthing Server](https://github.com/syncthing/syncthing/issues/5224),
|
||||
you can't reconnect for some minutes if the App was killed (due to removing from the recent App list) or the connection was interrupted.
|
||||
This does not apply to local discovery connections.
|
||||
|
||||
[<img alt="Get it on F-Droid" src="https://f-droid.org/badge/get-it-on.png" height="80">](https://f-droid.org/packages/net.syncthing.lite/)
|
||||
[<img alt="Get it on Google Play" src="https://play.google.com/intl/en_us/badges/images/generic/en_badge_web_generic.png" height="80">](https://play.google.com/store/apps/details?id=net.syncthing.lite)
|
||||
|
||||
## Translations
|
||||
|
||||
The project is translated on [Transifex](https://www.transifex.com/syncthing/syncthing-lite/).
|
||||
|
||||
## Building
|
||||
|
||||
The project uses a standard Android build, and requires the Android SDK. The easiest option is if
|
||||
you install [Android Studio][4] and import the project.
|
||||
|
||||
The syncthing-java library is not stable yet. If you encounter any build errors, you probably have
|
||||
to build it from source. To do this, clone the repo and run `gradle install`.
|
||||
The project uses a standard Android build, and requires the Android SDK. The easiest option is to
|
||||
install [Android Studio][3] and import the project.
|
||||
|
||||
## License
|
||||
All code is licensed under the [MPLv2 License][5].
|
||||
All code is licensed under the [MPLv2 License][4].
|
||||
|
||||
[1]: https://syncthing.net/
|
||||
[2]: https://github.com/syncthing/syncthing-android
|
||||
[3]: https://github.com/Nutomic/syncthing-java
|
||||
[4]: https://developer.android.com/studio/index.html
|
||||
[5]: LICENSE
|
||||
[3]: https://developer.android.com/studio/index.html
|
||||
[4]: LICENSE
|
||||
|
||||
+68
-17
@@ -1,18 +1,28 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
apply plugin: 'com.github.ben-manes.versions'
|
||||
apply plugin: 'kotlin-android-extensions'
|
||||
apply plugin: 'com.github.triplet.play'
|
||||
|
||||
android {
|
||||
compileSdkVersion 27
|
||||
buildToolsVersion "27.0.2"
|
||||
buildToolsVersion "28.0.2"
|
||||
dataBinding.enabled = true
|
||||
|
||||
playAccountConfigs {
|
||||
defaultAccountConfig {
|
||||
jsonFile = file(System.getenv("SYNCTHING_RELEASE_PLAY_ACCOUNT_CONFIG_FILE") ?: 'keys.json')
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "net.syncthing.lite"
|
||||
minSdkVersion 19
|
||||
targetSdkVersion 25
|
||||
versionCode 3
|
||||
versionName "0.1.1"
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 26
|
||||
versionCode 12
|
||||
versionName "0.3.2"
|
||||
multiDexEnabled true
|
||||
playAccountConfig = playAccountConfigs.defaultAccountConfig
|
||||
}
|
||||
sourceSets {
|
||||
main.java.srcDirs += "src/main/kotlin"
|
||||
@@ -21,26 +31,67 @@ android {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
signingConfigs {
|
||||
release {
|
||||
storeFile = {
|
||||
def path = System.getenv("SYNCTHING_LITE_RELEASE_STORE_FILE")
|
||||
return (path) ? file(path) : null
|
||||
}()
|
||||
storePassword System.getenv("SIGNING_PASSWORD") ?: ""
|
||||
keyAlias System.getenv("SYNCTHING_LITE_RELEASE_KEY_ALIAS") ?: ""
|
||||
keyPassword System.getenv("SIGNING_PASSWORD") ?: ""
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
minifyEnabled false
|
||||
}
|
||||
release {
|
||||
signingConfig signingConfigs.release
|
||||
minifyEnabled true
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
packagingOptions {
|
||||
exclude 'META-INF/*'
|
||||
}
|
||||
dataBinding {
|
||||
enabled = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
||||
kapt "com.android.databinding:compiler:$build_tools_version"
|
||||
implementation "com.android.support:appcompat-v7:$support_version"
|
||||
implementation "com.android.support:recyclerview-v7:$support_version"
|
||||
implementation "com.android.support:support-v4:$support_version"
|
||||
implementation "org.jetbrains.anko:anko-commons:$anko_version"
|
||||
implementation "org.jetbrains.anko:anko-coroutines:$anko_version"
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:0.30.2'
|
||||
implementation "com.android.support:design:$support_version"
|
||||
implementation "com.android.support:cardview-v7:$support_version"
|
||||
implementation ("com.github.Nutomic:syncthing-java:0.1") {
|
||||
exclude group: 'commons-logging', module:'commons-logging'
|
||||
exclude group: 'commons-codec'
|
||||
exclude group: 'org.apache.httpcomponents', module:'httpclient'
|
||||
implementation "com.android.support:preference-v14:$support_version"
|
||||
implementation "com.android.support:support-v4:$support_version"
|
||||
implementation 'android.arch.lifecycle:extensions:1.1.1'
|
||||
|
||||
/**
|
||||
* syncthing-java depends on the Apache HTTP Client
|
||||
* https://github.com/syncthing/syncthing-java/blob/dd020737ba5fc6a7c681a1d258025b8ddb2e8f67/core/build.gradle#L9
|
||||
*
|
||||
* Android itself contains an older version of this HTTP Client. Due to that, there is an
|
||||
* extra version of it which does not cause conflicts with the builtin client of Android.
|
||||
*
|
||||
* This extra implementation is included below. As this other version is used,
|
||||
* it's ignored as dependency of syncthing-java.
|
||||
*/
|
||||
implementation(project(':syncthing-client')) {
|
||||
exclude group: 'commons-logging', module: 'commons-logging'
|
||||
exclude group: 'org.apache.httpcomponents', module: 'httpclient'
|
||||
exclude group: 'org.slf4j'
|
||||
exclude group: 'ch.qos.logback'
|
||||
}
|
||||
implementation 'org.apache.httpcomponents:httpclient-android:4.3.5.1'
|
||||
|
||||
implementation 'sk.baka.slf4j:slf4j-handroid:1.7.26'
|
||||
implementation 'com.google.zxing:android-integration:3.3.0'
|
||||
implementation 'com.nononsenseapps:filepicker:2.5.2'
|
||||
implementation 'uk.co.markormesher:android-fab:2.0.0'
|
||||
implementation 'com.google.zxing:core:3.3.0'
|
||||
implementation 'com.github.apl-devs:appintro:v4.2.3'
|
||||
|
||||
implementation project(':syncthing-repository-android')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<lint>
|
||||
<issue id="MissingTranslation" severity="ignore" />
|
||||
|
||||
<issue id="GoogleAppIndexingWarning" severity="ignore" />
|
||||
|
||||
<issue id="InvalidPackage" severity="ignore" />
|
||||
|
||||
<issue id="OldTargetApi" severity="ignore" />
|
||||
</lint>
|
||||
Vendored
+89
@@ -0,0 +1,89 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# By default, the flags in this file are appended to flags specified
|
||||
# in /home/jonas/android-studio/sdk/tools/proguard/proguard-android.txt
|
||||
# You can edit the include path and order by changing the proguardFiles
|
||||
# directive in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# Add any project specific keep options here:
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# ensure that stack traces make sense
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# this library uses factories with reflection
|
||||
-keep class net.jpountz.lz4.** { *; }
|
||||
|
||||
# from https://github.com/Kotlin/kotlinx.coroutines/blob/master/ui/kotlinx-coroutines-android/example-app/app/proguard-rules.pro
|
||||
# kotlin coroutines crash without it
|
||||
-keepclassmembernames class kotlinx.** {
|
||||
volatile <fields>;
|
||||
}
|
||||
|
||||
# disable warnings
|
||||
-dontwarn com.google.protobuf.UnsafeUtil
|
||||
-dontwarn com.google.protobuf.UnsafeUtil$1
|
||||
-dontwarn net.jpountz.util.UnsafeUtils
|
||||
-dontwarn org.bouncycastle.cert.dane.fetcher.JndiDANEFetcherFactory
|
||||
-dontwarn org.bouncycastle.cert.dane.fetcher.JndiDANEFetcherFactory$1
|
||||
-dontwarn org.bouncycastle.jce.provider.X509LDAPCertStoreSpi
|
||||
-dontwarn org.bouncycastle.mail.smime.CMSProcessableBodyPart
|
||||
-dontwarn org.bouncycastle.mail.smime.CMSProcessableBodyPartInbound
|
||||
-dontwarn org.bouncycastle.mail.smime.CMSProcessableBodyPartOutbound
|
||||
-dontwarn org.bouncycastle.mail.smime.examples.CreateCompressedMail
|
||||
-dontwarn org.bouncycastle.mail.smime.examples.CreateEncryptedMail
|
||||
-dontwarn org.bouncycastle.mail.smime.examples.CreateLargeCompressedMail
|
||||
-dontwarn org.bouncycastle.mail.smime.examples.CreateLargeEncryptedMail
|
||||
-dontwarn org.bouncycastle.mail.smime.examples.CreateLargeSignedMail
|
||||
-dontwarn org.bouncycastle.mail.smime.examples.CreateSignedMail
|
||||
-dontwarn org.bouncycastle.mail.smime.examples.CreateSignedMultipartMail
|
||||
-dontwarn org.bouncycastle.mail.smime.examples.ExampleUtils
|
||||
-dontwarn org.bouncycastle.mail.smime.examples.ReadCompressedMail
|
||||
-dontwarn org.bouncycastle.mail.smime.examples.ReadEncryptedMail
|
||||
-dontwarn org.bouncycastle.mail.smime.examples.ReadLargeCompressedMail
|
||||
-dontwarn org.bouncycastle.mail.smime.examples.ReadLargeEncryptedMail
|
||||
-dontwarn org.bouncycastle.mail.smime.examples.ReadLargeSignedMail
|
||||
-dontwarn org.bouncycastle.mail.smime.examples.ReadSignedMail
|
||||
-dontwarn org.bouncycastle.mail.smime.examples.SendSignedAndEncryptedMail
|
||||
-dontwarn org.bouncycastle.mail.smime.examples.ValidateSignedMail
|
||||
-dontwarn org.bouncycastle.mail.smime.handlers.multipart_signed
|
||||
-dontwarn org.bouncycastle.mail.smime.handlers.multipart_signed$LineOutputStream
|
||||
-dontwarn org.bouncycastle.mail.smime.handlers.PKCS7ContentHandler
|
||||
-dontwarn org.bouncycastle.mail.smime.handlers.pkcs7_mime
|
||||
-dontwarn org.bouncycastle.mail.smime.handlers.pkcs7_signature
|
||||
-dontwarn org.bouncycastle.mail.smime.handlers.x_pkcs7_mime
|
||||
-dontwarn org.bouncycastle.mail.smime.handlers.x_pkcs7_signature
|
||||
-dontwarn org.bouncycastle.mail.smime.SMIMECompressed
|
||||
-dontwarn org.bouncycastle.mail.smime.SMIMECompressedGenerator
|
||||
-dontwarn org.bouncycastle.mail.smime.SMIMECompressedGenerator$1
|
||||
-dontwarn org.bouncycastle.mail.smime.SMIMECompressedGenerator$ContentCompressor
|
||||
-dontwarn org.bouncycastle.mail.smime.SMIMECompressedParser
|
||||
-dontwarn org.bouncycastle.mail.smime.SMIMEEnveloped
|
||||
-dontwarn org.bouncycastle.mail.smime.SMIMEEnvelopedGenerator
|
||||
-dontwarn org.bouncycastle.mail.smime.SMIMEEnvelopedGenerator$1
|
||||
-dontwarn org.bouncycastle.mail.smime.SMIMEEnvelopedGenerator$ContentEncryptor
|
||||
-dontwarn org.bouncycastle.mail.smime.SMIMEEnvelopedParser
|
||||
-dontwarn org.bouncycastle.mail.smime.SMIMEGenerator
|
||||
-dontwarn org.bouncycastle.mail.smime.SMIMESigned
|
||||
-dontwarn org.bouncycastle.mail.smime.SMIMESigned$1
|
||||
-dontwarn org.bouncycastle.mail.smime.SMIMESignedGenerator
|
||||
-dontwarn org.bouncycastle.mail.smime.SMIMESignedGenerator$1
|
||||
-dontwarn org.bouncycastle.mail.smime.SMIMESignedGenerator$ContentSigner
|
||||
-dontwarn org.bouncycastle.mail.smime.SMIMESignedParser
|
||||
-dontwarn org.bouncycastle.mail.smime.SMIMESignedParser$1
|
||||
-dontwarn org.bouncycastle.mail.smime.SMIMEToolkit
|
||||
-dontwarn org.bouncycastle.mail.smime.SMIMEUtil
|
||||
-dontwarn org.bouncycastle.mail.smime.SMIMEUtil$LineOutputStream
|
||||
-dontwarn org.bouncycastle.mail.smime.SMIMEUtil$WriteOnceFileBackedMimeBodyPart
|
||||
-dontwarn org.bouncycastle.mail.smime.util.FileBackedMimeBodyPart
|
||||
-dontwarn org.bouncycastle.mail.smime.util.SharedFileInputStream
|
||||
-dontwarn org.bouncycastle.mail.smime.validator.SignedMailValidator
|
||||
-dontwarn org.bouncycastle.x509.util.LDAPStoreHelper
|
||||
@@ -2,7 +2,6 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="net.syncthing.lite">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@@ -18,17 +17,29 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity android:name=".activities.IntroActivity"
|
||||
android:theme="@style/Theme.Syncthing.NoActionBar"/>
|
||||
<activity android:name=".activities.FolderBrowserActivity"
|
||||
android:parentActivityName=".activities.MainActivity"/>
|
||||
<activity
|
||||
android:name=".activities.MIVFilePickerActivity"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/FilePickerTheme">
|
||||
<provider
|
||||
android:name="android.support.v4.content.FileProvider"
|
||||
android:authorities="net.syncthing.lite.fileprovider"
|
||||
android:grantUriPermissions="true"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/filepaths" />
|
||||
</provider>
|
||||
<provider
|
||||
android:name=".library.SyncthingProvider"
|
||||
android:authorities="net.syncthing.lite.documents"
|
||||
android:grantUriPermissions="true"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.MANAGE_DOCUMENTS">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.GET_CONTENT" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
||||
@@ -1,221 +1,153 @@
|
||||
package net.syncthing.lite.activities
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.databinding.DataBindingUtil
|
||||
import android.os.AsyncTask
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.support.v4.app.ActivityCompat
|
||||
import android.support.v4.content.ContextCompat
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import com.google.common.base.Objects.equal
|
||||
import com.google.common.base.Preconditions.checkArgument
|
||||
import com.nononsenseapps.filepicker.FilePickerActivity
|
||||
import kotlinx.coroutines.experimental.android.UI
|
||||
import kotlinx.coroutines.experimental.async
|
||||
import net.syncthing.java.bep.IndexBrowser
|
||||
import net.syncthing.java.core.beans.FileInfo
|
||||
import net.syncthing.java.core.beans.FolderInfo
|
||||
import net.syncthing.java.core.utils.FileInfoOrdering
|
||||
import net.syncthing.java.core.utils.PathUtils
|
||||
import net.syncthing.lite.BuildConfig
|
||||
import net.syncthing.lite.R
|
||||
import net.syncthing.lite.adapters.FolderContentsAdapter
|
||||
import net.syncthing.lite.adapters.FolderContentsListener
|
||||
import net.syncthing.lite.databinding.ActivityFolderBrowserBinding
|
||||
import net.syncthing.lite.databinding.DialogLoadingBinding
|
||||
import net.syncthing.lite.utils.DownloadFileTask
|
||||
import net.syncthing.lite.utils.UploadFileTask
|
||||
import net.syncthing.lite.dialogs.FileUploadDialog
|
||||
import net.syncthing.lite.dialogs.ReconnectIssueDialogFragment
|
||||
import net.syncthing.lite.dialogs.downloadfile.DownloadFileDialogFragment
|
||||
import org.jetbrains.anko.custom.async
|
||||
|
||||
class FolderBrowserActivity : SyncthingActivity() {
|
||||
|
||||
companion object {
|
||||
|
||||
private val TAG = "FolderBrowserActivity"
|
||||
private val REQUEST_WRITE_STORAGE = 142
|
||||
private const val TAG = "FolderBrowserActivity"
|
||||
private const val REQUEST_SELECT_UPLOAD_FILE = 171
|
||||
|
||||
val EXTRA_FOLDER_NAME = "folder_name"
|
||||
const val EXTRA_FOLDER_NAME = "folder_name"
|
||||
}
|
||||
|
||||
private lateinit var binding: ActivityFolderBrowserBinding
|
||||
private var indexBrowser: IndexBrowser? = null
|
||||
private var loadingDialog: AlertDialog? = null
|
||||
private var adapter: FolderContentsAdapter? = null
|
||||
private var runWhenPermissionsReceived: Runnable? = null
|
||||
private lateinit var indexBrowser: IndexBrowser
|
||||
private val adapter = FolderContentsAdapter()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = DataBindingUtil.setContentView(this, R.layout.activity_folder_browser)
|
||||
binding.mainListViewUploadHereButton.setOnClickListener { showUploadHereDialog() }
|
||||
showFolderListView(intent.getStringExtra(EXTRA_FOLDER_NAME), null)
|
||||
binding.listView.adapter = adapter
|
||||
adapter.listener = object: FolderContentsListener {
|
||||
override fun onItemClicked(fileInfo: FileInfo) {
|
||||
navigateToFolder(fileInfo)
|
||||
}
|
||||
}
|
||||
val folder = intent.getStringExtra(EXTRA_FOLDER_NAME)
|
||||
libraryHandler?.syncthingClient {
|
||||
indexBrowser = it.indexHandler.newIndexBrowser(folder, true, true)
|
||||
indexBrowser.setOnFolderChangedListener(this::onFolderChanged)
|
||||
}
|
||||
|
||||
ReconnectIssueDialogFragment.showIfNeeded(this)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
Thread {
|
||||
indexBrowser?.close()
|
||||
indexBrowser = null
|
||||
indexBrowser.setOnFolderChangedListener(null)
|
||||
indexBrowser.close()
|
||||
}.start()
|
||||
cancelLoadingDialog()
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
val listView = binding.mainFolderAndFilesListView
|
||||
//click item '0', ie '..' (go to parent)
|
||||
listView.performItemClick(adapter!!.getView(0, null, listView), 0, listView.getItemIdAtPosition(0))
|
||||
navigateToFolder(adapter.data[0])
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent) {
|
||||
if (resultCode == Activity.RESULT_OK) {
|
||||
UploadFileTask(this, syncthingClient(), intent.data, indexBrowser!!.folder,
|
||||
indexBrowser!!.currentPath, { this.updateFolderListView() }).uploadFile()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showLoadingDialog(message: String) {
|
||||
val binding = DataBindingUtil.inflate<DialogLoadingBinding>(
|
||||
layoutInflater, R.layout.dialog_loading, null, false)
|
||||
binding.loadingText.text = message
|
||||
loadingDialog = android.app.AlertDialog.Builder(this)
|
||||
.setCancelable(false)
|
||||
.setView(binding.root)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun cancelLoadingDialog() {
|
||||
loadingDialog?.cancel()
|
||||
loadingDialog = null
|
||||
}
|
||||
|
||||
private fun showFolderListView(folder: String, previousPath: String?) {
|
||||
if (indexBrowser != null && equal(folder, indexBrowser!!.folder)) {
|
||||
Log.d(TAG, "reuse current index browser")
|
||||
indexBrowser!!.navigateToNearestPath(previousPath)
|
||||
} else {
|
||||
if (indexBrowser != null) {
|
||||
indexBrowser!!.close()
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) {
|
||||
if (requestCode == REQUEST_SELECT_UPLOAD_FILE && resultCode == Activity.RESULT_OK) {
|
||||
libraryHandler?.syncthingClient { syncthingClient ->
|
||||
async (UI) {
|
||||
// FIXME: it would be better if the dialog would use the library handler
|
||||
FileUploadDialog(this@FolderBrowserActivity, syncthingClient, intent!!.data,
|
||||
indexBrowser.folder, indexBrowser.currentPath,
|
||||
{ showFolderListView(indexBrowser.currentPath) }).show()
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "open new index browser")
|
||||
indexBrowser = syncthingClient().indexHandler
|
||||
.newIndexBrowserBuilder()
|
||||
.setOrdering(FileInfoOrdering.ALPHA_ASC_DIR_FIRST)
|
||||
.includeParentInList(true).allowParentInRoot(true)
|
||||
.setFolder(folder)
|
||||
.buildToNearestPath(previousPath)
|
||||
}
|
||||
adapter = FolderContentsAdapter(this)
|
||||
binding.mainFolderAndFilesListView.adapter = adapter
|
||||
binding.mainFolderAndFilesListView.setOnItemClickListener { _, _, position, _ ->
|
||||
val fileInfo = binding.mainFolderAndFilesListView.getItemAtPosition(position) as FileInfo
|
||||
Log.d(TAG, "navigate to path = '" + fileInfo.path + "' from path = '" + indexBrowser!!.currentPath + "'")
|
||||
navigateToFolder(fileInfo)
|
||||
}
|
||||
navigateToFolder(indexBrowser!!.currentPathInfo)
|
||||
}
|
||||
|
||||
private fun showFolderListView(path: String) {
|
||||
indexBrowser.navigateToNearestPath(path)
|
||||
navigateToFolder(indexBrowser.currentPathInfo())
|
||||
}
|
||||
|
||||
private fun navigateToFolder(fileInfo: FileInfo) {
|
||||
if (indexBrowser!!.isRoot && PathUtils.isParent(fileInfo.path)) {
|
||||
Log.d(TAG, "navigate to path = '" + fileInfo.path + "' from path = '" + indexBrowser.currentPath + "'")
|
||||
if (indexBrowser.isRoot() && PathUtils.isParent(fileInfo.path)) {
|
||||
finish()
|
||||
} else {
|
||||
if (fileInfo.isDirectory) {
|
||||
indexBrowser!!.navigateTo(fileInfo)
|
||||
val newFileInfo = if (PathUtils.isParent(fileInfo.path)) indexBrowser!!.currentPathInfo else fileInfo
|
||||
if (!indexBrowser!!.isCacheReadyAfterALittleWait) {
|
||||
Log.d(TAG, "load folder cache bg")
|
||||
object : AsyncTask<Void?, Void?, Void?>() {
|
||||
override fun onPreExecute() {
|
||||
// TODO: show ProgressBar in ListView instead of dialog
|
||||
showLoadingDialog("open directory: " +
|
||||
if (indexBrowser!!.isRoot) folderBrowser().getFolderInfo(indexBrowser!!.folder).label
|
||||
else indexBrowser!!.currentPathFileName)
|
||||
}
|
||||
|
||||
override fun doInBackground(vararg voids: Void?): Void? {
|
||||
indexBrowser!!.waitForCacheReady()
|
||||
return null
|
||||
}
|
||||
|
||||
override fun onPostExecute(aVoid: Void?) {
|
||||
Log.d(TAG, "cache ready, navigate to folder")
|
||||
cancelLoadingDialog()
|
||||
navigateToFolder(newFileInfo)
|
||||
}
|
||||
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR)
|
||||
} else {
|
||||
val list = indexBrowser!!.listFiles()
|
||||
Log.i("navigateToFolder", "list for path = '" + indexBrowser!!.currentPath + "' list = " + list.size + " records")
|
||||
Log.d("navigateToFolder", "list for path = '" + indexBrowser!!.currentPath + "' list = " + list)
|
||||
checkArgument(!list.isEmpty())//list must contain at least the 'parent' path
|
||||
adapter!!.clear()
|
||||
adapter!!.addAll(list)
|
||||
adapter!!.notifyDataSetChanged()
|
||||
binding.mainFolderAndFilesListView.setSelection(0)
|
||||
supportActionBar!!.setTitle(if (indexBrowser!!.isRoot)
|
||||
folderBrowser().getFolderInfo(indexBrowser!!.folder).label
|
||||
else
|
||||
newFileInfo.fileName)
|
||||
if (fileInfo.isDirectory()) {
|
||||
async {
|
||||
indexBrowser.navigateTo(fileInfo)
|
||||
}
|
||||
|
||||
Log.d(TAG, "load folder cache bg")
|
||||
binding.isLoading = true
|
||||
} else {
|
||||
Log.i(TAG, "pulling file = " + fileInfo)
|
||||
executeWithPermissions(
|
||||
Runnable { DownloadFileTask(this, syncthingClient(), fileInfo).downloadFile() })
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.i(TAG, "pulling file = " + fileInfo)
|
||||
}
|
||||
|
||||
DownloadFileDialogFragment.newInstance(fileInfo).show(supportFragmentManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onFolderChanged() {
|
||||
runOnUiThread {
|
||||
binding.isLoading = false
|
||||
|
||||
async {
|
||||
val list = indexBrowser.listFiles()
|
||||
|
||||
async (UI) {
|
||||
Log.i("navigateToFolder", "list for path = '" + indexBrowser.currentPath + "' list = " + list.size + " records")
|
||||
Log.d("navigateToFolder", "list for path = '" + indexBrowser.currentPath + "' list = " + list)
|
||||
assert(!list.isEmpty())//list must contain at least the 'parent' path
|
||||
adapter.data = list
|
||||
binding.listView.scrollToPosition(0)
|
||||
if (indexBrowser.isRoot())
|
||||
libraryHandler?.folderBrowser {
|
||||
val title = it.getFolderInfo(indexBrowser.folder)?.label
|
||||
|
||||
async(UI) {
|
||||
supportActionBar?.title = title
|
||||
}
|
||||
}
|
||||
else
|
||||
supportActionBar?.title = indexBrowser.currentPathInfo().fileName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateFolderListView() {
|
||||
showFolderListView(indexBrowser!!.folder, indexBrowser!!.currentPath)
|
||||
showFolderListView(indexBrowser.currentPath)
|
||||
}
|
||||
|
||||
private fun showUploadHereDialog() {
|
||||
executeWithPermissions(Runnable {
|
||||
val i = Intent(this, MIVFilePickerActivity::class.java)
|
||||
val path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath
|
||||
Log.i(TAG, "showUploadHereDialog path = " + path)
|
||||
i.putExtra(FilePickerActivity.EXTRA_START_PATH, path)
|
||||
startActivityForResult(i, 0)
|
||||
})
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
intent.type = "*/*"
|
||||
startActivityForResult(intent, REQUEST_SELECT_UPLOAD_FILE)
|
||||
}
|
||||
|
||||
override fun onIndexUpdateProgress(folder: FolderInfo, percentage: Int) {
|
||||
binding.mainIndexProgressBarLabel.text = ("index update, folder "
|
||||
+ folder.label + " " + percentage + "% synchronized")
|
||||
override fun onIndexUpdateComplete(folderInfo: FolderInfo) {
|
||||
super.onIndexUpdateComplete(folderInfo)
|
||||
updateFolderListView()
|
||||
}
|
||||
|
||||
override fun onIndexUpdateComplete() {
|
||||
binding.mainIndexProgressBar.visibility = View.GONE
|
||||
updateFolderListView()
|
||||
}
|
||||
|
||||
private fun executeWithPermissions(runnable: Runnable) {
|
||||
val permissionState = ContextCompat.checkSelfPermission(this,
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
if (permissionState != PackageManager.PERMISSION_GRANTED) {
|
||||
ActivityCompat.requestPermissions(this,
|
||||
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
|
||||
REQUEST_WRITE_STORAGE)
|
||||
runWhenPermissionsReceived = runnable
|
||||
} else {
|
||||
runnable.run()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>,
|
||||
grantResults: IntArray) {
|
||||
when (requestCode) {
|
||||
REQUEST_WRITE_STORAGE -> {
|
||||
if (grantResults.isEmpty() || grantResults[0] != PackageManager.PERMISSION_GRANTED) {
|
||||
Toast.makeText(this, R.string.toast_write_storage_permission_required,
|
||||
Toast.LENGTH_LONG).show()
|
||||
} else {
|
||||
runWhenPermissionsReceived!!.run()
|
||||
}
|
||||
runWhenPermissionsReceived = null
|
||||
}
|
||||
else -> super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package net.syncthing.lite.activities
|
||||
|
||||
import android.arch.lifecycle.Observer
|
||||
import android.content.Intent
|
||||
import android.databinding.DataBindingUtil
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.support.v4.app.Fragment
|
||||
import android.support.v4.content.ContextCompat
|
||||
import android.text.Html
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import com.github.paolorotolo.appintro.AppIntro
|
||||
import com.google.zxing.integration.android.IntentIntegrator
|
||||
import kotlinx.coroutines.experimental.android.UI
|
||||
import kotlinx.coroutines.experimental.async
|
||||
import net.syncthing.java.core.beans.DeviceId
|
||||
import net.syncthing.lite.R
|
||||
import net.syncthing.lite.databinding.FragmentIntroOneBinding
|
||||
import net.syncthing.lite.databinding.FragmentIntroThreeBinding
|
||||
import net.syncthing.lite.databinding.FragmentIntroTwoBinding
|
||||
import net.syncthing.lite.fragments.SyncthingFragment
|
||||
import net.syncthing.lite.utils.FragmentIntentIntegrator
|
||||
import net.syncthing.lite.utils.Util
|
||||
import org.jetbrains.anko.defaultSharedPreferences
|
||||
import org.jetbrains.anko.intentFor
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Shown when a user first starts the app. Shows some info and helps the user to add their first
|
||||
* device and folder.
|
||||
*/
|
||||
class IntroActivity : AppIntro() {
|
||||
|
||||
/**
|
||||
* Initialize fragments and library parameters.
|
||||
*/
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Disable continue button on second slide until a valid device ID is entered.
|
||||
nextButton.setOnClickListener {
|
||||
val fragment = fragments[pager.currentItem]
|
||||
if (fragment !is IntroFragmentTwo || fragment.isDeviceIdValid()) {
|
||||
pager.goToNextSlide()
|
||||
}
|
||||
}
|
||||
|
||||
addSlide(IntroFragmentOne())
|
||||
addSlide(IntroFragmentTwo())
|
||||
addSlide(IntroFragmentThree())
|
||||
|
||||
setSeparatorColor(ContextCompat.getColor(this, android.R.color.primary_text_dark))
|
||||
showSkipButton(true)
|
||||
isProgressButtonEnabled = true
|
||||
pager.isPagingEnabled = false
|
||||
}
|
||||
|
||||
override fun onSkipPressed(currentFragment: Fragment) {
|
||||
onDonePressed(currentFragment)
|
||||
}
|
||||
|
||||
override fun onDonePressed(currentFragment: Fragment) {
|
||||
defaultSharedPreferences.edit().putBoolean(MainActivity.PREF_IS_FIRST_START, false).apply()
|
||||
startActivity(intentFor<MainActivity>())
|
||||
finish()
|
||||
}
|
||||
|
||||
/**
|
||||
* Display some simple welcome text.
|
||||
*/
|
||||
class IntroFragmentOne : SyncthingFragment() {
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val binding = FragmentIntroOneBinding.inflate(inflater, container, false)
|
||||
|
||||
libraryHandler.isListeningPortTaken.observe(this, Observer { binding.listeningPortTaken = it })
|
||||
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onLibraryLoaded() {
|
||||
super.onLibraryLoaded()
|
||||
|
||||
libraryHandler.configuration { config ->
|
||||
config.localDeviceName = Util.getDeviceName()
|
||||
config.persistLater()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display device ID entry field and QR scanner option.
|
||||
*/
|
||||
class IntroFragmentTwo : SyncthingFragment() {
|
||||
|
||||
private lateinit var binding: FragmentIntroTwoBinding
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_intro_two, container, false)
|
||||
binding.enterDeviceId!!.scanQrCode.setOnClickListener {
|
||||
FragmentIntentIntegrator(this@IntroFragmentTwo).initiateScan()
|
||||
}
|
||||
binding.enterDeviceId!!.scanQrCode.setImageResource(R.drawable.ic_qr_code_white_24dp)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) {
|
||||
val scanResult = IntentIntegrator.parseActivityResult(requestCode, resultCode, intent)
|
||||
if (scanResult?.contents != null && scanResult.contents.isNotBlank()) {
|
||||
binding.enterDeviceId!!.deviceId.setText(scanResult.contents)
|
||||
binding.enterDeviceId!!.deviceIdHolder.isErrorEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the entered device ID is valid. If yes, imports it and returns true. If not,
|
||||
* sets an error on the textview and returns false.
|
||||
*/
|
||||
fun isDeviceIdValid(): Boolean {
|
||||
return try {
|
||||
val deviceId = binding.enterDeviceId!!.deviceId.text.toString()
|
||||
Util.importDeviceId(libraryHandler, context, deviceId, { })
|
||||
true
|
||||
} catch (e: IOException) {
|
||||
binding.enterDeviceId!!.deviceId.error = getString(R.string.invalid_device_id)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private val addedDeviceIds = HashSet<DeviceId>()
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
binding.foundDevices.removeAllViews()
|
||||
addedDeviceIds.clear()
|
||||
|
||||
libraryHandler.registerMessageFromUnknownDeviceListener(onDeviceFound)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
|
||||
libraryHandler.unregisterMessageFromUnknownDeviceListener(onDeviceFound)
|
||||
}
|
||||
|
||||
private val onDeviceFound: (DeviceId) -> Unit = {
|
||||
deviceId ->
|
||||
|
||||
if (addedDeviceIds.add(deviceId)) {
|
||||
binding.foundDevices.addView(
|
||||
Button(context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
text = deviceId.deviceId
|
||||
|
||||
setOnClickListener {
|
||||
binding.enterDeviceId.deviceId.setText(deviceId.deviceId)
|
||||
binding.enterDeviceId.deviceIdHolder.isErrorEnabled = false
|
||||
|
||||
binding.scroll.scrollTo(0, 0)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until remote device connects with new folder.
|
||||
*/
|
||||
class IntroFragmentThree : SyncthingFragment() {
|
||||
|
||||
private lateinit var binding: FragmentIntroThreeBinding
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_intro_three, container, false)
|
||||
|
||||
libraryHandler.library { config, client, _ ->
|
||||
async(UI) {
|
||||
client.addOnConnectionChangedListener(this@IntroFragmentThree::onConnectionChanged)
|
||||
val deviceId = config.localDeviceId.deviceId
|
||||
val desc = activity?.getString(R.string.intro_page_three_description, "<b>$deviceId</b>")
|
||||
binding.description.text = Html.fromHtml(desc)
|
||||
}
|
||||
}
|
||||
|
||||
return binding.root
|
||||
}
|
||||
|
||||
private fun onConnectionChanged(deviceId: DeviceId) {
|
||||
libraryHandler.library { config, client, _ ->
|
||||
async(UI) {
|
||||
if (config.folders.isNotEmpty()) {
|
||||
client.removeOnConnectionChangedListener(this@IntroFragmentThree::onConnectionChanged)
|
||||
(activity as IntroActivity?)?.onDonePressed(this@IntroFragmentThree)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package net.syncthing.lite.activities
|
||||
|
||||
import com.nononsenseapps.filepicker.AbstractFilePickerActivity
|
||||
import com.nononsenseapps.filepicker.AbstractFilePickerFragment
|
||||
|
||||
import net.syncthing.lite.fragments.MIVFilePickerFragment
|
||||
|
||||
import java.io.File
|
||||
|
||||
class MIVFilePickerActivity : AbstractFilePickerActivity<File>() {
|
||||
|
||||
override fun getFragment(startPath: String, mode: Int, allowMultiple: Boolean,
|
||||
allowCreateDir: Boolean): AbstractFilePickerFragment<File> {
|
||||
// Only the fragment in this line needs to be changed
|
||||
val fragment = MIVFilePickerFragment()
|
||||
fragment.setArgs(startPath, mode, allowMultiple, allowCreateDir)
|
||||
return fragment
|
||||
}
|
||||
}
|
||||
@@ -8,22 +8,34 @@ import android.support.v4.app.Fragment
|
||||
import android.support.v7.app.ActionBarDrawerToggle
|
||||
import android.view.Gravity
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import net.syncthing.java.core.beans.FolderInfo
|
||||
import kotlinx.coroutines.experimental.android.UI
|
||||
import kotlinx.coroutines.experimental.async
|
||||
import net.syncthing.lite.R
|
||||
import net.syncthing.lite.databinding.ActivityMainBinding
|
||||
import net.syncthing.lite.dialogs.DeviceIdDialogFragment
|
||||
import net.syncthing.lite.fragments.DevicesFragment
|
||||
import net.syncthing.lite.fragments.FoldersFragment
|
||||
import net.syncthing.lite.utils.UpdateIndexTask
|
||||
import net.syncthing.lite.fragments.SettingsFragment
|
||||
import org.jetbrains.anko.defaultSharedPreferences
|
||||
import org.jetbrains.anko.intentFor
|
||||
|
||||
class MainActivity : SyncthingActivity() {
|
||||
|
||||
companion object {
|
||||
const val PREF_IS_FIRST_START = "net.syncthing.lite.activities.MainActivity.IS_FIRST_START"
|
||||
}
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
private var drawerToggle: ActionBarDrawerToggle? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (defaultSharedPreferences.getBoolean(PREF_IS_FIRST_START, true)) {
|
||||
startActivity(intentFor<IntroActivity>())
|
||||
finish()
|
||||
}
|
||||
|
||||
binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
|
||||
|
||||
drawerToggle = ActionBarDrawerToggle(
|
||||
@@ -34,15 +46,19 @@ class MainActivity : SyncthingActivity() {
|
||||
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the toggle state and fragment after onRestoreInstanceState has occurred.
|
||||
*/
|
||||
override fun onPostCreate(savedInstanceState: Bundle?) {
|
||||
super.onPostCreate(savedInstanceState)
|
||||
// Sync the toggle state after onRestoreInstanceState has occurred.
|
||||
drawerToggle!!.syncState()
|
||||
}
|
||||
|
||||
override fun onLibraryLoaded() {
|
||||
super.onLibraryLoaded()
|
||||
setContentFragment(FoldersFragment())
|
||||
drawerToggle!!.syncState()
|
||||
val menu = binding.navigation.menu
|
||||
val selection = (0 until menu.size())
|
||||
.map { menu.getItem(it) }
|
||||
.find { it.isChecked }
|
||||
?: menu.getItem(0)
|
||||
onNavigationItemSelectedListener(selection)
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
@@ -57,17 +73,17 @@ class MainActivity : SyncthingActivity() {
|
||||
true
|
||||
} else super.onOptionsItemSelected(item)
|
||||
// Handle your other action bar items...
|
||||
|
||||
}
|
||||
|
||||
private fun onNavigationItemSelectedListener(menuItem: MenuItem): Boolean {
|
||||
when (menuItem.itemId) {
|
||||
R.id.folders -> setContentFragment(FoldersFragment())
|
||||
R.id.devices -> setContentFragment(DevicesFragment())
|
||||
R.id.update_index -> UpdateIndexTask(this, syncthingClient()).updateIndex()
|
||||
R.id.settings -> setContentFragment(SettingsFragment())
|
||||
R.id.device_id -> DeviceIdDialogFragment().show(supportFragmentManager)
|
||||
R.id.clear_index -> AlertDialog.Builder(this)
|
||||
.setTitle("clear cache and index")
|
||||
.setMessage("clear all cache data and index data?")
|
||||
.setTitle(getString(R.string.clear_cache_and_index_title))
|
||||
.setMessage(getString(R.string.clear_cache_and_index_body))
|
||||
.setIcon(android.R.drawable.ic_dialog_alert)
|
||||
.setPositiveButton(android.R.string.yes) { _, _ -> cleanCacheAndIndex() }
|
||||
.setNegativeButton(android.R.string.no, null)
|
||||
@@ -85,17 +101,9 @@ class MainActivity : SyncthingActivity() {
|
||||
}
|
||||
|
||||
private fun cleanCacheAndIndex() {
|
||||
syncthingClient().clearCacheAndIndex()
|
||||
recreate()
|
||||
}
|
||||
|
||||
override fun onIndexUpdateProgress(folder: FolderInfo, percentage: Int) {
|
||||
binding.mainIndexProgressBar.visibility = View.VISIBLE
|
||||
binding.mainIndexProgressBarLabel.text = ("index update, folder "
|
||||
+ folder.label + " " + percentage + "% synchronized")
|
||||
}
|
||||
|
||||
override fun onIndexUpdateComplete() {
|
||||
binding.mainIndexProgressBar.visibility = View.GONE
|
||||
async(UI) {
|
||||
libraryHandler.syncthingClient { it.clearCacheAndIndex() }
|
||||
recreate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,107 +3,76 @@ package net.syncthing.lite.activities
|
||||
import android.app.AlertDialog
|
||||
import android.content.Context
|
||||
import android.databinding.DataBindingUtil
|
||||
import android.os.AsyncTask
|
||||
import android.os.Bundle
|
||||
import android.preference.PreferenceManager
|
||||
import android.support.design.widget.Snackbar
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import net.syncthing.java.bep.FolderBrowser
|
||||
import net.syncthing.java.client.SyncthingClient
|
||||
import net.syncthing.java.core.beans.FolderInfo
|
||||
import net.syncthing.java.core.configuration.ConfigurationService
|
||||
import net.syncthing.lite.BuildConfig
|
||||
import net.syncthing.lite.R
|
||||
import net.syncthing.lite.databinding.DialogLoadingBinding
|
||||
import net.syncthing.lite.utils.LibraryHandler
|
||||
import net.syncthing.lite.utils.UpdateIndexTask
|
||||
import net.syncthing.lite.library.LibraryHandler
|
||||
import org.jetbrains.anko.contentView
|
||||
import org.slf4j.impl.HandroidLoggerAdapter
|
||||
import java.util.*
|
||||
|
||||
abstract class SyncthingActivity : AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
private val TAG = "SyncthingActivity"
|
||||
|
||||
private var activityCount = 0
|
||||
private var libraryHandler: LibraryHandler? = null
|
||||
val libraryHandler: LibraryHandler by lazy {
|
||||
LibraryHandler(
|
||||
context = this@SyncthingActivity,
|
||||
onIndexUpdateProgressListener = this::onIndexUpdateProgress,
|
||||
onIndexUpdateCompleteListener = this::onIndexUpdateComplete
|
||||
)
|
||||
}
|
||||
|
||||
fun syncthingClient(): SyncthingClient = libraryHandler!!.syncthingClient!!
|
||||
|
||||
fun configuration(): ConfigurationService = libraryHandler!!.configuration!!
|
||||
|
||||
fun folderBrowser(): FolderBrowser = libraryHandler!!.folderBrowser!!
|
||||
private var loadingDialog: AlertDialog? = null
|
||||
private var snackBar: Snackbar? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
HandroidLoggerAdapter.DEBUG = BuildConfig.DEBUG
|
||||
activityCount++
|
||||
if (libraryHandler == null) {
|
||||
InitTask(this, this::onLibraryLoaded).execute()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
activityCount--
|
||||
Thread {
|
||||
if (activityCount == 0) {
|
||||
libraryHandler!!.destroy()
|
||||
libraryHandler = null
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
val binding = DataBindingUtil.inflate<DialogLoadingBinding>(
|
||||
LayoutInflater.from(this), R.layout.dialog_loading, null, false)
|
||||
binding.loadingText.text = getString(R.string.loading_config_starting_syncthing_client)
|
||||
|
||||
loadingDialog = AlertDialog.Builder(this)
|
||||
.setCancelable(false)
|
||||
.setView(binding.root)
|
||||
.show()
|
||||
|
||||
libraryHandler.start {
|
||||
if (!isDestroyed) {
|
||||
loadingDialog?.dismiss()
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private class InitTask(val context: Context, val onLibraryLoaded: () -> Unit)
|
||||
: AsyncTask<Void?, Void?, Void?>() {
|
||||
|
||||
private var loadingDialog: AlertDialog? = null
|
||||
|
||||
override fun onPreExecute() {
|
||||
val binding = DataBindingUtil.inflate<DialogLoadingBinding>(
|
||||
LayoutInflater.from(context), R.layout.dialog_loading, null, false)
|
||||
binding.loadingText.text = "loading config, starting syncthing client"
|
||||
loadingDialog = android.app.AlertDialog.Builder(context)
|
||||
.setCancelable(false)
|
||||
.setView(binding.root)
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun doInBackground(vararg voidd: Void?): Void? {
|
||||
libraryHandler = LibraryHandler()
|
||||
libraryHandler!!.init(context)
|
||||
return null
|
||||
}
|
||||
|
||||
override fun onPostExecute(voidd: Void?) {
|
||||
loadingDialog!!.cancel()
|
||||
libraryHandler!!.setOnIndexUpdatedListener(object : LibraryHandler.OnIndexUpdatedListener {
|
||||
override fun onIndexUpdateProgress(folder: FolderInfo, percentage: Int) {
|
||||
onIndexUpdateProgress(folder, percentage)
|
||||
}
|
||||
|
||||
override fun onIndexUpdateComplete() {
|
||||
onIndexUpdateComplete()
|
||||
}
|
||||
})
|
||||
|
||||
//trigger update if last was more than 10mins ago
|
||||
val lastUpdateMillis = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
.getLong(UpdateIndexTask.LAST_INDEX_UPDATE_TS_PREF, -1)
|
||||
val lastUpdateTimeAgo = Date().time - lastUpdateMillis
|
||||
if (lastUpdateMillis == -1L || lastUpdateTimeAgo > 10 * 60 * 1000) {
|
||||
Log.d(TAG, "trigger index update, last was " + Date(lastUpdateMillis))
|
||||
UpdateIndexTask(context, libraryHandler!!.syncthingClient!!).updateIndex()
|
||||
}
|
||||
onLibraryLoaded()
|
||||
}
|
||||
}
|
||||
|
||||
open fun onIndexUpdateProgress(folder: FolderInfo, percentage: Int) {}
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
|
||||
open fun onIndexUpdateComplete() {}
|
||||
libraryHandler.stop()
|
||||
loadingDialog?.dismiss()
|
||||
}
|
||||
|
||||
open fun onLibraryLoaded() {}
|
||||
open fun onIndexUpdateProgress(folderInfo: FolderInfo, percentage: Int) {
|
||||
val message = getString(R.string.index_update_progress_label, folderInfo.label, percentage)
|
||||
snackBar?.setText(message) ?: run {
|
||||
snackBar = Snackbar.make(contentView!!, message, Snackbar.LENGTH_INDEFINITE)
|
||||
snackBar?.show()
|
||||
}
|
||||
}
|
||||
|
||||
open fun onIndexUpdateComplete(folderInfo: FolderInfo) {
|
||||
snackBar?.dismiss()
|
||||
snackBar = null
|
||||
}
|
||||
|
||||
open fun onLibraryLoaded() {
|
||||
// nothing to do
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,47 @@
|
||||
package net.syncthing.lite.adapters
|
||||
|
||||
import android.content.Context
|
||||
import android.databinding.DataBindingUtil
|
||||
import android.support.v7.widget.RecyclerView
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import com.google.common.collect.Lists
|
||||
import net.syncthing.java.core.beans.DeviceStats
|
||||
import net.syncthing.lite.R
|
||||
import net.syncthing.java.core.beans.DeviceInfo
|
||||
import net.syncthing.lite.databinding.ListviewDeviceBinding
|
||||
import kotlin.properties.Delegates
|
||||
|
||||
class DevicesAdapter(context: Context) :
|
||||
ArrayAdapter<DeviceStats>(context, R.layout.listview_device, Lists.newArrayList()) {
|
||||
class DevicesAdapter: RecyclerView.Adapter<DeviceViewHolder>() {
|
||||
var data: List<DeviceInfo> by Delegates.observable(listOf()) {
|
||||
_, _, _ -> notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun getView(position: Int, v: View?, parent: ViewGroup): View {
|
||||
val binding: ListviewDeviceBinding
|
||||
= if (v == null) {
|
||||
DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.listview_device, parent, false)
|
||||
} else {
|
||||
DataBindingUtil.bind(v)
|
||||
}
|
||||
val deviceStats = getItem(position)
|
||||
binding.deviceName.text = deviceStats!!.name
|
||||
val icon =
|
||||
when (deviceStats.status) {
|
||||
DeviceStats.DeviceStatus.OFFLINE -> R.drawable.ic_laptop_red_24dp
|
||||
DeviceStats.DeviceStatus.ONLINE_INACTIVE,
|
||||
DeviceStats.DeviceStatus.ONLINE_ACTIVE -> R.drawable.ic_laptop_green_24dp
|
||||
}
|
||||
binding.deviceIcon.setImageResource(icon)
|
||||
return binding.root
|
||||
var listener: DeviceAdapterListener? = null
|
||||
|
||||
init {
|
||||
setHasStableIds(true)
|
||||
}
|
||||
|
||||
override fun getItemCount() = data.size
|
||||
override fun getItemId(position: Int) = data[position].deviceId.deviceId.hashCode().toLong()
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = DeviceViewHolder(
|
||||
ListviewDeviceBinding.inflate(
|
||||
LayoutInflater.from(parent.context), parent, false
|
||||
)
|
||||
)
|
||||
|
||||
override fun onBindViewHolder(holder: DeviceViewHolder, position: Int) {
|
||||
val deviceStats = data[position]
|
||||
val binding = holder.binding
|
||||
|
||||
binding.name = deviceStats.name
|
||||
binding.isConnected = deviceStats.isConnected
|
||||
|
||||
binding.root.setOnLongClickListener { listener?.onDeviceLongClicked(deviceStats) ?: false }
|
||||
|
||||
binding.executePendingBindings()
|
||||
}
|
||||
}
|
||||
|
||||
interface DeviceAdapterListener {
|
||||
fun onDeviceLongClicked(deviceInfo: DeviceInfo): Boolean
|
||||
}
|
||||
|
||||
class DeviceViewHolder(val binding: ListviewDeviceBinding): RecyclerView.ViewHolder(binding.root)
|
||||
@@ -1,40 +1,64 @@
|
||||
package net.syncthing.lite.adapters
|
||||
|
||||
import android.content.Context
|
||||
import android.databinding.DataBindingUtil
|
||||
import android.support.v7.widget.RecyclerView
|
||||
import android.text.format.DateUtils
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import com.google.common.collect.Lists
|
||||
import net.syncthing.java.core.beans.FileInfo
|
||||
import net.syncthing.lite.R
|
||||
import net.syncthing.lite.databinding.ListviewFileBinding
|
||||
import org.apache.commons.io.FileUtils
|
||||
import kotlin.properties.Delegates
|
||||
|
||||
class FolderContentsAdapter(context: Context) :
|
||||
ArrayAdapter<FileInfo>(context, R.layout.listview_file, Lists.newArrayList()) {
|
||||
// TODO: enable setHasStableIds and add a good way to get an id
|
||||
class FolderContentsAdapter: RecyclerView.Adapter<FolderContentsViewHolder>() {
|
||||
var data: List<FileInfo> by Delegates.observable(listOf()) {
|
||||
_, _, _ -> notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun getView(position: Int, v: View?, parent: ViewGroup): View {
|
||||
val binding: ListviewFileBinding =
|
||||
if (v == null) {
|
||||
DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.listview_file, parent, false)
|
||||
} else {
|
||||
DataBindingUtil.bind(v)
|
||||
}
|
||||
val fileInfo = getItem(position)
|
||||
binding.fileLabel.text = fileInfo!!.fileName
|
||||
if (fileInfo.isDirectory) {
|
||||
var listener: FolderContentsListener? = null
|
||||
|
||||
init {
|
||||
// setHasStableIds(true)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = FolderContentsViewHolder(
|
||||
ListviewFileBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
)
|
||||
|
||||
override fun onBindViewHolder(holder: FolderContentsViewHolder, position: Int) {
|
||||
val binding = holder.binding
|
||||
val fileInfo = data[position]
|
||||
|
||||
binding.fileName = fileInfo.fileName
|
||||
|
||||
if (fileInfo.isDirectory()) {
|
||||
binding.fileIcon.setImageResource(R.drawable.ic_folder_black_24dp)
|
||||
binding.fileSize.visibility = View.GONE
|
||||
binding.fileSize = null
|
||||
} else {
|
||||
binding.fileIcon.setImageResource(R.drawable.ic_image_black_24dp)
|
||||
binding.fileSize.visibility = View.VISIBLE
|
||||
binding.fileSize.text = (FileUtils.byteCountToDisplaySize(fileInfo.size!!)
|
||||
+ " - last modified "
|
||||
+ DateUtils.getRelativeDateTimeString(context, fileInfo.lastModified.time, DateUtils.MINUTE_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, 0))
|
||||
binding.fileSize = binding.root.context.getString(R.string.file_info,
|
||||
FileUtils.byteCountToDisplaySize(fileInfo.size!!),
|
||||
DateUtils.getRelativeDateTimeString(binding.root.context, fileInfo.lastModified.time, DateUtils.MINUTE_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, 0))
|
||||
}
|
||||
return binding.root
|
||||
|
||||
binding.root.setOnClickListener {
|
||||
listener?.onItemClicked(fileInfo)
|
||||
}
|
||||
|
||||
binding.executePendingBindings()
|
||||
}
|
||||
|
||||
override fun getItemCount() = data.size
|
||||
// override fun getItemId(position: Int) = data[position].fileName.hashCode().toLong()
|
||||
}
|
||||
|
||||
interface FolderContentsListener {
|
||||
fun onItemClicked(fileInfo: FileInfo)
|
||||
}
|
||||
|
||||
class FolderContentsViewHolder(val binding: ListviewFileBinding): RecyclerView.ViewHolder(binding.root)
|
||||
@@ -1,38 +1,55 @@
|
||||
package net.syncthing.lite.adapters
|
||||
|
||||
import android.content.Context
|
||||
import android.databinding.DataBindingUtil
|
||||
import android.support.v7.widget.RecyclerView
|
||||
import android.text.format.DateUtils
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import net.syncthing.java.core.beans.FolderInfo
|
||||
import net.syncthing.java.core.beans.FolderStats
|
||||
import net.syncthing.lite.R
|
||||
import net.syncthing.lite.databinding.ListviewFolderBinding
|
||||
import org.apache.commons.lang3.tuple.Pair
|
||||
import kotlin.properties.Delegates
|
||||
|
||||
class FoldersListAdapter(context: Context, list: List<Pair<FolderInfo, FolderStats>>) :
|
||||
ArrayAdapter<Pair<FolderInfo, FolderStats>>(context, R.layout.listview_folder, list) {
|
||||
|
||||
override fun getView(position: Int, v: View?, parent: ViewGroup): View {
|
||||
val binding: ListviewFolderBinding =
|
||||
if (v == null) {
|
||||
DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.listview_folder, parent, false)
|
||||
} else {
|
||||
DataBindingUtil.bind(v)
|
||||
}
|
||||
val folderInfo = getItem(position)!!.left
|
||||
val folderStats = getItem(position)!!.right
|
||||
binding.folderName.text = "${folderInfo.label} (${folderInfo.folder})"
|
||||
binding.folderLastmodInfo.text =
|
||||
if (folderStats.lastUpdate == null)
|
||||
"last modified: unknown"
|
||||
else "last modified: " +
|
||||
DateUtils.getRelativeDateTimeString(context, folderStats.lastUpdate.time, DateUtils.MINUTE_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, 0)
|
||||
binding.folderContentInfo.text = "${folderStats.describeSize()}, ${folderStats.fileCount} files, ${folderStats.dirCount} dirs"
|
||||
return binding.root
|
||||
class FoldersListAdapter: RecyclerView.Adapter<FolderListViewHolder>() {
|
||||
var data: List<Pair<FolderInfo, FolderStats>> by Delegates.observable(listOf()) {
|
||||
_, _, _ -> notifyDataSetChanged()
|
||||
}
|
||||
|
||||
var listener: FolderListAdapterListener? = null
|
||||
|
||||
init {
|
||||
setHasStableIds(true)
|
||||
}
|
||||
|
||||
override fun getItemCount() = data.size
|
||||
override fun getItemId(position: Int) = data[position].first.folderId.hashCode().toLong()
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = FolderListViewHolder (
|
||||
ListviewFolderBinding.inflate(
|
||||
LayoutInflater.from(parent.context), parent, false
|
||||
)
|
||||
)
|
||||
|
||||
override fun onBindViewHolder(holder: FolderListViewHolder, position: Int) {
|
||||
val binding = holder.binding
|
||||
val (folderInfo, folderStats) = data[position]
|
||||
val context = holder.itemView.context
|
||||
|
||||
binding.folderName = context.getString(R.string.folder_label_format, folderInfo.label, folderInfo.folderId)
|
||||
|
||||
binding.lastModification = context.getString(R.string.last_modified_time,
|
||||
DateUtils.getRelativeDateTimeString(context, folderStats.lastUpdate.time, DateUtils.MINUTE_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, 0))
|
||||
|
||||
binding.info = context.getString(R.string.folder_content_info, folderStats.describeSize(), folderStats.fileCount, folderStats.dirCount)
|
||||
|
||||
binding.root.setOnClickListener {
|
||||
listener?.onFolderClicked(folderInfo, folderStats)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FolderListViewHolder(val binding: ListviewFolderBinding): RecyclerView.ViewHolder(binding.root)
|
||||
|
||||
interface FolderListAdapterListener {
|
||||
fun onFolderClicked(folderInfo: FolderInfo, folderStats: FolderStats)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package net.syncthing.lite.dialogs
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.app.Dialog
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.FragmentManager
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.WriterException
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import kotlinx.coroutines.experimental.android.UI
|
||||
import kotlinx.coroutines.experimental.async
|
||||
import net.syncthing.lite.R
|
||||
import net.syncthing.lite.databinding.DialogDeviceIdBinding
|
||||
import net.syncthing.lite.fragments.SyncthingDialogFragment
|
||||
import org.jetbrains.anko.doAsync
|
||||
|
||||
class DeviceIdDialogFragment: SyncthingDialogFragment() {
|
||||
companion object {
|
||||
private const val QR_RESOLUTION = 512
|
||||
private const val TAG = "DeviceIdDialog"
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val binding = DialogDeviceIdBinding.inflate(LayoutInflater.from(context), null, false)
|
||||
|
||||
// use an placeholder to prevent size changes; this string is never shown
|
||||
binding.deviceId.text = "XXXXXXX-XXXXXXX-XXXXXXX-XXXXXXX-XXXXXXX-XXXXXXX-XXXXXXX-XXXXXXX"
|
||||
binding.deviceId.visibility = View.INVISIBLE
|
||||
|
||||
binding.qrCode.setImageBitmap(Bitmap.createBitmap(QR_RESOLUTION, QR_RESOLUTION, Bitmap.Config.RGB_565))
|
||||
|
||||
libraryHandler.library { configuration, _, _ ->
|
||||
val deviceId = configuration.localDeviceId
|
||||
|
||||
fun copyDeviceId() {
|
||||
val clipboard = context!!.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText(context!!.getString(R.string.device_id), deviceId.deviceId)
|
||||
|
||||
clipboard.primaryClip = clip
|
||||
|
||||
Toast.makeText(context, context!!.getString(R.string.device_id_copied), Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
|
||||
fun shareDeviceId() {
|
||||
context!!.startActivity(Intent.createChooser(
|
||||
Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, deviceId.deviceId)
|
||||
},
|
||||
context!!.getString(R.string.share_device_id_chooser)
|
||||
))
|
||||
}
|
||||
|
||||
async (UI) {
|
||||
binding.deviceId.text = deviceId.deviceId
|
||||
binding.deviceId.visibility = View.VISIBLE
|
||||
|
||||
binding.deviceId.setOnClickListener { copyDeviceId() }
|
||||
binding.share.setOnClickListener { shareDeviceId() }
|
||||
}
|
||||
|
||||
doAsync {
|
||||
val writer = QRCodeWriter()
|
||||
try {
|
||||
val bitMatrix = writer.encode(deviceId.deviceId, BarcodeFormat.QR_CODE, QR_RESOLUTION, QR_RESOLUTION)
|
||||
val width = bitMatrix.width
|
||||
val height = bitMatrix.height
|
||||
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
|
||||
for (x in 0 until width) {
|
||||
for (y in 0 until height) {
|
||||
bmp.setPixel(x, y, if (bitMatrix.get(x, y)) Color.BLACK else Color.WHITE)
|
||||
}
|
||||
}
|
||||
|
||||
async(UI) {
|
||||
binding.flipper.displayedChild = 1
|
||||
binding.qrCode.setImageBitmap(bmp)
|
||||
}
|
||||
} catch (e: WriterException) {
|
||||
Log.w(TAG, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return AlertDialog.Builder(context!!, theme)
|
||||
.setTitle(context!!.getString(R.string.device_id))
|
||||
.setView(binding.root)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.create()
|
||||
}
|
||||
|
||||
fun show(manager: FragmentManager?) {
|
||||
super.show(manager, TAG)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package net.syncthing.lite.dialogs
|
||||
|
||||
import android.app.ProgressDialog
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import kotlinx.coroutines.experimental.android.UI
|
||||
import kotlinx.coroutines.experimental.async
|
||||
import net.syncthing.java.bep.BlockPusher
|
||||
import net.syncthing.java.client.SyncthingClient
|
||||
import net.syncthing.lite.R
|
||||
import net.syncthing.lite.library.UploadFileTask
|
||||
import net.syncthing.lite.utils.Util
|
||||
import org.jetbrains.anko.doAsync
|
||||
import org.jetbrains.anko.toast
|
||||
|
||||
class FileUploadDialog(private val context: Context, private val syncthingClient: SyncthingClient,
|
||||
private val localFile: Uri, private val syncthingFolder: String,
|
||||
private val syncthingSubFolder: String,
|
||||
private val onUploadCompleteListener: () -> Unit) {
|
||||
|
||||
private lateinit var progressDialog: ProgressDialog
|
||||
private var uploadFileTask: UploadFileTask? = null
|
||||
|
||||
fun show() {
|
||||
showDialog()
|
||||
doAsync {
|
||||
uploadFileTask = UploadFileTask(context, syncthingClient, localFile, syncthingFolder,
|
||||
syncthingSubFolder, this@FileUploadDialog::onProgress,
|
||||
this@FileUploadDialog::onComplete, this@FileUploadDialog::onError)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showDialog() {
|
||||
progressDialog = ProgressDialog(context)
|
||||
progressDialog.setMessage(context.getString(R.string.dialog_uploading_file, Util.getContentFileName(context, localFile)))
|
||||
progressDialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL)
|
||||
progressDialog.setCancelable(true)
|
||||
progressDialog.setOnCancelListener { uploadFileTask?.cancel() }
|
||||
progressDialog.isIndeterminate = true
|
||||
progressDialog.show()
|
||||
}
|
||||
|
||||
private fun onProgress(observer: BlockPusher.FileUploadObserver) {
|
||||
progressDialog.isIndeterminate = false
|
||||
progressDialog.progress = observer.progressPercentage()
|
||||
progressDialog.max = 100
|
||||
}
|
||||
|
||||
private fun onComplete() {
|
||||
progressDialog.dismiss()
|
||||
this@FileUploadDialog.context.toast(R.string.toast_upload_complete)
|
||||
onUploadCompleteListener()
|
||||
}
|
||||
|
||||
private fun onError() {
|
||||
progressDialog.dismiss()
|
||||
this@FileUploadDialog.context.toast(R.string.toast_file_upload_failed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package net.syncthing.lite.dialogs
|
||||
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.DialogFragment
|
||||
import android.support.v4.app.FragmentActivity
|
||||
import android.support.v7.app.AlertDialog
|
||||
import net.syncthing.lite.R
|
||||
import org.jetbrains.anko.defaultSharedPreferences
|
||||
|
||||
class ReconnectIssueDialogFragment: DialogFragment() {
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?) = AlertDialog.Builder(context!!, theme)
|
||||
.setMessage(R.string.dialog_warning_reconnect_problem)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
context!!.defaultSharedPreferences.edit()
|
||||
.putBoolean(SETTINGS_PARAM, true)
|
||||
.apply()
|
||||
}
|
||||
.create()
|
||||
|
||||
companion object {
|
||||
private const val DIALOG_TAG = "ReconnectIssueDialog"
|
||||
private const val SETTINGS_PARAM = "has_educated_about_reconnect_issues"
|
||||
|
||||
fun showIfNeeded(activity: FragmentActivity) {
|
||||
if (!activity.defaultSharedPreferences.getBoolean(SETTINGS_PARAM, false)) {
|
||||
if (activity.supportFragmentManager.findFragmentByTag(DIALOG_TAG) == null) {
|
||||
ReconnectIssueDialogFragment().show(activity.supportFragmentManager, DIALOG_TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
package net.syncthing.lite.dialogs.downloadfile
|
||||
|
||||
import android.app.Dialog
|
||||
import android.app.ProgressDialog
|
||||
import android.arch.lifecycle.Observer
|
||||
import android.arch.lifecycle.ViewModelProviders
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.DialogFragment
|
||||
import android.support.v4.app.FragmentManager
|
||||
import android.support.v4.content.FileProvider
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
import net.syncthing.java.core.beans.FileInfo
|
||||
import net.syncthing.lite.BuildConfig
|
||||
import net.syncthing.lite.R
|
||||
import net.syncthing.lite.library.LibraryHandler
|
||||
import org.apache.commons.io.FilenameUtils
|
||||
import org.jetbrains.anko.newTask
|
||||
import org.jetbrains.anko.toast
|
||||
|
||||
class DownloadFileDialogFragment : DialogFragment() {
|
||||
companion object {
|
||||
private const val ARG_FILE_SPEC = "file spec"
|
||||
private const val TAG = "DownloadFileDialog"
|
||||
|
||||
fun newInstance(fileInfo: FileInfo) = newInstance(DownloadFileSpec(
|
||||
folder = fileInfo.folder,
|
||||
path = fileInfo.path,
|
||||
fileName = fileInfo.fileName
|
||||
))
|
||||
|
||||
fun newInstance(fileSpec: DownloadFileSpec) = DownloadFileDialogFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putSerializable(ARG_FILE_SPEC, fileSpec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val model: DownloadFileDialogViewModel by lazy {
|
||||
ViewModelProviders.of(this).get(DownloadFileDialogViewModel::class.java)
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val fileSpec = arguments!!.getSerializable(ARG_FILE_SPEC) as DownloadFileSpec
|
||||
|
||||
model.init(
|
||||
libraryHandler = LibraryHandler(context!!),
|
||||
fileSpec = fileSpec,
|
||||
externalCacheDir = context!!.externalCacheDir
|
||||
)
|
||||
|
||||
val progressDialog = ProgressDialog(context).apply {
|
||||
setMessage(context!!.getString(R.string.dialog_downloading_file, fileSpec.fileName))
|
||||
setProgressStyle(ProgressDialog.STYLE_HORIZONTAL)
|
||||
isCancelable = true
|
||||
isIndeterminate = true
|
||||
max = DownloadFileStatusRunning.MAX_PROGRESS
|
||||
}
|
||||
|
||||
model.status.observe(this, Observer {
|
||||
status ->
|
||||
|
||||
when (status) {
|
||||
is DownloadFileStatusRunning -> {
|
||||
progressDialog.apply {
|
||||
isIndeterminate = false
|
||||
progress = status.progress
|
||||
}
|
||||
}
|
||||
is DownloadFileStatusDone -> {
|
||||
dismissAllowingStateLoss()
|
||||
|
||||
try {
|
||||
context!!.startActivity(
|
||||
Intent(Intent.ACTION_VIEW)
|
||||
.setDataAndType(
|
||||
FileProvider.getUriForFile(context!!, "net.syncthing.lite.fileprovider", status.file),
|
||||
MimeTypeMap.getSingleton().getMimeTypeFromExtension(FilenameUtils.getExtension(fileSpec.fileName))
|
||||
)
|
||||
.newTask()
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.w(TAG, "No handler found for file " + status.file.name, e)
|
||||
}
|
||||
|
||||
context!!.toast(R.string.toast_open_file_failed)
|
||||
}
|
||||
}
|
||||
is DownloadFileStatusFailed -> {
|
||||
dismissAllowingStateLoss()
|
||||
|
||||
context!!.toast(R.string.toast_file_download_failed)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return progressDialog
|
||||
}
|
||||
|
||||
override fun onCancel(dialog: DialogInterface?) {
|
||||
super.onCancel(dialog)
|
||||
|
||||
model.cancel()
|
||||
}
|
||||
|
||||
fun show(fragmentManager: FragmentManager?) {
|
||||
show(fragmentManager, TAG)
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package net.syncthing.lite.dialogs.downloadfile
|
||||
|
||||
import android.arch.lifecycle.LiveData
|
||||
import android.arch.lifecycle.MutableLiveData
|
||||
import android.arch.lifecycle.ViewModel;
|
||||
import android.support.v4.os.CancellationSignal
|
||||
import android.util.Log
|
||||
import net.syncthing.lite.BuildConfig
|
||||
import net.syncthing.lite.library.DownloadFileTask
|
||||
import net.syncthing.lite.library.LibraryHandler
|
||||
import java.io.File
|
||||
|
||||
class DownloadFileDialogViewModel : ViewModel() {
|
||||
companion object {
|
||||
private const val TAG = "DownloadFileDialog"
|
||||
}
|
||||
|
||||
private var isInitialized = false
|
||||
private val statusInternal = MutableLiveData<DownloadFileStatus>()
|
||||
private val cancellationSignal = CancellationSignal()
|
||||
val status: LiveData<DownloadFileStatus> = statusInternal
|
||||
|
||||
fun init(libraryHandler: LibraryHandler, fileSpec: DownloadFileSpec, externalCacheDir: File) {
|
||||
if (isInitialized) {
|
||||
return
|
||||
}
|
||||
|
||||
isInitialized = true
|
||||
|
||||
libraryHandler.start()
|
||||
|
||||
// this keeps the client only active as long as the block is running
|
||||
// but the file downloading is not synchronous.
|
||||
// Due to that, the start and stop calls are used.
|
||||
libraryHandler.syncthingClient {
|
||||
syncthingClient ->
|
||||
|
||||
try {
|
||||
val fileInfo = syncthingClient.indexHandler.getFileInfoByPath(
|
||||
folder = fileSpec.folder,
|
||||
path = fileSpec.path
|
||||
)!!
|
||||
|
||||
val task = DownloadFileTask(
|
||||
fileStorageDirectory = externalCacheDir,
|
||||
syncthingClient = syncthingClient,
|
||||
fileInfo = fileInfo,
|
||||
onProgress = { status ->
|
||||
val newProgress = (status.downloadedBytes * DownloadFileStatusRunning.MAX_PROGRESS / status.totalTransferSize).toInt()
|
||||
val currentStatus = statusInternal.value
|
||||
|
||||
// only update if it changed
|
||||
if (!(currentStatus is DownloadFileStatusRunning) || currentStatus.progress != newProgress) {
|
||||
statusInternal.value = DownloadFileStatusRunning(newProgress)
|
||||
}
|
||||
},
|
||||
onComplete = {
|
||||
statusInternal.value = DownloadFileStatusDone(it)
|
||||
|
||||
libraryHandler.stop()
|
||||
},
|
||||
onError = {
|
||||
statusInternal.value = DownloadFileStatusFailed
|
||||
|
||||
libraryHandler.stop()
|
||||
}
|
||||
)
|
||||
|
||||
cancellationSignal.setOnCancelListener {
|
||||
task.cancel()
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.w(TAG, "downloading file failed", ex)
|
||||
}
|
||||
|
||||
statusInternal.postValue(DownloadFileStatusFailed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
|
||||
cancel()
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
cancellationSignal.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package net.syncthing.lite.dialogs.downloadfile
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
data class DownloadFileSpec(val folder: String, val path: String, val fileName: String): Serializable
|
||||
@@ -0,0 +1,12 @@
|
||||
package net.syncthing.lite.dialogs.downloadfile
|
||||
|
||||
import java.io.File
|
||||
|
||||
sealed class DownloadFileStatus
|
||||
object DownloadFileStatusFailed: DownloadFileStatus()
|
||||
data class DownloadFileStatusDone(val file: File): DownloadFileStatus()
|
||||
data class DownloadFileStatusRunning(val progress: Int): DownloadFileStatus() {
|
||||
companion object {
|
||||
const val MAX_PROGRESS = 100
|
||||
}
|
||||
}
|
||||
@@ -5,138 +5,121 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.databinding.DataBindingUtil
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.Fragment
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.EditText
|
||||
import android.widget.Toast
|
||||
import com.google.zxing.integration.android.IntentIntegrator
|
||||
import kotlinx.coroutines.experimental.android.UI
|
||||
import kotlinx.coroutines.experimental.async
|
||||
import net.syncthing.java.core.beans.DeviceInfo
|
||||
import net.syncthing.java.core.beans.DeviceStats
|
||||
import net.syncthing.java.core.security.KeystoreHandler
|
||||
import net.syncthing.lite.R
|
||||
import net.syncthing.lite.activities.SyncthingActivity
|
||||
import net.syncthing.lite.adapters.DeviceAdapterListener
|
||||
import net.syncthing.lite.adapters.DevicesAdapter
|
||||
import net.syncthing.lite.databinding.FragmentDevicesBinding
|
||||
import net.syncthing.lite.utils.UpdateIndexTask
|
||||
import org.apache.commons.lang3.StringUtils.isBlank
|
||||
import uk.co.markormesher.android_fab.SpeedDialMenuAdapter
|
||||
import uk.co.markormesher.android_fab.SpeedDialMenuItem
|
||||
import java.security.InvalidParameterException
|
||||
import net.syncthing.lite.databinding.ViewEnterDeviceIdBinding
|
||||
import net.syncthing.lite.utils.FragmentIntentIntegrator
|
||||
import net.syncthing.lite.utils.Util
|
||||
import java.io.IOException
|
||||
|
||||
class DevicesFragment : Fragment() {
|
||||
class DevicesFragment : SyncthingFragment() {
|
||||
|
||||
companion object {
|
||||
private val TAG = "DevicesFragment"
|
||||
}
|
||||
|
||||
private lateinit var syncthingActivity: SyncthingActivity
|
||||
private lateinit var binding: FragmentDevicesBinding
|
||||
private lateinit var adapter: DevicesAdapter
|
||||
private val adapter = DevicesAdapter()
|
||||
private var addDeviceDialog: AlertDialog? = null
|
||||
private var addDeviceDialogBinding: ViewEnterDeviceIdBinding? = null
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?): View? {
|
||||
binding = DataBindingUtil.inflate(layoutInflater, R.layout.fragment_devices, container, false)
|
||||
binding.list.emptyView = binding.empty
|
||||
binding.fab.speedDialMenuAdapter = FabMenuAdapter()
|
||||
binding.addDevice.setOnClickListener { showDialog() }
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
syncthingActivity = activity as SyncthingActivity
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
libraryHandler?.syncthingClient { it.addOnConnectionChangedListener { _ -> updateDeviceList() } }
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
libraryHandler?.syncthingClient { it.removeOnConnectionChangedListener{ _ -> updateDeviceList() } }
|
||||
}
|
||||
|
||||
override fun onLibraryLoaded() {
|
||||
initDeviceList()
|
||||
updateDeviceList()
|
||||
}
|
||||
|
||||
private fun initDeviceList() {
|
||||
adapter = DevicesAdapter(syncthingActivity)
|
||||
binding.list.adapter = adapter
|
||||
binding.list.setOnItemLongClickListener { _, _, position, _ ->
|
||||
val deviceId = (binding.list.getItemAtPosition(position) as DeviceStats).deviceId
|
||||
AlertDialog.Builder(syncthingActivity)
|
||||
.setTitle("remove device " + deviceId.substring(0, 7))
|
||||
.setMessage("remove device" + deviceId.substring(0, 7) + " from list of known devices?")
|
||||
.setPositiveButton(android.R.string.yes) { _, _ ->
|
||||
syncthingActivity.configuration().edit().removePeer(deviceId).persistLater() }
|
||||
.setNegativeButton(android.R.string.no, null)
|
||||
.show()
|
||||
Log.d(TAG, "showFolderListView delete device = '$deviceId'")
|
||||
false
|
||||
|
||||
adapter.listener = object: DeviceAdapterListener {
|
||||
override fun onDeviceLongClicked(deviceInfo: DeviceInfo): Boolean {
|
||||
AlertDialog.Builder(context)
|
||||
.setTitle(getString(R.string.remove_device_title, deviceInfo.name))
|
||||
.setMessage(getString(R.string.remove_device_message, deviceInfo.deviceId.deviceId.substring(0, 7)))
|
||||
.setPositiveButton(android.R.string.yes) { _, _ ->
|
||||
libraryHandler?.configuration { config ->
|
||||
config.peers = config.peers.filterNot { it.deviceId == deviceInfo.deviceId }.toSet()
|
||||
config.persistLater()
|
||||
updateDeviceList()
|
||||
}
|
||||
}
|
||||
.setNegativeButton(android.R.string.no, null)
|
||||
.show()
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateDeviceList() {
|
||||
adapter.clear()
|
||||
adapter.addAll(syncthingActivity.syncthingClient().devicesHandler.deviceStatsList)
|
||||
adapter.notifyDataSetChanged()
|
||||
libraryHandler.syncthingClient { syncthingClient ->
|
||||
async(UI) {
|
||||
adapter.data = syncthingClient.getPeerStatus()
|
||||
binding.isEmpty = adapter.data.isEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) {
|
||||
// Check if this was a QR code scan.
|
||||
val scanResult = IntentIntegrator.parseActivityResult(requestCode, resultCode, intent)
|
||||
if (scanResult != null) {
|
||||
val deviceId = scanResult.contents
|
||||
if (!isBlank(deviceId)) {
|
||||
importDeviceId(deviceId)
|
||||
}
|
||||
if (scanResult?.contents != null && scanResult.contents.isNotBlank()) {
|
||||
addDeviceDialogBinding?.deviceId?.setText(scanResult.contents)
|
||||
}
|
||||
}
|
||||
|
||||
private fun importDeviceId(deviceId: String) {
|
||||
try {
|
||||
KeystoreHandler.validateDeviceId(deviceId)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Toast.makeText(context, R.string.invalid_device_id, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
val modified = syncthingActivity.configuration().edit().addPeers(DeviceInfo(deviceId, null))
|
||||
if (modified) {
|
||||
syncthingActivity.configuration().edit().persistLater()
|
||||
Toast.makeText(context, "successfully imported device: " + deviceId, Toast.LENGTH_SHORT).show()
|
||||
updateDeviceList()//TODO remove this if event triggered (and handler trigger update)
|
||||
UpdateIndexTask(syncthingActivity, syncthingActivity.syncthingClient()).updateIndex()
|
||||
} else {
|
||||
Toast.makeText(context, "device already present: " + deviceId, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class FabMenuAdapter : SpeedDialMenuAdapter() {
|
||||
override fun getCount(): Int {
|
||||
return 2
|
||||
}
|
||||
|
||||
override fun getMenuItem(context: Context, position: Int): SpeedDialMenuItem {
|
||||
when (position) {
|
||||
0 -> return SpeedDialMenuItem(context, R.drawable.ic_qr_code_white_24dp, R.string.scan_qr_code)
|
||||
1 -> return SpeedDialMenuItem(context, R.drawable.ic_edit_white_24dp, R.string.enter_device_id)
|
||||
private fun showDialog() {
|
||||
addDeviceDialogBinding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.view_enter_device_id, null, false)
|
||||
addDeviceDialogBinding?.let { binding ->
|
||||
binding.scanQrCode.setOnClickListener {
|
||||
FragmentIntentIntegrator(this@DevicesFragment).initiateScan()
|
||||
}
|
||||
binding.deviceId.post {
|
||||
val imm = context!!.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.showSoftInput(binding.deviceId, InputMethodManager.SHOW_IMPLICIT)
|
||||
}
|
||||
throw InvalidParameterException()
|
||||
}
|
||||
|
||||
override fun onMenuItemClick(position: Int): Boolean {
|
||||
when (position) {
|
||||
0 -> FragmentIntentIntegrator(this@DevicesFragment).initiateScan()
|
||||
1 -> {
|
||||
val editText = EditText(context)
|
||||
val dialog = AlertDialog.Builder(context)
|
||||
.setTitle(R.string.device_id_dialog_title)
|
||||
.setView(editText)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ -> importDeviceId(editText.text.toString()) }
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.create()
|
||||
dialog.setOnShowListener {
|
||||
val imm = context!!.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT)
|
||||
addDeviceDialog = AlertDialog.Builder(context)
|
||||
.setTitle(R.string.device_id_dialog_title)
|
||||
.setView(binding.root)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
|
||||
// Use different listener to keep dialog open after button click.
|
||||
// https://stackoverflow.com/a/15619098
|
||||
addDeviceDialog?.getButton(AlertDialog.BUTTON_POSITIVE)
|
||||
?.setOnClickListener {
|
||||
try {
|
||||
val deviceId = binding.deviceId.text.toString()
|
||||
Util.importDeviceId(libraryHandler, context, deviceId, { updateDeviceList() })
|
||||
addDeviceDialog?.dismiss()
|
||||
} catch (e: IOException) {
|
||||
binding.deviceId.error = getString(R.string.invalid_device_id)
|
||||
}
|
||||
}
|
||||
dialog.show()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +1,65 @@
|
||||
package net.syncthing.lite.fragments
|
||||
|
||||
import android.content.Intent
|
||||
import android.databinding.DataBindingUtil
|
||||
import android.arch.lifecycle.Observer
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.Fragment
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import com.google.common.collect.Lists
|
||||
import com.google.common.collect.Ordering
|
||||
import kotlinx.coroutines.experimental.android.UI
|
||||
import kotlinx.coroutines.experimental.async
|
||||
import net.syncthing.java.core.beans.FolderInfo
|
||||
import net.syncthing.java.core.beans.FolderStats
|
||||
import net.syncthing.lite.R
|
||||
import net.syncthing.lite.activities.FolderBrowserActivity
|
||||
import net.syncthing.lite.activities.SyncthingActivity
|
||||
import net.syncthing.lite.adapters.FolderListAdapterListener
|
||||
import net.syncthing.lite.adapters.FoldersListAdapter
|
||||
import net.syncthing.lite.databinding.FragmentFoldersBinding
|
||||
import org.apache.commons.lang3.tuple.Pair
|
||||
import java.util.*
|
||||
import org.jetbrains.anko.intentFor
|
||||
|
||||
class FoldersFragment : Fragment() {
|
||||
class FoldersFragment : SyncthingFragment() {
|
||||
|
||||
companion object {
|
||||
private val TAG = "FoldersFragment"
|
||||
}
|
||||
private val TAG = "FoldersFragment"
|
||||
|
||||
private lateinit var syncthingActivity: SyncthingActivity
|
||||
private lateinit var binding: FragmentFoldersBinding
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?): View? {
|
||||
binding = DataBindingUtil.inflate(layoutInflater, R.layout.fragment_folders, container, false)
|
||||
binding.list.emptyView = binding.empty
|
||||
binding = FragmentFoldersBinding.inflate(layoutInflater, container, false)
|
||||
|
||||
libraryHandler.isListeningPortTaken.observe(this, Observer { binding.listeningPortTaken = it })
|
||||
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
syncthingActivity = activity as SyncthingActivity
|
||||
override fun onLibraryLoaded() {
|
||||
showAllFoldersListView()
|
||||
}
|
||||
|
||||
private fun showAllFoldersListView() {
|
||||
val list = Lists.newArrayList(syncthingActivity.folderBrowser().folderInfoAndStatsList)
|
||||
Collections.sort(list, Ordering.natural<Comparable<String>>()
|
||||
.onResultOf<Pair<FolderInfo, FolderStats>> { input -> input?.left?.label })
|
||||
Log.i(TAG, "list folders = " + list + " (" + list.size + " records")
|
||||
val adapter = FoldersListAdapter(context!!, list)
|
||||
binding.list.adapter = adapter
|
||||
binding.list.setOnItemClickListener { _, _, position, _ ->
|
||||
val folder = adapter.getItem(position)!!.left.folder
|
||||
val intent = Intent(context, FolderBrowserActivity::class.java)
|
||||
intent.putExtra(FolderBrowserActivity.EXTRA_FOLDER_NAME, folder)
|
||||
startActivity(intent)
|
||||
libraryHandler.folderBrowser { folderBrowser ->
|
||||
val list = folderBrowser.folderInfoAndStatsList()
|
||||
|
||||
async (UI) {
|
||||
Log.i(TAG, "list folders = " + list + " (" + list.size + " records)")
|
||||
val adapter = FoldersListAdapter().apply { data = list }
|
||||
binding.list.adapter = adapter
|
||||
adapter.listener = object : FolderListAdapterListener {
|
||||
override fun onFolderClicked(folderInfo: FolderInfo, folderStats: FolderStats) {
|
||||
startActivity(
|
||||
activity!!.intentFor<FolderBrowserActivity>(
|
||||
FolderBrowserActivity.EXTRA_FOLDER_NAME to folderInfo.folderId
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
binding.isEmpty = list.isEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIndexUpdateComplete(folderInfo: FolderInfo) {
|
||||
super.onIndexUpdateComplete(folderInfo)
|
||||
showAllFoldersListView()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
package net.syncthing.lite.fragments
|
||||
|
||||
import android.view.View
|
||||
import com.nononsenseapps.filepicker.AbstractFilePickerFragment
|
||||
|
||||
import com.nononsenseapps.filepicker.FilePickerFragment
|
||||
|
||||
import java.io.File
|
||||
|
||||
class MIVFilePickerFragment : FilePickerFragment() {
|
||||
|
||||
override fun onClickCheckable(v: View, vh: AbstractFilePickerFragment<File>.CheckableViewHolder) {
|
||||
// auto open file on click
|
||||
if (!allowMultiple) {
|
||||
// Clear is necessary, in case user clicked some checkbox directly
|
||||
mCheckedItems.clear()
|
||||
mCheckedItems.add(vh.file)
|
||||
onClickOk(null)
|
||||
} else {
|
||||
super.onClickCheckable(v, vh)
|
||||
}
|
||||
}
|
||||
|
||||
// private static final String EXTENSION = ".*[.](jpg|png|jpeg)";
|
||||
|
||||
|
||||
override fun isItemVisible(file: File): Boolean {
|
||||
// return isDir(file) || file.getName().toLowerCase().matches(EXTENSION);
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package net.syncthing.lite.fragments
|
||||
|
||||
import android.os.Bundle
|
||||
import android.support.v7.preference.EditTextPreference
|
||||
import android.support.v7.preference.PreferenceFragmentCompat
|
||||
import net.syncthing.lite.R
|
||||
import net.syncthing.lite.activities.SyncthingActivity
|
||||
|
||||
class SettingsFragment : PreferenceFragmentCompat() {
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.preferences)
|
||||
|
||||
val localDeviceName = findPreference("local_device_name") as EditTextPreference
|
||||
val appVersion = findPreference("app_version")
|
||||
|
||||
(activity as SyncthingActivity?)?.let { activity ->
|
||||
val versionName = activity.packageManager.getPackageInfo(activity.packageName, 0)?.versionName
|
||||
appVersion.summary = versionName
|
||||
|
||||
activity.libraryHandler?.configuration { localDeviceName.text = it.localDeviceName }
|
||||
localDeviceName.setOnPreferenceChangeListener { _, _ ->
|
||||
activity.libraryHandler?.configuration { conf ->
|
||||
conf.localDeviceName = localDeviceName.text
|
||||
conf.persistLater()
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package net.syncthing.lite.fragments
|
||||
|
||||
import android.support.v4.app.DialogFragment
|
||||
import net.syncthing.lite.library.LibraryHandler
|
||||
|
||||
abstract class SyncthingDialogFragment : DialogFragment() {
|
||||
val libraryHandler: LibraryHandler by lazy { LibraryHandler(
|
||||
context = context!!
|
||||
)}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
libraryHandler.start()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
|
||||
libraryHandler.stop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package net.syncthing.lite.fragments
|
||||
|
||||
import android.support.v4.app.Fragment
|
||||
import net.syncthing.java.core.beans.FolderInfo
|
||||
import net.syncthing.lite.library.LibraryHandler
|
||||
|
||||
abstract class SyncthingFragment : Fragment() {
|
||||
val libraryHandler: LibraryHandler by lazy { LibraryHandler(
|
||||
context = context!!,
|
||||
onIndexUpdateProgressListener = this::onIndexUpdateProgress,
|
||||
onIndexUpdateCompleteListener = this::onIndexUpdateComplete
|
||||
)}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
libraryHandler.start {
|
||||
// TODO: check if this is still useful
|
||||
onLibraryLoaded()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
|
||||
libraryHandler.stop()
|
||||
}
|
||||
|
||||
open fun onLibraryLoaded() {}
|
||||
|
||||
open fun onIndexUpdateProgress(folderInfo: FolderInfo, percentage: Int) {}
|
||||
|
||||
open fun onIndexUpdateComplete(folderInfo: FolderInfo) {}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package net.syncthing.lite.library
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import net.syncthing.lite.BuildConfig
|
||||
import net.syncthing.lite.R
|
||||
import org.jetbrains.anko.defaultSharedPreferences
|
||||
|
||||
object DefaultLibraryManager {
|
||||
private const val LOG_TAG = "DefaultLibraryManager"
|
||||
|
||||
private var instance: LibraryManager? = null
|
||||
private val lock = Object()
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
fun with(context: Context) = withApplicationContext(context.applicationContext)
|
||||
|
||||
private fun withApplicationContext(context: Context): LibraryManager {
|
||||
if (instance == null) {
|
||||
synchronized(lock) {
|
||||
if (instance == null) {
|
||||
val shutdownRunnable = Runnable {
|
||||
instance!!.shutdownIfThereAreZeroUsers()
|
||||
}
|
||||
|
||||
fun scheduleShutdown() {
|
||||
val shutdownDelay = context.defaultSharedPreferences.getString(
|
||||
"shutdown_delay",
|
||||
context.getString(R.string.default_shutdown_delay)
|
||||
).toLong()
|
||||
|
||||
handler.postDelayed(shutdownRunnable, shutdownDelay)
|
||||
}
|
||||
|
||||
fun cancelShutdown() {
|
||||
handler.removeCallbacks(shutdownRunnable)
|
||||
}
|
||||
|
||||
instance = LibraryManager(
|
||||
synchronousInstanceCreator = { LibraryInstance(context) },
|
||||
userCounterListener = {
|
||||
newUserCounter ->
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d(LOG_TAG, "user counter updated to $newUserCounter")
|
||||
}
|
||||
|
||||
val isUsed = newUserCounter > 0
|
||||
|
||||
if (isUsed) {
|
||||
cancelShutdown()
|
||||
} else {
|
||||
scheduleShutdown()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return instance!!
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package net.syncthing.lite.library
|
||||
|
||||
import java.io.File
|
||||
|
||||
data class DownloadFilePath (val baseDirectory: File, val fileHash: String) {
|
||||
val filesDirectory = File(baseDirectory, fileHash.substring(0, 2))
|
||||
val targetFile = File(filesDirectory, fileHash.substring(2))
|
||||
val tempFile = File(filesDirectory, fileHash.substring(2) + "_temp")
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package net.syncthing.lite.library
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.support.v4.os.CancellationSignal
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.experimental.launch
|
||||
import kotlinx.coroutines.experimental.suspendCancellableCoroutine
|
||||
import net.syncthing.java.bep.BlockPullerStatus
|
||||
import net.syncthing.java.client.SyncthingClient
|
||||
import net.syncthing.java.core.beans.FileInfo
|
||||
import net.syncthing.lite.BuildConfig
|
||||
import org.apache.commons.io.FileUtils
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
class DownloadFileTask(private val fileStorageDirectory: File,
|
||||
syncthingClient: SyncthingClient,
|
||||
private val fileInfo: FileInfo,
|
||||
private val onProgress: (status: BlockPullerStatus) -> Unit,
|
||||
private val onComplete: (File) -> Unit,
|
||||
private val onError: (Exception) -> Unit) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DownloadFileTask"
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
suspend fun downloadFileCoroutine(
|
||||
externalCacheDir: File,
|
||||
syncthingClient: SyncthingClient,
|
||||
fileInfo: FileInfo,
|
||||
onProgress: (status: BlockPullerStatus) -> Unit
|
||||
) = suspendCancellableCoroutine<File> {
|
||||
continuation ->
|
||||
|
||||
val task = DownloadFileTask(
|
||||
externalCacheDir,
|
||||
syncthingClient,
|
||||
fileInfo,
|
||||
onProgress,
|
||||
{
|
||||
continuation.resume(it)
|
||||
},
|
||||
{
|
||||
continuation.resumeWithException(it)
|
||||
}
|
||||
)
|
||||
|
||||
continuation.invokeOnCancellation {
|
||||
task.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val cancellationSignal = CancellationSignal()
|
||||
private var doneListenerCalled = false
|
||||
|
||||
init {
|
||||
val file = DownloadFilePath(fileStorageDirectory, fileInfo.hash!!)
|
||||
|
||||
launch {
|
||||
if (file.targetFile.exists()) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d(TAG, "there is already a file")
|
||||
}
|
||||
|
||||
callComplete(file.targetFile)
|
||||
|
||||
return@launch
|
||||
}
|
||||
|
||||
syncthingClient.getBlockPuller(fileInfo.folder, { blockPuller ->
|
||||
val job = launch {
|
||||
try {
|
||||
if (!file.filesDirectory.isDirectory) {
|
||||
if (!file.filesDirectory.mkdirs()) {
|
||||
throw IOException("could not create output directory")
|
||||
}
|
||||
}
|
||||
|
||||
// download the file to a temp location
|
||||
val inputStream = blockPuller.pullFileCoroutine(fileInfo, this@DownloadFileTask::callProgress)
|
||||
|
||||
try {
|
||||
FileUtils.copyInputStreamToFile(inputStream, file.tempFile)
|
||||
file.tempFile.renameTo(file.targetFile)
|
||||
} finally {
|
||||
file.tempFile.delete()
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.i(TAG, "Downloaded file $fileInfo")
|
||||
}
|
||||
|
||||
callComplete(file.targetFile)
|
||||
} catch (e: Exception) {
|
||||
callError(e)
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.w(TAG, "Failed to download file $fileInfo", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cancellationSignal.setOnCancelListener {
|
||||
job.cancel()
|
||||
}
|
||||
}, { callError(IOException("could not get block puller for file")) })
|
||||
}
|
||||
}
|
||||
|
||||
private fun callProgress(status: BlockPullerStatus) {
|
||||
handler.post {
|
||||
if (!doneListenerCalled) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.i("pullFile", "download progress = $status")
|
||||
}
|
||||
|
||||
onProgress(status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun callComplete(file: File) {
|
||||
handler.post {
|
||||
if (!doneListenerCalled) {
|
||||
doneListenerCalled = true
|
||||
|
||||
onComplete(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun callError(exception: Exception) {
|
||||
handler.post {
|
||||
if (!doneListenerCalled) {
|
||||
doneListenerCalled = true
|
||||
|
||||
onError(exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
cancellationSignal.cancel()
|
||||
callError(InterruptedException())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package net.syncthing.lite.library
|
||||
|
||||
import android.arch.lifecycle.LiveData
|
||||
import android.arch.lifecycle.MutableLiveData
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.experimental.android.UI
|
||||
import kotlinx.coroutines.experimental.async
|
||||
import net.syncthing.java.bep.FolderBrowser
|
||||
import net.syncthing.java.client.SyncthingClient
|
||||
import net.syncthing.java.core.beans.DeviceId
|
||||
import net.syncthing.java.core.beans.FileInfo
|
||||
import net.syncthing.java.core.beans.FolderInfo
|
||||
import net.syncthing.java.core.beans.IndexInfo
|
||||
import net.syncthing.java.core.configuration.Configuration
|
||||
import org.jetbrains.anko.doAsync
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* This class helps when using the library.
|
||||
* It's required to start and stop it to make the callbacks fire (or stop to fire).
|
||||
*
|
||||
* It's possible to do multiple start and stop cycles with one instance of this class.
|
||||
*/
|
||||
class LibraryHandler(context: Context,
|
||||
private val onIndexUpdateProgressListener: (FolderInfo, Int) -> Unit = {_, _ -> },
|
||||
private val onIndexUpdateCompleteListener: (FolderInfo) -> Unit = {}) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "LibraryHandler"
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
}
|
||||
|
||||
private val libraryManager = DefaultLibraryManager.with(context)
|
||||
private val isStarted = AtomicBoolean(false)
|
||||
private val isListeningPortTakenInternal = MutableLiveData<Boolean>().apply { value = false }
|
||||
|
||||
val isListeningPortTaken: LiveData<Boolean> = isListeningPortTakenInternal
|
||||
|
||||
private val messageFromUnknownDeviceListeners = HashSet<(DeviceId) -> Unit>()
|
||||
private val internalMessageFromUnknownDeviceListener: (DeviceId) -> Unit = {
|
||||
deviceId ->
|
||||
|
||||
handler.post {
|
||||
messageFromUnknownDeviceListeners.forEach { listener -> listener(deviceId) }
|
||||
}
|
||||
}
|
||||
|
||||
fun start(onLibraryLoaded: (LibraryHandler) -> Unit = {}) {
|
||||
if (isStarted.getAndSet(true) == true) {
|
||||
throw IllegalStateException("already started")
|
||||
}
|
||||
|
||||
libraryManager.startLibraryUsage {
|
||||
libraryInstance ->
|
||||
|
||||
isListeningPortTakenInternal.value = libraryInstance.isListeningPortTaken
|
||||
onLibraryLoaded(this)
|
||||
|
||||
val client = libraryInstance.syncthingClient
|
||||
|
||||
client.indexHandler.registerOnIndexRecordAcquiredListener(this::onIndexRecordAcquired)
|
||||
client.indexHandler.registerOnFullIndexAcquiredListenersListener(this::onRemoteIndexAcquired)
|
||||
client.discoveryHandler.registerMessageFromUnknownDeviceListener(internalMessageFromUnknownDeviceListener)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (isStarted.getAndSet(false) == false) {
|
||||
throw IllegalStateException("already stopped")
|
||||
}
|
||||
|
||||
syncthingClient {
|
||||
try {
|
||||
it.indexHandler.unregisterOnIndexRecordAcquiredListener(this::onIndexRecordAcquired)
|
||||
it.indexHandler.unregisterOnFullIndexAcquiredListenersListener(this::onRemoteIndexAcquired)
|
||||
it.discoveryHandler.unregisterMessageFromUnknownDeviceListener(internalMessageFromUnknownDeviceListener)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// ignored, no idea why this is thrown
|
||||
}
|
||||
}
|
||||
|
||||
libraryManager.stopLibraryUsage()
|
||||
}
|
||||
|
||||
private fun onIndexRecordAcquired(folderInfo: FolderInfo, newRecords: List<FileInfo>, indexInfo: IndexInfo) {
|
||||
Log.i(TAG, "handleIndexRecordEvent trigger folder list update from index record acquired")
|
||||
|
||||
async(UI) {
|
||||
onIndexUpdateProgressListener(folderInfo, (indexInfo.getCompleted() * 100).toInt())
|
||||
}
|
||||
}
|
||||
|
||||
private fun onRemoteIndexAcquired(folderInfo: FolderInfo) {
|
||||
Log.i(TAG, "handleIndexAcquiredEvent trigger folder list update from index acquired")
|
||||
|
||||
async(UI) {
|
||||
onIndexUpdateCompleteListener(folderInfo)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The callback is executed asynchronously.
|
||||
* As soon as it returns, there is no guarantee about the availability of the library
|
||||
*/
|
||||
fun library(callback: (Configuration, SyncthingClient, FolderBrowser) -> Unit) {
|
||||
libraryManager.startLibraryUsage {
|
||||
doAsync {
|
||||
try {
|
||||
callback(it.configuration, it.syncthingClient, it.folderBrowser)
|
||||
} finally {
|
||||
libraryManager.stopLibraryUsage()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun syncthingClient(callback: (SyncthingClient) -> Unit) {
|
||||
library { _, s, _ -> callback(s) }
|
||||
}
|
||||
|
||||
fun configuration(callback: (Configuration) -> Unit) {
|
||||
library { c, _, _ -> callback(c) }
|
||||
}
|
||||
|
||||
fun folderBrowser(callback: (FolderBrowser) -> Unit) {
|
||||
library { _, _, f -> callback(f) }
|
||||
}
|
||||
|
||||
// these listeners are called at the UI Thread
|
||||
// there is no need to unregister because they removed from the library when close is called
|
||||
fun registerMessageFromUnknownDeviceListener(listener: (DeviceId) -> Unit) {
|
||||
messageFromUnknownDeviceListeners.add(listener)
|
||||
}
|
||||
|
||||
fun unregisterMessageFromUnknownDeviceListener(listener: (DeviceId) -> Unit) {
|
||||
messageFromUnknownDeviceListeners.remove(listener)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package net.syncthing.lite.library
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import net.syncthing.java.client.SyncthingClient
|
||||
import net.syncthing.java.core.configuration.Configuration
|
||||
import net.syncthing.repository.android.SqliteIndexRepository
|
||||
import net.syncthing.repository.android.TempDirectoryLocalRepository
|
||||
import net.syncthing.repository.android.database.RepositoryDatabase
|
||||
import java.io.File
|
||||
import java.net.DatagramSocket
|
||||
import java.net.InetAddress
|
||||
import java.net.SocketException
|
||||
|
||||
/**
|
||||
* This class is used internally to access the syncthing-java library
|
||||
* There should be never more than 1 instance of this class
|
||||
*
|
||||
* This class can not be recycled. This means that after doing a shutdown of it,
|
||||
* a new instance must be created
|
||||
*
|
||||
* The creation and the shutdown are synchronous, so keep them out of the UI Thread
|
||||
*/
|
||||
class LibraryInstance (context: Context) {
|
||||
companion object {
|
||||
private const val LOG_TAG = "LibraryInstance"
|
||||
|
||||
/**
|
||||
* Check if listening port for local discovery is taken by another app. Do this check here to
|
||||
* avoid adding another callback.
|
||||
*/
|
||||
private fun checkIsListeningPortTaken(): Boolean {
|
||||
try {
|
||||
DatagramSocket(21027, InetAddress.getByName("0.0.0.0")).close()
|
||||
|
||||
return false
|
||||
} catch (e: SocketException) {
|
||||
Log.w(LOG_TAG, e)
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val tempRepository = TempDirectoryLocalRepository(File(context.filesDir, "temp_repository"))
|
||||
|
||||
val isListeningPortTaken = checkIsListeningPortTaken() // this must come first to work correctly
|
||||
val configuration = Configuration(configFolder = context.filesDir)
|
||||
val syncthingClient = SyncthingClient(
|
||||
configuration = configuration,
|
||||
repository = SqliteIndexRepository(
|
||||
database = RepositoryDatabase.with(context),
|
||||
closeDatabaseOnClose = false,
|
||||
clearTempStorageHook = { tempRepository.deleteAllData() }
|
||||
),
|
||||
tempRepository = tempRepository
|
||||
)
|
||||
val folderBrowser = syncthingClient.indexHandler.newFolderBrowser()
|
||||
|
||||
fun shutdown() {
|
||||
folderBrowser.close()
|
||||
syncthingClient.close()
|
||||
configuration.persistNow()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package net.syncthing.lite.library
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.coroutines.experimental.suspendCoroutine
|
||||
|
||||
/**
|
||||
* This class manages the access to an LibraryInstance
|
||||
*
|
||||
* Users can get an instance with startLibraryUsage()
|
||||
* If they are done with it, the should call stopLibraryUsage()
|
||||
* After this, it's NOT safe to continue using the received LibraryInstance
|
||||
*
|
||||
* Every call to startLibraryUsage should be followed by an call to stopLibraryUsage,
|
||||
* even if the callback was not called yet. It can still be called, so users should watch out.
|
||||
*
|
||||
* All listeners are executed at the UI Thread (except the synchronousInstanceCreator)
|
||||
*
|
||||
* The userCounterListener is always called before the isRunningListener
|
||||
*
|
||||
* The listeners are called for all changes, nothing is skipped or batched
|
||||
*/
|
||||
class LibraryManager (
|
||||
val synchronousInstanceCreator: () -> LibraryInstance,
|
||||
val userCounterListener: (Int) -> Unit = {},
|
||||
val isRunningListener: (isRunning: Boolean) -> Unit = {}
|
||||
) {
|
||||
companion object {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
}
|
||||
|
||||
// this must be a SingleThreadExecutor to avoid race conditions
|
||||
// only this Thread should access instance and userCounter
|
||||
private val startStopExecutor = Executors.newSingleThreadExecutor()
|
||||
|
||||
private var instance: LibraryInstance? = null
|
||||
private var userCounter = 0
|
||||
|
||||
fun startLibraryUsage(callback: (LibraryInstance) -> Unit) {
|
||||
startStopExecutor.submit {
|
||||
val newUserCounter = ++userCounter
|
||||
handler.post { userCounterListener(newUserCounter) }
|
||||
|
||||
if (instance == null) {
|
||||
instance = synchronousInstanceCreator()
|
||||
handler.post { isRunningListener(true) }
|
||||
}
|
||||
|
||||
handler.post { callback(instance!!) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun startLibraryUsageCoroutine(): LibraryInstance {
|
||||
return suspendCoroutine { continuation ->
|
||||
startLibraryUsage { instance ->
|
||||
continuation.resume(instance)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopLibraryUsage() {
|
||||
startStopExecutor.submit {
|
||||
val newUserCounter = --userCounter
|
||||
|
||||
if (newUserCounter < 0) {
|
||||
userCounter = 0
|
||||
|
||||
throw IllegalStateException("can not stop library usage if there are 0 users")
|
||||
}
|
||||
|
||||
handler.post { userCounterListener(newUserCounter) }
|
||||
}
|
||||
}
|
||||
|
||||
fun shutdownIfThereAreZeroUsers(listener: (wasShutdownPerformed: Boolean) -> Unit = {}) {
|
||||
startStopExecutor.submit {
|
||||
if (userCounter == 0) {
|
||||
instance?.shutdown()
|
||||
instance = null
|
||||
|
||||
handler.post { isRunningListener(false) }
|
||||
handler.post { listener(true) }
|
||||
} else {
|
||||
handler.post { listener(false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package net.syncthing.lite.library
|
||||
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.os.CancellationSignal
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.provider.DocumentsContract.Document
|
||||
import android.provider.DocumentsContract.Root
|
||||
import android.provider.DocumentsProvider
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.experimental.cancel
|
||||
import kotlinx.coroutines.experimental.runBlocking
|
||||
import net.syncthing.java.bep.IndexBrowser
|
||||
import net.syncthing.java.core.beans.FileInfo
|
||||
import net.syncthing.java.core.beans.FolderInfo
|
||||
import net.syncthing.java.core.beans.FolderStats
|
||||
import net.syncthing.lite.R
|
||||
import java.io.FileNotFoundException
|
||||
import java.net.URLConnection
|
||||
import java.util.concurrent.CountDownLatch
|
||||
|
||||
class SyncthingProvider : DocumentsProvider() {
|
||||
|
||||
companion object {
|
||||
private const val Tag = "SyncthingProvider"
|
||||
private val DefaultRootProjection = arrayOf(
|
||||
Root.COLUMN_ROOT_ID,
|
||||
Root.COLUMN_FLAGS,
|
||||
Root.COLUMN_TITLE,
|
||||
Root.COLUMN_SUMMARY,
|
||||
Root.COLUMN_DOCUMENT_ID,
|
||||
Root.COLUMN_ICON)
|
||||
private val DefaultDocumentProjection = arrayOf(
|
||||
Document.COLUMN_DOCUMENT_ID,
|
||||
Document.COLUMN_DISPLAY_NAME,
|
||||
Document.COLUMN_SIZE,
|
||||
Document.COLUMN_MIME_TYPE,
|
||||
Document.COLUMN_LAST_MODIFIED,
|
||||
Document.COLUMN_FLAGS)
|
||||
}
|
||||
|
||||
override fun onCreate(): Boolean {
|
||||
Log.d(Tag, "onCreate()")
|
||||
return true
|
||||
}
|
||||
|
||||
// this instance is not started -> it connects and disconnects on demand
|
||||
private val libraryHandler: LibraryHandler by lazy { LibraryHandler(context) }
|
||||
private val libraryManager: LibraryManager by lazy { DefaultLibraryManager.with(context) }
|
||||
|
||||
override fun queryRoots(projection: Array<String>?): Cursor {
|
||||
Log.d(Tag, "queryRoots($projection)")
|
||||
val latch = CountDownLatch(1)
|
||||
var folders: List<Pair<FolderInfo, FolderStats>>? = null
|
||||
libraryHandler.folderBrowser { folderBrowser ->
|
||||
folders = folderBrowser.folderInfoAndStatsList()
|
||||
latch.countDown()
|
||||
}
|
||||
latch.await()
|
||||
|
||||
val result = MatrixCursor(projection ?: DefaultRootProjection)
|
||||
folders!!.forEach { folder ->
|
||||
val row = result.newRow()
|
||||
row.add(Root.COLUMN_ROOT_ID, folder.first.folderId)
|
||||
row.add(Root.COLUMN_SUMMARY, folder.first.label)
|
||||
row.add(Root.COLUMN_FLAGS, 0)
|
||||
row.add(Root.COLUMN_TITLE, context.getString(R.string.app_name))
|
||||
row.add(Root.COLUMN_DOCUMENT_ID, getDocIdForFile(folder.first))
|
||||
row.add(Root.COLUMN_ICON, R.mipmap.ic_launcher)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override fun queryChildDocuments(parentDocumentId: String, projection: Array<String>?,
|
||||
sortOrder: String?): Cursor {
|
||||
Log.d(Tag, "queryChildDocuments($parentDocumentId, $projection, $sortOrder)")
|
||||
val result = MatrixCursor(projection ?: DefaultDocumentProjection)
|
||||
getIndexBrowser(getFolderIdForDocId(parentDocumentId))
|
||||
.listFiles(getPathForDocId(parentDocumentId))
|
||||
.forEach { fileInfo ->
|
||||
includeFile(result, fileInfo)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override fun queryDocument(documentId: String, projection: Array<String>?): Cursor {
|
||||
Log.d(Tag, "queryDocument($documentId, $projection)")
|
||||
val result = MatrixCursor(projection ?: DefaultDocumentProjection)
|
||||
val fileInfo = getIndexBrowser(getFolderIdForDocId(documentId))
|
||||
.getFileInfoByAbsolutePath(getPathForDocId(documentId))
|
||||
includeFile(result, fileInfo)
|
||||
return result
|
||||
}
|
||||
|
||||
@Throws(FileNotFoundException::class)
|
||||
override fun openDocument(documentId: String, mode: String, signal: CancellationSignal?):
|
||||
ParcelFileDescriptor {
|
||||
Log.d(Tag, "openDocument($documentId, $mode, $signal)")
|
||||
val fileInfo = getIndexBrowser(getFolderIdForDocId(documentId))
|
||||
.getFileInfoByAbsolutePath(getPathForDocId(documentId))
|
||||
val accessMode = ParcelFileDescriptor.parseMode(mode)
|
||||
if (accessMode != ParcelFileDescriptor.MODE_READ_ONLY) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
val outputFile = runBlocking {
|
||||
signal?.setOnCancelListener {
|
||||
this.coroutineContext.cancel()
|
||||
}
|
||||
|
||||
val libraryInstance = libraryManager.startLibraryUsageCoroutine()
|
||||
|
||||
try {
|
||||
DownloadFileTask.downloadFileCoroutine(
|
||||
externalCacheDir = context.externalCacheDir,
|
||||
syncthingClient = libraryInstance.syncthingClient,
|
||||
fileInfo = fileInfo,
|
||||
onProgress = { /* ignore the progress */ }
|
||||
)
|
||||
} finally {
|
||||
libraryManager.stopLibraryUsage()
|
||||
}
|
||||
}
|
||||
|
||||
return ParcelFileDescriptor.open(outputFile, ParcelFileDescriptor.MODE_READ_ONLY)
|
||||
}
|
||||
|
||||
private fun includeFile(result: MatrixCursor, fileInfo: FileInfo) {
|
||||
val row = result.newRow()
|
||||
row.add(Document.COLUMN_DOCUMENT_ID, getDocIdForFile(fileInfo))
|
||||
row.add(Document.COLUMN_DISPLAY_NAME, fileInfo.fileName)
|
||||
row.add(Document.COLUMN_SIZE, fileInfo.size)
|
||||
val mime = if (fileInfo.isDirectory()) Document.MIME_TYPE_DIR
|
||||
else URLConnection.guessContentTypeFromName(fileInfo.fileName)
|
||||
row.add(Document.COLUMN_MIME_TYPE, mime)
|
||||
row.add(Document.COLUMN_LAST_MODIFIED, fileInfo.lastModified)
|
||||
row.add(Document.COLUMN_FLAGS, 0)
|
||||
}
|
||||
|
||||
private fun getFolderIdForDocId(docId: String) = docId.split(":")[0]
|
||||
|
||||
private fun getPathForDocId(docId: String) = docId.split(":")[1]
|
||||
|
||||
private fun getDocIdForFile(folderInfo: FolderInfo) = folderInfo.folderId + ":"
|
||||
|
||||
private fun getDocIdForFile(fileInfo: FileInfo) = fileInfo.folder + ":" + fileInfo.path
|
||||
|
||||
private fun getIndexBrowser(folderId: String): IndexBrowser {
|
||||
val latch = CountDownLatch(1)
|
||||
var indexBrowser: IndexBrowser? = null
|
||||
libraryHandler.syncthingClient {
|
||||
indexBrowser = it.indexHandler.newIndexBrowser(folderId)
|
||||
latch.countDown()
|
||||
}
|
||||
latch.await()
|
||||
return indexBrowser!!
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package net.syncthing.lite.library
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import net.syncthing.java.bep.BlockPusher
|
||||
import net.syncthing.java.client.SyncthingClient
|
||||
import net.syncthing.java.core.utils.PathUtils
|
||||
import net.syncthing.lite.utils.Util
|
||||
import org.apache.commons.io.IOUtils
|
||||
|
||||
// TODO: this should be an IntentService with notification
|
||||
class UploadFileTask(context: Context, syncthingClient: SyncthingClient,
|
||||
localFile: Uri, private val syncthingFolder: String,
|
||||
syncthingSubFolder: String,
|
||||
private val onProgress: (BlockPusher.FileUploadObserver) -> Unit,
|
||||
private val onComplete: () -> Unit,
|
||||
private val onError: () -> Unit) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "UploadFileTask"
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
}
|
||||
|
||||
private val syncthingPath = PathUtils.buildPath(syncthingSubFolder, Util.getContentFileName(context, localFile))
|
||||
private val uploadStream = context.contentResolver.openInputStream(localFile)
|
||||
|
||||
private var isCancelled = false
|
||||
|
||||
init {
|
||||
Log.i(TAG, "Uploading file $localFile to folder $syncthingFolder:$syncthingPath")
|
||||
syncthingClient.getBlockPusher(syncthingFolder, { blockPusher ->
|
||||
val observer = blockPusher.pushFile(uploadStream, syncthingFolder, syncthingPath)
|
||||
|
||||
handler.post { onProgress(observer) }
|
||||
|
||||
while (!observer.isCompleted()) {
|
||||
if (isCancelled)
|
||||
return@getBlockPusher
|
||||
|
||||
observer.waitForProgressUpdate()
|
||||
Log.i(TAG, "upload progress = ${observer.progressPercentage()}%")
|
||||
handler.post { onProgress(observer) }
|
||||
}
|
||||
IOUtils.closeQuietly(uploadStream)
|
||||
handler.post { onComplete() }
|
||||
}, { handler.post { onError() } })
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
isCancelled = true
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package net.syncthing.lite.utils
|
||||
|
||||
import android.app.ProgressDialog
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.os.Handler
|
||||
import android.support.annotation.StringRes
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
import android.widget.Toast
|
||||
import net.syncthing.java.bep.BlockPuller
|
||||
import net.syncthing.java.client.SyncthingClient
|
||||
import net.syncthing.java.core.beans.FileInfo
|
||||
import net.syncthing.lite.R
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.apache.commons.io.FilenameUtils
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
class DownloadFileTask(private val mContext: Context, private val mSyncthingClient: SyncthingClient, private val mFileInfo: FileInfo) {
|
||||
private val mMainHandler: Handler = Handler()
|
||||
|
||||
private lateinit var progressDialog: ProgressDialog
|
||||
private var cancelled = false
|
||||
|
||||
fun downloadFile() {
|
||||
showDialog()
|
||||
// TODO: can just pass FileInfo directly?
|
||||
Thread {
|
||||
mSyncthingClient.pullFile(mFileInfo.folder, mFileInfo.path, { observer ->
|
||||
onProgress(observer)
|
||||
try {
|
||||
while (!observer.isCompleted) {
|
||||
if (cancelled)
|
||||
return@pullFile
|
||||
|
||||
observer.waitForProgressUpdate()
|
||||
Log.i("pullFile", "download progress = " + observer.progressMessage)
|
||||
onProgress(observer)
|
||||
}
|
||||
|
||||
val outputDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||
val outputFile = File(outputDir, mFileInfo.fileName)
|
||||
FileUtils.copyInputStreamToFile(observer.inputStream, outputFile)
|
||||
Log.i(TAG, "downloaded file = " + mFileInfo.path)
|
||||
onComplete(outputFile)
|
||||
} catch (e: IOException) {
|
||||
onError(R.string.toast_file_download_failed)
|
||||
Log.w(TAG, "Failed to download file " + mFileInfo, e)
|
||||
} catch (e: InterruptedException) {
|
||||
onError(R.string.toast_file_download_failed)
|
||||
Log.w(TAG, "Failed to download file " + mFileInfo, e)
|
||||
}
|
||||
}) { onError(R.string.toast_file_download_failed) }
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun showDialog() {
|
||||
progressDialog = ProgressDialog(mContext)
|
||||
progressDialog.setMessage(mContext.getString(R.string.dialog_downloading_file, mFileInfo.fileName))
|
||||
progressDialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL)
|
||||
progressDialog.setCancelable(true)
|
||||
progressDialog.setOnCancelListener { cancelled = true }
|
||||
progressDialog.isIndeterminate = true
|
||||
progressDialog.show()
|
||||
}
|
||||
|
||||
private fun onProgress(fileDownloadObserver: BlockPuller.FileDownloadObserver) {
|
||||
mMainHandler.post {
|
||||
progressDialog.isIndeterminate = false
|
||||
progressDialog.max = (mFileInfo.size as Long).toInt()
|
||||
progressDialog.progress = (fileDownloadObserver.progress * mFileInfo.size!!).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onComplete(file: File) {
|
||||
progressDialog.dismiss()
|
||||
if (cancelled)
|
||||
return
|
||||
|
||||
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(FilenameUtils.getExtension(file.name))
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
intent.setDataAndType(Uri.fromFile(file), mimeType)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
try {
|
||||
mContext.startActivity(intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
onError(R.string.toast_open_file_failed)
|
||||
Log.w(TAG, "No handler found for file " + file.name, e)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun onError(@StringRes error: Int) {
|
||||
progressDialog.dismiss()
|
||||
mMainHandler.post { Toast.makeText(mContext, error, Toast.LENGTH_SHORT).show() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private val TAG = "DownloadFileTask"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package net.syncthing.lite.fragments
|
||||
package net.syncthing.lite.utils
|
||||
|
||||
import android.content.Intent
|
||||
import android.support.v4.app.Fragment
|
||||
@@ -1,84 +0,0 @@
|
||||
package net.syncthing.lite.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.google.common.eventbus.Subscribe
|
||||
import net.syncthing.java.bep.FolderBrowser
|
||||
import net.syncthing.java.bep.IndexHandler
|
||||
import net.syncthing.java.client.SyncthingClient
|
||||
import net.syncthing.java.core.beans.FolderInfo
|
||||
import net.syncthing.java.core.configuration.ConfigurationService
|
||||
import net.syncthing.java.core.security.KeystoreHandler
|
||||
import org.apache.commons.io.FileUtils
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
class LibraryHandler {
|
||||
|
||||
private var mOnIndexUpdatedListener: OnIndexUpdatedListener? = null
|
||||
var configuration: ConfigurationService? = null
|
||||
private set
|
||||
var syncthingClient: SyncthingClient? = null
|
||||
private set
|
||||
var folderBrowser: FolderBrowser? = null
|
||||
private set
|
||||
|
||||
interface OnIndexUpdatedListener {
|
||||
fun onIndexUpdateProgress(folder: FolderInfo, percentage: Int)
|
||||
fun onIndexUpdateComplete()
|
||||
}
|
||||
|
||||
fun init(context: Context) {
|
||||
configuration = ConfigurationService.newLoader()
|
||||
.setCache(File(context.externalCacheDir, "cache"))
|
||||
.setDatabase(File(context.getExternalFilesDir(null), "database"))
|
||||
.loadFrom(File(context.getExternalFilesDir(null), "config.properties"))
|
||||
configuration!!.edit().setDeviceName(Util.getDeviceName())
|
||||
try {
|
||||
FileUtils.cleanDirectory(configuration!!.temp)
|
||||
} catch (ex: IOException) {
|
||||
Log.e(TAG, "error", ex)
|
||||
destroy()
|
||||
}
|
||||
|
||||
KeystoreHandler.newLoader().loadAndStore(configuration!!)
|
||||
configuration!!.edit().persistLater()
|
||||
Log.i(TAG, "loaded mConfiguration = " + configuration!!.newWriter().dumpToString())
|
||||
Log.i(TAG, "storage space = " + configuration!!.storageInfo.dumpAvailableSpace())
|
||||
syncthingClient = net.syncthing.java.client.SyncthingClient(configuration!!)
|
||||
//TODO listen for device events, update device list
|
||||
folderBrowser = syncthingClient!!.indexHandler.newFolderBrowser()
|
||||
}
|
||||
|
||||
fun setOnIndexUpdatedListener(onIndexUpdatedListener: OnIndexUpdatedListener) {
|
||||
mOnIndexUpdatedListener = onIndexUpdatedListener
|
||||
syncthingClient!!.indexHandler.eventBus.register(object : Any() {
|
||||
|
||||
@Subscribe
|
||||
fun handleIndexRecordAquiredEvent(event: IndexHandler.IndexRecordAquiredEvent) {
|
||||
val folder = syncthingClient!!.indexHandler.getFolderInfo(event.folder)
|
||||
val indexInfo = event.indexInfo
|
||||
event.newRecords.size
|
||||
Log.i(TAG, "handleIndexRecordEvent trigger folder list update from index record acquired")
|
||||
mOnIndexUpdatedListener!!.onIndexUpdateProgress(folder, (indexInfo.completed * 100).toInt())
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
fun handleRemoteIndexAquiredEvent(event: IndexHandler.FullIndexAquiredEvent) {
|
||||
Log.i(TAG, "handleIndexAquiredEvent trigger folder list update from index acquired")
|
||||
mOnIndexUpdatedListener!!.onIndexUpdateComplete()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
folderBrowser!!.close()
|
||||
syncthingClient!!.close()
|
||||
configuration!!.close()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private val TAG = "LibConnectionHandler"
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package net.syncthing.lite.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.preference.PreferenceManager
|
||||
import android.widget.Toast
|
||||
import net.syncthing.java.client.SyncthingClient
|
||||
import net.syncthing.lite.R
|
||||
import java.util.*
|
||||
|
||||
class UpdateIndexTask(private val mContext: Context, private val mSyncthingClient: SyncthingClient) {
|
||||
private val mPreferences = PreferenceManager.getDefaultSharedPreferences(mContext)
|
||||
private val mMainHandler = Handler()
|
||||
|
||||
fun updateIndex() {
|
||||
if (sIndexUpdateInProgress)
|
||||
return
|
||||
|
||||
sIndexUpdateInProgress = true
|
||||
mSyncthingClient.updateIndexFromPeers { _, failures ->
|
||||
sIndexUpdateInProgress = false
|
||||
if (failures.isEmpty()) {
|
||||
showToast(mContext.getString(R.string.toast_index_update_successful))
|
||||
} else {
|
||||
showToast(mContext.getString(R.string.toast_index_update_failed, failures.size))
|
||||
}
|
||||
mPreferences.edit()
|
||||
.putLong(LAST_INDEX_UPDATE_TS_PREF, Date().time)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showToast(message: String) {
|
||||
mMainHandler.post { Toast.makeText(mContext, message, Toast.LENGTH_SHORT).show() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
val LAST_INDEX_UPDATE_TS_PREF = "LAST_INDEX_UPDATE_TS"
|
||||
|
||||
private var sIndexUpdateInProgress: Boolean = false
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package net.syncthing.lite.utils
|
||||
|
||||
import android.app.ProgressDialog
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import net.syncthing.java.bep.BlockPusher
|
||||
import net.syncthing.java.client.SyncthingClient
|
||||
import net.syncthing.java.core.utils.PathUtils
|
||||
import net.syncthing.lite.R
|
||||
import java.io.IOException
|
||||
|
||||
// TODO: this should be an IntentService with notification
|
||||
class UploadFileTask(private val context: Context, private val syncthingClient: SyncthingClient,
|
||||
private val localFile: Uri, private val syncthingFolder: String,
|
||||
syncthingSubFolder: String,
|
||||
private val onUploadCompleteListener: () -> Unit) {
|
||||
|
||||
companion object {
|
||||
private val TAG = "UploadFileTask"
|
||||
}
|
||||
|
||||
private val fileName = Util.getContentFileName(context, localFile)
|
||||
private val syncthingPath = PathUtils.buildPath(syncthingSubFolder, fileName)
|
||||
private val mainHandler = Handler()
|
||||
|
||||
private lateinit var mProgressDialog: ProgressDialog
|
||||
private var mCancelled = false
|
||||
|
||||
fun uploadFile() {
|
||||
createDialog()
|
||||
Log.i(TAG, "Uploading file $localFile to folder $syncthingFolder:$syncthingPath")
|
||||
try {
|
||||
val uploadStream = context.contentResolver.openInputStream(localFile)
|
||||
syncthingClient.pushFile(uploadStream, syncthingFolder, syncthingPath, { observer ->
|
||||
onProgress(observer)
|
||||
try {
|
||||
while (!observer.isCompleted) {
|
||||
if (mCancelled)
|
||||
return@pushFile
|
||||
|
||||
observer.waitForProgressUpdate()
|
||||
Log.i(TAG, "upload progress = " + observer.progressMessage)
|
||||
onProgress(observer)
|
||||
}
|
||||
} catch (e: InterruptedException) {
|
||||
onError()
|
||||
}
|
||||
|
||||
onComplete()
|
||||
}, { this.onError() })
|
||||
} catch (e: IOException) {
|
||||
onError()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun createDialog() {
|
||||
mProgressDialog = ProgressDialog(context)
|
||||
mProgressDialog.setMessage(context.getString(R.string.dialog_uploading_file, fileName))
|
||||
mProgressDialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL)
|
||||
mProgressDialog.setCancelable(true)
|
||||
mProgressDialog.setOnCancelListener { mCancelled = true }
|
||||
mProgressDialog.isIndeterminate = true
|
||||
mProgressDialog.show()
|
||||
}
|
||||
|
||||
private fun onProgress(observer: BlockPusher.FileUploadObserver) {
|
||||
mainHandler.post {
|
||||
mProgressDialog.isIndeterminate = false
|
||||
mProgressDialog.max = observer.dataSource.size.toInt()
|
||||
mProgressDialog.progress = (observer.progress * observer.dataSource.size).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onComplete() {
|
||||
mProgressDialog.dismiss()
|
||||
if (mCancelled)
|
||||
return
|
||||
|
||||
Log.i(TAG, "Uploaded file $fileName to folder $syncthingFolder:$syncthingPath")
|
||||
mainHandler.post {
|
||||
Toast.makeText(context, R.string.toast_upload_complete, Toast.LENGTH_SHORT).show()
|
||||
onUploadCompleteListener()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onError() {
|
||||
mProgressDialog.dismiss()
|
||||
mainHandler.post { Toast.makeText(context, R.string.toast_file_upload_failed, Toast.LENGTH_SHORT).show() }
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,24 @@ package net.syncthing.lite.utils
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import com.google.common.base.Objects.equal
|
||||
import com.google.common.base.Strings.nullToEmpty
|
||||
import android.provider.OpenableColumns
|
||||
import kotlinx.coroutines.experimental.android.UI
|
||||
import kotlinx.coroutines.experimental.async
|
||||
import net.syncthing.java.core.beans.DeviceId
|
||||
import net.syncthing.java.core.beans.DeviceInfo
|
||||
import net.syncthing.lite.R
|
||||
import net.syncthing.lite.library.LibraryHandler
|
||||
import org.apache.commons.lang3.StringUtils.capitalize
|
||||
import java.io.File
|
||||
import org.jetbrains.anko.toast
|
||||
import java.io.IOException
|
||||
import java.security.InvalidParameterException
|
||||
import java.util.*
|
||||
|
||||
object Util {
|
||||
|
||||
fun getDeviceName(): String {
|
||||
val manufacturer = nullToEmpty(Build.MANUFACTURER)
|
||||
val model = nullToEmpty(Build.MODEL)
|
||||
val manufacturer = Build.MANUFACTURER ?: ""
|
||||
val model = Build.MODEL ?: ""
|
||||
val deviceName =
|
||||
if (model.startsWith(manufacturer)) {
|
||||
capitalize(model)
|
||||
@@ -22,19 +28,34 @@ object Util {
|
||||
capitalize(manufacturer) + " " + model
|
||||
}
|
||||
return deviceName ?: "android"
|
||||
}
|
||||
}
|
||||
|
||||
fun getContentFileName(context: Context, contentUri: Uri): String {
|
||||
var fileName = File(contentUri.lastPathSegment).name
|
||||
if (equal(contentUri.scheme, "content")) {
|
||||
context.contentResolver.query(contentUri, arrayOf(MediaStore.Images.Media.DATA), null, null, null)!!.use { cursor ->
|
||||
val columnIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
|
||||
cursor.moveToFirst()
|
||||
val path = cursor.getString(columnIndex)
|
||||
Log.d("Main", "recovered 'content' uri real path = " + path)
|
||||
fileName = File(Uri.parse(path).lastPathSegment).name
|
||||
fun getContentFileName(context: Context, uri: Uri): String {
|
||||
context.contentResolver.query(uri, null, null, null, null, null).use { cursor ->
|
||||
if (cursor == null || !cursor.moveToFirst()) {
|
||||
throw InvalidParameterException("Cursor is null or empty")
|
||||
}
|
||||
return cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME))
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun importDeviceId(libraryHandler: LibraryHandler?, context: Context?, deviceId: String,
|
||||
onComplete: () -> Unit) {
|
||||
val deviceId2 = DeviceId(deviceId.toUpperCase(Locale.US))
|
||||
libraryHandler?.configuration { configuration ->
|
||||
if (!configuration.peerIds.contains(deviceId2)) {
|
||||
configuration.peers = configuration.peers + DeviceInfo(deviceId2, null)
|
||||
configuration.persistLater()
|
||||
async(UI) {
|
||||
context?.toast(context.getString(R.string.device_import_success, deviceId2.shortId))
|
||||
onComplete()
|
||||
}
|
||||
} else {
|
||||
async(UI) {
|
||||
context?.toast(context.getString(R.string.device_already_known, deviceId2.shortId))
|
||||
}
|
||||
}
|
||||
}
|
||||
return fileName
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M16,1L4,1c-1.1,0 -2,0.9 -2,2v14h2L4,3h12L16,1zM19,5L8,5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h11c1.1,0 2,-0.9 2,-2L21,7c0,-1.1 -0.9,-2 -2,-2zM19,21L8,21L8,7h11v14z"/>
|
||||
</vector>
|
||||
@@ -1,9 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M13,3h-2v10h2L13,3zM17.83,5.17l-1.42,1.42C17.99,7.86 19,9.81 19,12c0,3.87 -3.13,7 -7,7s-7,-3.13 -7,-7c0,-2.19 1.01,-4.14 2.58,-5.42L6.17,5.17C4.23,6.82 3,9.26 3,12c0,4.97 4.03,9 9,9s9,-4.03 9,-9c0,-2.74 -1.23,-5.18 -3.17,-6.83z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M3,11H5V13H3V11M11,5H13V9H11V5M9,11H13V15H11V13H9V11M15,11H17V13H19V11H21V13H19V15H21V19H19V21H17V19H13V21H11V17H15V15H17V13H15V11M19,19V15H17V19H19M15,3H21V9H15V3M17,5V7H19V5H17M3,3H9V9H3V3M5,5V7H7V5H5M3,15H9V21H3V15M5,17V19H7V17H5Z"/>
|
||||
</vector>
|
||||
@@ -1,9 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#7D000000"
|
||||
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#7D000000"
|
||||
android:pathData="M19.43,12.98c0.04,-0.32 0.07,-0.64 0.07,-0.98s-0.03,-0.66 -0.07,-0.98l2.11,-1.65c0.19,-0.15 0.24,-0.42 0.12,-0.64l-2,-3.46c-0.12,-0.22 -0.39,-0.3 -0.61,-0.22l-2.49,1c-0.52,-0.4 -1.08,-0.73 -1.69,-0.98l-0.38,-2.65C14.46,2.18 14.25,2 14,2h-4c-0.25,0 -0.46,0.18 -0.49,0.42l-0.38,2.65c-0.61,0.25 -1.17,0.59 -1.69,0.98l-2.49,-1c-0.23,-0.09 -0.49,0 -0.61,0.22l-2,3.46c-0.13,0.22 -0.07,0.49 0.12,0.64l2.11,1.65c-0.04,0.32 -0.07,0.65 -0.07,0.98s0.03,0.66 0.07,0.98l-2.11,1.65c-0.19,0.15 -0.24,0.42 -0.12,0.64l2,3.46c0.12,0.22 0.39,0.3 0.61,0.22l2.49,-1c0.52,0.4 1.08,0.73 1.69,0.98l0.38,2.65c0.03,0.24 0.24,0.42 0.49,0.42h4c0.25,0 0.46,-0.18 0.49,-0.42l0.38,-2.65c0.61,-0.25 1.17,-0.59 1.69,-0.98l2.49,1c0.23,0.09 0.49,0 0.61,-0.22l2,-3.46c0.12,-0.22 0.07,-0.49 -0.12,-0.64l-2.11,-1.65zM12,15.5c-1.93,0 -3.5,-1.57 -3.5,-3.5s1.57,-3.5 3.5,-3.5 3.5,1.57 3.5,3.5 -1.57,3.5 -3.5,3.5z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92 1.61,0 2.92,-1.31 2.92,-2.92s-1.31,-2.92 -2.92,-2.92z"/>
|
||||
</vector>
|
||||
@@ -2,82 +2,43 @@
|
||||
<layout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<data>
|
||||
<variable
|
||||
name="isLoading"
|
||||
type="Boolean" />
|
||||
|
||||
<import type="android.view.View" />
|
||||
</data>
|
||||
|
||||
<RelativeLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<!--center content BEGIN-->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:divider="?android:listDivider"
|
||||
android:showDividers="middle">
|
||||
<android.support.v7.widget.RecyclerView
|
||||
android:visibility="@{safeUnbox(isLoading) ? View.GONE : View.VISIBLE}"
|
||||
app:layoutManager="android.support.v7.widget.LinearLayoutManager"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:id="@+id/list_view" />
|
||||
|
||||
<!--index loading progress BEGIN-->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="40dp"
|
||||
android:padding="8dp"
|
||||
android:id="@+id/main_index_progress_bar"
|
||||
android:orientation="horizontal"
|
||||
android:background="@color/primary"
|
||||
android:visibility="gone">
|
||||
<ProgressBar
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:indeterminate="true"
|
||||
android:paddingStart="12dp"/>
|
||||
<TextView
|
||||
android:id="@+id/main_index_progress_bar_label"
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_height="match_parent"
|
||||
android:textSize="18sp"
|
||||
android:textColor="@color/white_on_primary"
|
||||
android:text="@string/index_update_progress_message"
|
||||
android:layout_gravity="start"
|
||||
android:textAlignment="gravity"
|
||||
/>
|
||||
</LinearLayout>
|
||||
<!--index loading progress END-->
|
||||
<ProgressBar
|
||||
android:visibility="@{safeUnbox(isLoading) ? View.VISIBLE : View.GONE}"
|
||||
android:layout_centerInParent="true"
|
||||
android:id="@+id/progress_bar"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<!--main list view BEGIN-->
|
||||
<ListView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:id="@+id/main_folder_and_files_list_view"
|
||||
android:divider="@color/divider"
|
||||
android:dividerHeight="2dp">
|
||||
</ListView>
|
||||
<TextView
|
||||
android:id="@+id/main_list_view_empty_element"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:textAlignment="center"
|
||||
android:text="@string/folder_list_empty_message"
|
||||
android:textSize="20sp"
|
||||
android:visibility="gone" />
|
||||
<!--main list view END-->
|
||||
|
||||
</LinearLayout>
|
||||
<!--center content END-->
|
||||
|
||||
<!--upload here overlay button BEGIN-->
|
||||
<android.support.design.widget.FloatingActionButton
|
||||
android:id="@+id/main_list_view_upload_here_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="24dp"
|
||||
android:layout_marginBottom="24dp"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_alignParentBottom="true"
|
||||
app:elevation="6dp"
|
||||
app:pressedTranslationZ="12dp"
|
||||
android:src="@drawable/ic_file_upload_white_24dp"/>
|
||||
<!--upload here overlay button END-->
|
||||
<android.support.design.widget.FloatingActionButton
|
||||
android:id="@+id/main_list_view_upload_here_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="24dp"
|
||||
android:layout_marginBottom="24dp"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_alignParentBottom="true"
|
||||
app:elevation="6dp"
|
||||
app:pressedTranslationZ="12dp"
|
||||
android:src="@drawable/ic_file_upload_white_24dp"/>
|
||||
|
||||
</RelativeLayout>
|
||||
|
||||
|
||||
@@ -5,40 +5,12 @@
|
||||
android:id="@+id/drawer_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
<!-- The main content view -->
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="40dp"
|
||||
android:padding="8dp"
|
||||
android:id="@+id/main_index_progress_bar"
|
||||
android:orientation="horizontal"
|
||||
android:background="@color/primary"
|
||||
android:visibility="gone">
|
||||
<ProgressBar
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:indeterminate="true"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp"/>
|
||||
<TextView
|
||||
android:id="@+id/main_index_progress_bar_label"
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_height="match_parent"
|
||||
android:textSize="18sp"
|
||||
android:textColor="@color/white_on_primary"
|
||||
android:text="@string/index_update_progress_message"
|
||||
android:layout_gravity="start"
|
||||
android:textAlignment="gravity"
|
||||
/>
|
||||
</LinearLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/content_frame"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
<!-- The navigation drawer -->
|
||||
|
||||
<android.support.design.widget.NavigationView
|
||||
android:id="@+id/navigation"
|
||||
android:layout_width="wrap_content"
|
||||
@@ -46,6 +18,7 @@
|
||||
android:layout_gravity="start"
|
||||
android:background="@android:color/white"
|
||||
app:menu="@menu/drawer_view" />
|
||||
|
||||
</android.support.v4.widget.DrawerLayout>
|
||||
|
||||
</layout>
|
||||
@@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<LinearLayout
|
||||
android:orientation="vertical"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="12dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/device_id"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1"
|
||||
android:padding="8dp"
|
||||
android:clickable="true"
|
||||
android:drawableEnd="@drawable/ic_content_copy_black_24dp"
|
||||
android:focusable="true"
|
||||
android:fontFamily="monospace"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Caption"
|
||||
tools:text="ASD1ASD-ASD1ASD-ASD1ASD-ASD1ASD-ASD1ASD-ASD1ASD-ASD1ASD-ASD1ASD"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/share"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:padding="8dp"
|
||||
android:drawableEnd="@drawable/ic_share_black_24dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<ViewFlipper
|
||||
android:id="@+id/flipper"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<RelativeLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ProgressBar
|
||||
android:layout_centerInParent="true"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"/>
|
||||
|
||||
</RelativeLayout>
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/qr_code"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:scaleType="fitCenter" />
|
||||
|
||||
</ViewFlipper>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
</layout>
|
||||
@@ -4,27 +4,20 @@
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="@dimen/abc_action_bar_content_inset_material"
|
||||
android:padding="24dp"
|
||||
android:theme="?alertDialogTheme"
|
||||
android:orientation="vertical">
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
<ProgressBar
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center">
|
||||
android:layout_marginEnd="24dp" />
|
||||
|
||||
<ProgressBar
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="@dimen/abc_action_bar_content_inset_material" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/loading_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"/>
|
||||
|
||||
</LinearLayout>
|
||||
<TextView
|
||||
android:id="@+id/loading_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
@@ -2,32 +2,44 @@
|
||||
<layout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<data>
|
||||
<variable
|
||||
name="isEmpty"
|
||||
type="Boolean" />
|
||||
|
||||
<import type="android.view.View" />
|
||||
</data>
|
||||
|
||||
<RelativeLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ListView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:id="@+id/list"
|
||||
android:divider="@color/divider"
|
||||
android:dividerHeight="2dp">
|
||||
</ListView>
|
||||
<TextView
|
||||
android:id="@+id/empty"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_centerInParent="true"
|
||||
android:text="@string/devices_list_view_empty_message"
|
||||
android:textSize="20sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<uk.co.markormesher.android_fab.FloatingActionButton
|
||||
android:id="@+id/fab"
|
||||
<android.support.v7.widget.RecyclerView
|
||||
app:layoutManager="android.support.v7.widget.LinearLayoutManager"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:buttonIcon="@drawable/ic_add_white_24dp"
|
||||
app:buttonBackgroundColour="@color/accent"/>
|
||||
android:id="@+id/list"
|
||||
android:divider="@color/divider"
|
||||
android:dividerHeight="2dp">
|
||||
</android.support.v7.widget.RecyclerView>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/empty"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_centerInParent="true"
|
||||
android:text="@string/devices_list_view_empty_message"
|
||||
android:textSize="20sp"
|
||||
android:visibility="@{safeUnbox(isEmpty) ? View.VISIBLE : View.GONE}" />
|
||||
|
||||
<android.support.design.widget.FloatingActionButton
|
||||
android:id="@+id/add_device"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentBottom="true"
|
||||
android:layout_alignParentEnd="true"
|
||||
app:useCompatPadding="true"
|
||||
android:src="@drawable/ic_add_white_24dp" />
|
||||
|
||||
</RelativeLayout>
|
||||
|
||||
|
||||
@@ -2,26 +2,69 @@
|
||||
<layout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<FrameLayout
|
||||
<data>
|
||||
<variable
|
||||
name="isEmpty"
|
||||
type="Boolean" />
|
||||
|
||||
<import type="android.view.View" />
|
||||
|
||||
<variable
|
||||
name="listeningPortTaken"
|
||||
type="Boolean" />
|
||||
</data>
|
||||
|
||||
<LinearLayout
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ListView
|
||||
<FrameLayout
|
||||
android:layout_weight="1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:id="@+id/list"
|
||||
android:divider="@color/divider"
|
||||
android:dividerHeight="2dp">
|
||||
</ListView>
|
||||
<TextView
|
||||
android:id="@+id/empty"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:text="@string/folder_list_empty_message"
|
||||
android:textSize="20sp"
|
||||
android:visibility="gone" />
|
||||
android:layout_height="0dp">
|
||||
|
||||
</FrameLayout>
|
||||
<android.support.v7.widget.RecyclerView
|
||||
app:layoutManager="android.support.v7.widget.LinearLayoutManager"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:id="@+id/list"
|
||||
android:visibility="@{safeUnbox(isEmpty) ? View.GONE : View.VISIBLE}" />
|
||||
|
||||
</layout>
|
||||
<TextView
|
||||
android:id="@+id/empty"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:text="@string/folder_list_empty_message"
|
||||
android:textSize="20sp"
|
||||
android:visibility="@{safeUnbox(isEmpty) ? View.VISIBLE : View.GONE}" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:theme="@style/ThemeOverlay.AppCompat.Dark"
|
||||
android:background="?colorPrimary"
|
||||
android:visibility="@{safeUnbox(listeningPortTaken) ? View.VISIBLE : View.GONE}"
|
||||
android:padding="8dp"
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<TextView
|
||||
android:textAppearance="?android:textAppearanceMedium"
|
||||
android:text="@string/other_syncthing_instance_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<TextView
|
||||
android:textAppearance="?android:textAppearanceSmall"
|
||||
android:text="@string/other_syncthing_instance_message"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</layout>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layout xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<data>
|
||||
<variable
|
||||
name="listeningPortTaken"
|
||||
type="Boolean" />
|
||||
|
||||
<import type="android.view.View" />
|
||||
</data>
|
||||
|
||||
<LinearLayout
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/intro_primary"
|
||||
android:padding="28dp"
|
||||
android:gravity="center_horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="24dp"
|
||||
android:textColor="#eee"
|
||||
android:textSize="24sp"
|
||||
android:text="@string/intro_page_one_title"/>
|
||||
|
||||
<ImageView
|
||||
android:layout_width="160dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:src="@mipmap/ic_launcher"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/description"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:layout_marginBottom="@dimen/appIntroBottomBarHeight"
|
||||
android:textSize="16sp"
|
||||
android:textAlignment="center"
|
||||
android:textColor="#eee"
|
||||
android:text="@string/intro_page_one_description" />
|
||||
|
||||
<TextView
|
||||
android:visibility="@{safeUnbox(listeningPortTaken) ? View.VISIBLE : View.GONE}"
|
||||
android:textAppearance="?android:textAppearanceMedium"
|
||||
android:textColor="#eee"
|
||||
android:text="@string/other_syncthing_instance_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<TextView
|
||||
android:layout_marginBottom="48dp"
|
||||
android:visibility="@{safeUnbox(listeningPortTaken) ? View.VISIBLE : View.GONE}"
|
||||
android:textAppearance="?android:textAppearanceSmall"
|
||||
android:textColor="#eee"
|
||||
android:text="@string/other_syncthing_instance_message"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</layout>
|
||||
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<LinearLayout
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/intro_primary"
|
||||
android:padding="28dp"
|
||||
android:gravity="center_horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="24dp"
|
||||
android:textColor="#eee"
|
||||
android:textSize="24sp"
|
||||
android:text="@string/intro_page_three_title"/>
|
||||
|
||||
<ProgressBar
|
||||
android:layout_width="72dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/description"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:layout_marginBottom="@dimen/appIntroBottomBarHeight"
|
||||
android:textSize="16sp"
|
||||
android:textAlignment="center"
|
||||
android:textColor="#eee"
|
||||
tools:text="@string/intro_page_three_description" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</layout>
|
||||
@@ -0,0 +1,73 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layout xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<LinearLayout
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/intro_primary"
|
||||
android:padding="28dp"
|
||||
android:gravity="center_horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="24dp"
|
||||
android:textColor="#eee"
|
||||
android:textSize="24sp"
|
||||
android:text="@string/intro_page_two_title"/>
|
||||
|
||||
<FrameLayout
|
||||
android:layout_weight="1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp">
|
||||
<ScrollView
|
||||
android:layout_gravity="center"
|
||||
android:id="@+id/scroll"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
<LinearLayout
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<include
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:id="@+id/enter_device_id"
|
||||
layout="@layout/view_enter_device_id" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/found_devices"
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<!--
|
||||
Found device ids will be put here as buttons
|
||||
|
||||
This does not use an ListView or RecyclerView because this allows using
|
||||
wrap_content as height and because it's expected to be an small list
|
||||
-->
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</FrameLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/description"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:layout_marginBottom="@dimen/appIntroBottomBarHeight"
|
||||
android:textSize="16sp"
|
||||
android:textAlignment="center"
|
||||
android:textColor="#eee"
|
||||
android:text="@string/intro_page_two_description" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</layout>
|
||||
@@ -1,23 +1,38 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layout xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<layout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<data>
|
||||
<variable
|
||||
name="name"
|
||||
type="String" />
|
||||
|
||||
<variable
|
||||
name="isConnected"
|
||||
type="Boolean" />
|
||||
</data>
|
||||
|
||||
<RelativeLayout
|
||||
android:background="?selectableItemBackground"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingBottom="12dp"
|
||||
android:paddingLeft="@dimen/abc_action_bar_content_inset_material"
|
||||
android:paddingRight="@dimen/abc_action_bar_content_inset_material"
|
||||
android:paddingLeft="24dp"
|
||||
android:paddingRight="24dp"
|
||||
android:paddingTop="12dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/device_icon"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:src="@drawable/ic_laptop_green_24dp"
|
||||
tools:src="@drawable/ic_laptop_green_24dp"
|
||||
android:src="@{safeUnbox(isConnected) ? @drawable/ic_laptop_green_24dp : @drawable/ic_laptop_red_24dp}"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_alignParentTop="true"/>
|
||||
|
||||
<TextView
|
||||
tools:text="Computer"
|
||||
android:text="@{name}"
|
||||
android:id="@+id/device_name"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_alignParentEnd="true"
|
||||
|
||||
@@ -2,7 +2,21 @@
|
||||
<layout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<data>
|
||||
<variable
|
||||
name="fileName"
|
||||
type="String" />
|
||||
|
||||
<variable
|
||||
name="fileSize"
|
||||
type="String" />
|
||||
|
||||
<import type="android.view.View" />
|
||||
<import type="android.text.TextUtils" />
|
||||
</data>
|
||||
|
||||
<RelativeLayout
|
||||
android:background="?selectableItemBackground"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingTop="8dp"
|
||||
@@ -19,6 +33,8 @@
|
||||
android:layout_alignParentTop="true" />
|
||||
|
||||
<TextView
|
||||
tools:text="Test Directory"
|
||||
android:text="@{fileName}"
|
||||
android:id="@+id/file_label"
|
||||
android:maxLines="1"
|
||||
android:layout_width="wrap_content"
|
||||
@@ -29,7 +45,9 @@
|
||||
android:textSize="22sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/file_size"
|
||||
android:visibility="@{TextUtils.isEmpty(fileSize) ? View.GONE : View.VISIBLE}"
|
||||
tools:text="250 MB"
|
||||
android:text="@{fileSize}"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layout xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<layout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<data>
|
||||
<variable
|
||||
name="folderName"
|
||||
type="String" />
|
||||
|
||||
<variable
|
||||
name="lastModification"
|
||||
type="String" />
|
||||
|
||||
<variable
|
||||
name="info"
|
||||
type="String" />
|
||||
</data>
|
||||
|
||||
<RelativeLayout
|
||||
android:background="?selectableItemBackground"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingBottom="12dp"
|
||||
android:paddingLeft="@dimen/abc_action_bar_content_inset_material"
|
||||
android:paddingRight="@dimen/abc_action_bar_content_inset_material"
|
||||
android:paddingLeft="24dp"
|
||||
android:paddingRight="24dp"
|
||||
android:paddingTop="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/folder_name"
|
||||
tools:text="Music"
|
||||
android:text="@{folderName}"
|
||||
android:id="@+id/folder_name_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_height="wrap_content"
|
||||
@@ -23,16 +40,20 @@
|
||||
android:textStyle="bold"/>
|
||||
|
||||
<TextView
|
||||
tools:text="Last modified: two minutes ago"
|
||||
android:text="@{lastModification}"
|
||||
android:id="@+id/folder_lastmod_info"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:layout_below="@id/folder_name"
|
||||
android:layout_below="@id/folder_name_view"
|
||||
android:textSize="14sp"
|
||||
android:layout_alignParentStart="true" />
|
||||
|
||||
<TextView
|
||||
tools:text="Additional information"
|
||||
android:text="@{info}"
|
||||
android:id="@+id/folder_content_info"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<LinearLayout
|
||||
android:orientation="horizontal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp">
|
||||
|
||||
<android.support.design.widget.TextInputLayout
|
||||
android:id="@+id/device_id_holder"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
app:errorEnabled="true">
|
||||
|
||||
<android.support.design.widget.TextInputEditText
|
||||
android:id="@+id/device_id"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:minLines="3"
|
||||
android:inputType="textNoSuggestions|textMultiLine|textCapCharacters"
|
||||
tools:text="VPNPKMK-VL2SOQN-SS5I2AB-G4BV7ZK-RO5ODEE-Y2G3CZ4-C4FUW4P-ZEMJOAF"/>
|
||||
|
||||
</android.support.design.widget.TextInputLayout>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/scan_qr_code"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_gravity="center"
|
||||
android:padding="8dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/ic_qr_code_black_24dp"
|
||||
android:background="?android:selectableItemBackgroundBorderless"/>
|
||||
|
||||
</LinearLayout>
|
||||
</layout>
|
||||
@@ -4,20 +4,25 @@
|
||||
|
||||
<item
|
||||
android:id="@+id/folders"
|
||||
android:checked="true"
|
||||
android:icon="@drawable/ic_folder_gray_24dp"
|
||||
android:title="Folders"
|
||||
android:checked="true"/>
|
||||
android:title="@string/folders_label" />
|
||||
|
||||
<item
|
||||
android:id="@+id/devices"
|
||||
android:icon="@drawable/ic_laptop_gray_24dp"
|
||||
android:title="Devices" />
|
||||
android:title="@string/devices_label" />
|
||||
|
||||
<item
|
||||
android:id="@+id/update_index"
|
||||
android:icon="@drawable/ic_refresh_gray_24dp"
|
||||
android:title="@string/update_remote_index_label"
|
||||
android:checkable="false"/>
|
||||
android:id="@+id/settings"
|
||||
android:icon="@drawable/ic_settings_gray_24dp"
|
||||
android:title="@string/settings" />
|
||||
|
||||
<item
|
||||
android:id="@+id/device_id"
|
||||
android:icon="@drawable/ic_qr_code_black_24dp"
|
||||
android:title="@string/show_device_id"
|
||||
android:checkable="false" />
|
||||
|
||||
<item
|
||||
android:id="@+id/clear_index"
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<resources>
|
||||
<string name="app_name">Syncthing Lite</string>
|
||||
<string name="folder_list_empty_message">Kein Ordner verfügbar</string>
|
||||
<string name="clear_local_cache_index_label">Lokalen Index und Cache löschen</string>
|
||||
<string name="devices_list_view_empty_message">Keine Geräte verfügbar</string>
|
||||
<string name="invalid_device_id">Fehler: Ungültige Geräte ID</string>
|
||||
<string name="dialog_downloading_file">Datei %1$s wird heruntergeladen</string>
|
||||
<string name="toast_file_download_failed">Datei konnte nicht heruntergeladen werden</string>
|
||||
<string name="toast_open_file_failed">Keine kompatible app gefunden</string>
|
||||
<string name="toast_file_upload_failed">Hochladen gescheitert</string>
|
||||
<string name="toast_upload_complete">Hochladen erfolgreich</string>
|
||||
<string name="dialog_uploading_file">Datei %1$s wird hochgeladen</string>
|
||||
<string name="clear_cache_and_index_title">Lokalen Cache und Index löschen?</string>
|
||||
<string name="clear_cache_and_index_body">Gesamten lokalen Cache und Index löschen?</string>
|
||||
<string name="index_update_progress_label">Index Aktualisierung für Ordner %1$s, %2$d %% synchronisiert</string>
|
||||
<string name="loading_config_starting_syncthing_client">Konfiguartion wird geladen, Syncthing wird gestartet...</string>
|
||||
<string name="last_modified_time">Zuletzt modifiziert: %1$s</string>
|
||||
<string name="remove_device_title">Gerät %1$s entfernen?</string>
|
||||
<string name="remove_device_message">Gerät %1$s von der Liste der bekannten Geräte entfernen?</string>
|
||||
<string name="device_import_success">Gerät %1$s erfolgreich importiert</string>
|
||||
<string name="device_already_known">Gerät ist bereits bekannt $1%s</string>
|
||||
<string name="folders_label">Ordner</string>
|
||||
<string name="devices_label">Geräte</string>
|
||||
<string name="folder_label_format">%1$s (%2$s)</string>
|
||||
<string name="folder_content_info">%1$s, %2$d Dateien, %3$d Ordner</string>
|
||||
<string name="file_info">%1$s, zuletzt modifiziert %2$s</string>
|
||||
<string name="show_device_id">Geräte ID anzeigen</string>
|
||||
<string name="device_id">Geräte ID</string>
|
||||
<string name="device_id_copied">Geräte ID in den Zwischenspeicher kopiert</string>
|
||||
<string name="share_device_id_chooser">Teile Geräte ID mit</string>
|
||||
<string name="other_syncthing_instance_title">Eine andere Syncthing Instanz läuft bereits</string>
|
||||
<string name="other_syncthing_instance_message">Lokale Auffindung wird nicht funktionieren. Stoppen Sie die andere Syncthing Instanz, um die lokale Auffindung zu ermöglichen.</string>
|
||||
<string name="intro_page_one_title">Willkommen zu Syncthing Lite</string>
|
||||
<string name="intro_page_one_description">Syncthing ersetzt proprietäre Sync- und Cloud-Services durch etwas Offenes, Vertrauenswürdiges und Dezentrales. Ihre Daten sind allein Ihre Daten, und Sie verdienen es zu wählen, wo sie gespeichert werden, ob sie an Dritte weitergegeben werden und wie sie über das Internet übertragen werden.</string>
|
||||
<string name="intro_page_two_title">Ein Gerät hinzufügen</string>
|
||||
<string name="intro_page_three_title">Ordner teilen</string>
|
||||
<string name="intro_page_two_description">Eine Syncthing Geräte ID eingeben oder QR Code einer Geräte ID scannen.</string>
|
||||
<string name="intro_page_three_description">Akzeptieren Sie nun das Gerät mit der ID %1$s und geben Sie einen Ordner mit ihm frei. Es kann einige Minuten dauern, bis sich die Geräte verbinden.</string>
|
||||
<string name="settings">Einstellungen</string>
|
||||
<string name="settings_app_version_title">App Version</string>
|
||||
<string name="settings_local_device_name">Lokaler Geräte Namen</string>
|
||||
<string name="settings_local_device_summary">Name, den das andere Gerät für dieses Gerät sehen wird</string>
|
||||
<string name="device_id_dialog_title">Geräte ID eingeben</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,52 @@
|
||||
<resources>
|
||||
<string name="app_name">Syncthing Lite</string>
|
||||
<string name="folder_list_empty_message">Carpeta no disponible</string>
|
||||
<string name="clear_local_cache_index_label">Limpia caché/índice local</string>
|
||||
<string name="devices_list_view_empty_message">Dispositivos no disponibles</string>
|
||||
<string name="invalid_device_id">Error: identificador de dispositivo inválido</string>
|
||||
<string name="dialog_downloading_file">Descargando archivo%1$s</string>
|
||||
<string name="toast_file_download_failed">Falló al descargar el archivo</string>
|
||||
<string name="toast_open_file_failed">No se ha encontrado una app compatible</string>
|
||||
<string name="toast_file_upload_failed">Error en la carga de archivos
|
||||
</string>
|
||||
<string name="toast_upload_complete">Carga de archivos completa
|
||||
</string>
|
||||
<string name="dialog_uploading_file">Cargando archivo %1$s</string>
|
||||
<string name="clear_cache_and_index_title">¿Borrar la caché local y el índice?</string>
|
||||
<string name="clear_cache_and_index_body">¿Borrar todos los datos de la caché local y los datos de índice?</string>
|
||||
<string name="index_update_progress_label">Actualización del índice de las carpetas %1$s, %2$d%% sincronizado</string>
|
||||
<string name="loading_config_starting_syncthing_client">Cargando configuración, iniciando el cliente de sincronización....</string>
|
||||
<string name="last_modified_time">Última modificación: %1$s</string>
|
||||
<string name="remove_device_title">¿Quitar dispositivo %1$s?</string>
|
||||
<string name="remove_device_message">¿Quitar dispositivo %1$s de la lista de dispositivos conocidos?</string>
|
||||
<string name="device_import_success">Dispositivo %1$simportado con éxito</string>
|
||||
<string name="device_already_known">Dispositivo %1$s ya está presente</string>
|
||||
<string name="folders_label">Carpetas</string>
|
||||
<string name="devices_label">Dispositivos</string>
|
||||
<string name="folder_label_format">%1$s (%2$s)</string>
|
||||
<string name="folder_content_info">%1$s, %2$d archivos, %3$ddirectorios</string>
|
||||
<string name="file_info">%1$s, modificado por última vez %2$s</string>
|
||||
<string name="show_device_id">Mostrar ID de dispositivo </string>
|
||||
<string name="device_id">ID de dispositivo</string>
|
||||
<string name="device_id_copied">ID de dispositivo copiado al portapapeles</string>
|
||||
<string name="share_device_id_chooser">Compartir ID de dispositivo con</string>
|
||||
<string name="other_syncthing_instance_title">Otra instancia de Syncthing está siendo ejecutada</string>
|
||||
<string name="other_syncthing_instance_message">El descubrimiento local no funcionará. Detenga la otra instancia de Syncthing para habilitar la detección local.</string>
|
||||
<string name="intro_page_one_title">Bienvenido a Syncthing Lite</string>
|
||||
<string name="intro_page_one_description">Syncthing reemplaza los servicios de sincronización y nube propietarios por algo abierto, fiable y descentralizado. Sus datos son sólo suyos y usted merece elegir dónde se almacenan, si se comparten con terceros y cómo se transmiten a través de Internet.</string>
|
||||
<string name="intro_page_two_title">Añadir un dispositivo</string>
|
||||
<string name="intro_page_three_title">Compartir tus carpetas</string>
|
||||
<string name="intro_page_two_description">Introduce un ID de dispositivo de Syncthing o escanea un ID de dispositivo desde un código QR</string>
|
||||
<string name="intro_page_three_description">Acepta ahora el dispositivo con ID %1$s, y comparte una carpeta con él. Pueden pasar unos minutos hasta que los dispositivos se conecten.</string>
|
||||
<string name="settings">Configuración</string>
|
||||
<string name="settings_app_version_title">Versión de la aplicación</string>
|
||||
<string name="settings_local_device_name">Nombre del dispositivo local</string>
|
||||
<string name="settings_local_device_summary">El nombre que otros dispositivos verán para este dispositivo</string>
|
||||
<string name="settings_shutdown_delay_title">Retardo en el apagado</string>
|
||||
<string name="settings_shutdown_delay_summary">Tiempo antes de apagar el cliente Syncthing después de su último uso</string>
|
||||
<string name="device_id_dialog_title">Introducir la ID del dispositivo</string>
|
||||
<string name="settings_shutdown_delay_10_seconds">10 segundos</string>
|
||||
<string name="settings_shutdown_delay_30_seconds">30 segundos</string>
|
||||
<string name="settings_shutdown_delay_1_minute">1 minuto</string>
|
||||
<string name="settings_shutdown_delay_5_minutes">5 minutos</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,50 @@
|
||||
<resources>
|
||||
<string name="app_name">Syncthing Lite</string>
|
||||
<string name="folder_list_empty_message">Aucun dossier disponible</string>
|
||||
<string name="clear_local_cache_index_label">Effacer le cache et l\'index local</string>
|
||||
<string name="devices_list_view_empty_message">Aucun appareil disponible</string>
|
||||
<string name="invalid_device_id">Erreur: ID de l\'appareil invalide</string>
|
||||
<string name="dialog_downloading_file">Téléchargement du fichier %1$s</string>
|
||||
<string name="toast_file_download_failed">Le téléchargement du fichier a échoué</string>
|
||||
<string name="toast_open_file_failed">Aucune appli compatible trouvée</string>
|
||||
<string name="toast_file_upload_failed">Échec de l\'upload</string>
|
||||
<string name="toast_upload_complete">Upload du fichier terminé</string>
|
||||
<string name="dialog_uploading_file">Upload du fichier %1$s</string>
|
||||
<string name="clear_cache_and_index_title">Effacer le cache local et l\'index?</string>
|
||||
<string name="clear_cache_and_index_body">Effacer toutes les données du cache local et de l\'index ?</string>
|
||||
<string name="index_update_progress_label">Mise à jour de l\'index pour le dossier %1$s, %2$d%% synchronisés</string>
|
||||
<string name="loading_config_starting_syncthing_client">Chargement de la configuration, démarrage du client Syncthing...</string>
|
||||
<string name="last_modified_time">Dernière modification : %1$s</string>
|
||||
<string name="remove_device_title">Supprimer l\'appareil %1$s\?</string>
|
||||
<string name="remove_device_message">Supprimer l\'appareil %1$s de la liste des appareil connus ?</string>
|
||||
<string name="device_import_success">Appareil %1$s importé avec succès</string>
|
||||
<string name="device_already_known">Appareil déjà connu %1$s</string>
|
||||
<string name="folders_label">Dossiers</string>
|
||||
<string name="devices_label">Appareils</string>
|
||||
<string name="folder_label_format">%1$s (%2$s)</string>
|
||||
<string name="folder_content_info">%1$s, %2$d fichiers, %3$d dossiers</string>
|
||||
<string name="file_info">%1$s, dernière modification %2$s</string>
|
||||
<string name="show_device_id">Afficher l\'ID de l\'appareil</string>
|
||||
<string name="device_id">ID de l\'appareil</string>
|
||||
<string name="device_id_copied">ID de l\'appareil copié dans le presse-papier</string>
|
||||
<string name="share_device_id_chooser">Partager l\'ID de l\'appareil avec</string>
|
||||
<string name="other_syncthing_instance_title">Une autre instance de Syncthing fonctionne</string>
|
||||
<string name="other_syncthing_instance_message">La découverte locale ne fonctionnera pas. Arrêtez l\'autre instance de Syncthing pour activer la découverte locale.</string>
|
||||
<string name="intro_page_one_title">Bienvenue dans Syncthing Lite</string>
|
||||
<string name="intro_page_one_description">Syncthing remplace les services de synchronisation et de cloud propriétaires par quelque chose d\'ouvert, fiable et décentralisé. Vos données sont uniquement vos données et vous méritez de choisir où elles sont stockées, si elles sont partagées avec des tiers et comment elles sont transmises sur Internet.</string>
|
||||
<string name="intro_page_two_title">Ajouter un appareil</string>
|
||||
<string name="intro_page_three_title">Partager vos dossiers</string>
|
||||
<string name="intro_page_two_description">Entrer l\'ID Syncthing de l\'appareil, ou scanner le QR code de l\'ID d\'un appareil.</string>
|
||||
<string name="intro_page_three_description">Maintenant, acceptez l\'appareil avec l\'ID %1$s, et partagez un dossier avec lui. Cela peut prendre quelques minutes avant que les appareils ne se connectent.</string>
|
||||
<string name="settings">Réglages</string>
|
||||
<string name="settings_app_version_title">Version d\'application</string>
|
||||
<string name="settings_local_device_name">Nom local de l\'appareil</string>
|
||||
<string name="settings_local_device_summary">Le nom que les autres appareils verront pour cet appareil</string>
|
||||
<string name="settings_shutdown_delay_title">Délai d\'arrêt</string>
|
||||
<string name="settings_shutdown_delay_summary">Délai avant d\'arrêter le client Syncthing après sa dernière utilisation</string>
|
||||
<string name="device_id_dialog_title">Entrer l\'ID de l\'appareil</string>
|
||||
<string name="settings_shutdown_delay_10_seconds">10 secondes</string>
|
||||
<string name="settings_shutdown_delay_30_seconds">30 secondes</string>
|
||||
<string name="settings_shutdown_delay_1_minute">1 minute</string>
|
||||
<string name="settings_shutdown_delay_5_minutes">5 minutes</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,44 @@
|
||||
<resources>
|
||||
<string name="app_name">Syncthing Lite</string>
|
||||
<string name="folder_list_empty_message">Nincs elérhető mappa</string>
|
||||
<string name="clear_local_cache_index_label">Helyi gyorsítótár/index törlése</string>
|
||||
<string name="devices_list_view_empty_message">Nincs elérhető eszköz</string>
|
||||
<string name="invalid_device_id">Hiba: helytelen eszközazonosítő</string>
|
||||
<string name="dialog_downloading_file">Fájl letöltése %1$s</string>
|
||||
<string name="toast_file_download_failed">Hiba a fájl letöltése közben</string>
|
||||
<string name="toast_open_file_failed">Nem található kompatibilis alkalmazás</string>
|
||||
<string name="toast_file_upload_failed">Hiba fájl feltöltése közben</string>
|
||||
<string name="toast_upload_complete">Feltöltés befejezve</string>
|
||||
<string name="dialog_uploading_file">Fájl feltöltése: %1$s</string>
|
||||
<string name="clear_cache_and_index_title">Törlöd a helyi gyorsítótárat és index-et?</string>
|
||||
<string name="clear_cache_and_index_body">Törlöd az összes helyi gyorsítótár-at és index-et?</string>
|
||||
<string name="index_update_progress_label">Index frissítés szinkronizálva a %1$s,%2$d,%% mappákhoz</string>
|
||||
<string name="loading_config_starting_syncthing_client">Beállítások betöltése, Syncthing kliens indítása</string>
|
||||
<string name="last_modified_time">Utolsó módosítás: %1$s</string>
|
||||
<string name="remove_device_title">Törlöd a %1$s eszközt?</string>
|
||||
<string name="remove_device_message">Törlöd a %1$s eszközt az ismertek listájáról?</string>
|
||||
<string name="device_import_success">Eszköz sikeresen importálva: %1$s</string>
|
||||
<string name="device_already_known">%1$s eszköz már hozzá van adva</string>
|
||||
<string name="folders_label">Mappák</string>
|
||||
<string name="devices_label">Eszközök</string>
|
||||
<string name="folder_label_format">%1$s (%2$s)</string>
|
||||
<string name="folder_content_info">%1$s, %2$d fájlok, %3$d mappák</string>
|
||||
<string name="file_info">%1$s, utoljára módosítva %2$s</string>
|
||||
<string name="show_device_id">Eszközazonosító megjelenítése</string>
|
||||
<string name="device_id">Eszközazonosító</string>
|
||||
<string name="device_id_copied">Eszközazonosító a vágólapra másolva</string>
|
||||
<string name="share_device_id_chooser">Eszközazonosító megosztása</string>
|
||||
<string name="other_syncthing_instance_title">Egy másik Syncthing folyamat fut</string>
|
||||
<string name="other_syncthing_instance_message">Helyi felfedezés nem fog működni. Állítsd le a másik Syncthing folyamatot a helyi felfedezés bekapcsolásához.</string>
|
||||
<string name="intro_page_one_title">Üdvözöllek a Syncthing Lite-ban</string>
|
||||
<string name="intro_page_one_description">A Syncthing a zárt forrású szinkronizáló és felhő szolgáltatásokat egy nyílt, megbízható és decentralizált szoftverrel váltja fel. A te adatod csak a tiéd és te szabod meg, hogy hol tárolod, kivel osztod meg, és hogyan továbbítod az interneten.</string>
|
||||
<string name="intro_page_two_title">Eszköz hozzáadása</string>
|
||||
<string name="intro_page_three_title">Mappáid megosztása</string>
|
||||
<string name="intro_page_two_description">Adj meg egy Syncthing eszközazonosítót, vagy olvasd be QR kódból</string>
|
||||
<string name="intro_page_three_description">Fogadd el a(z) %1$s azonosítójú eszközt, és ossz meg vele egy mappát. Beletelhet néhány percbe mire az eszközök csatlakoznak.</string>
|
||||
<string name="settings">Beállítások</string>
|
||||
<string name="settings_app_version_title">Verzió</string>
|
||||
<string name="settings_local_device_name">Helyi eszköz neve</string>
|
||||
<string name="settings_local_device_summary">Név amit a többi eszköz fog látni</string>
|
||||
<string name="device_id_dialog_title">Eszközazonosító megadása</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,50 @@
|
||||
<resources>
|
||||
<string name="app_name">Syncthing Lite</string>
|
||||
<string name="folder_list_empty_message">Nessuna cartella disponibile</string>
|
||||
<string name="clear_local_cache_index_label">Cancella cache/indice</string>
|
||||
<string name="devices_list_view_empty_message">Nessun dispositivo disponibile</string>
|
||||
<string name="invalid_device_id">Errore: ID dispositivo non valido</string>
|
||||
<string name="dialog_downloading_file">Scaricamento del file %1$s</string>
|
||||
<string name="toast_file_download_failed">Impossibile scaricare il file</string>
|
||||
<string name="toast_open_file_failed">Nessuna applicazione compatibile trovata</string>
|
||||
<string name="toast_file_upload_failed">Caricamento file fallito</string>
|
||||
<string name="toast_upload_complete">Caricamento file completato</string>
|
||||
<string name="dialog_uploading_file">Caricamento del file %1$s</string>
|
||||
<string name="clear_cache_and_index_title">Cancellare la cache locale e l\'indice?</string>
|
||||
<string name="clear_cache_and_index_body">Cancellare tutti i dati della cache locale e i dati dell\'indice?</string>
|
||||
<string name="index_update_progress_label">Aggiornamento dell\'indice per la cartella %1$s, %2$d%% sincronizzato</string>
|
||||
<string name="loading_config_starting_syncthing_client">Caricamento configurazione, avvio del client syncthing...</string>
|
||||
<string name="last_modified_time">Ultima modifica: %1$s</string>
|
||||
<string name="remove_device_title">Rimuovere il dispositivo %1$s\?</string>
|
||||
<string name="remove_device_message">Rimuovere %1$s dalla lista dei dispositivi noti?</string>
|
||||
<string name="device_import_success">Dispositivo %1$s importato con successo</string>
|
||||
<string name="device_already_known">Dispositivo %1$s già presente</string>
|
||||
<string name="folders_label">Cartelle</string>
|
||||
<string name="devices_label">Dispositivi</string>
|
||||
<string name="folder_label_format">%1$s (%2$s)</string>
|
||||
<string name="folder_content_info">%1$s, %2$d file, %3$d cartelle</string>
|
||||
<string name="file_info">%1$s, ultima modifica %2$s</string>
|
||||
<string name="show_device_id">Mostra l\'ID del dispositivo</string>
|
||||
<string name="device_id">ID dispositivo</string>
|
||||
<string name="device_id_copied">ID dispositivo copiato negli appunti</string>
|
||||
<string name="share_device_id_chooser">Condividi ID dispositivo con</string>
|
||||
<string name="other_syncthing_instance_title">Un\'altra istanza di Syncthing è in esecuzione</string>
|
||||
<string name="other_syncthing_instance_message">L\'individuazione locale non funzionerà. Arresta l\'altra istanza di Syncthing per abilitare l\'individuazione locale.</string>
|
||||
<string name="intro_page_one_title">Benvenuto in Syncthing Lite</string>
|
||||
<string name="intro_page_one_description">Syncthing sostituisce servizi proprietari di sincronizzazione e cloud con qualcosa di aperto, affidabile e decentralizzato. I tuoi dati sono solo tuoi e meriti di scegliere dove vengono immagazzinati, se sono condivisi con terze parti e come sono trasmessi attraverso Internet.</string>
|
||||
<string name="intro_page_two_title">Aggiungi un dispositivo</string>
|
||||
<string name="intro_page_three_title">Condividi le tue cartelle</string>
|
||||
<string name="intro_page_two_description">Immetti un ID dispositivo Syncthing o esegui la scansione di un ID dispositivo da un codice QR</string>
|
||||
<string name="intro_page_three_description">Ora accetta il dispositivo con ID %1$s, e condividi una cartella con esso. Potrebbero essere necessari alcuni minuti prima che i dispositivi si connettano.</string>
|
||||
<string name="settings">Impostazioni</string>
|
||||
<string name="settings_app_version_title">Versione dell\'app</string>
|
||||
<string name="settings_local_device_name">Nome del dispositivo locale</string>
|
||||
<string name="settings_local_device_summary">Il nome che altri dispositivi vedranno per questo dispositivo</string>
|
||||
<string name="settings_shutdown_delay_title">Ritardo di chiusura</string>
|
||||
<string name="settings_shutdown_delay_summary">Tempo prima della chiusura del client Syncthing dopo l\'ultimo utilizzo</string>
|
||||
<string name="device_id_dialog_title">Inserisci ID Dispositivo</string>
|
||||
<string name="settings_shutdown_delay_10_seconds">10 secondi</string>
|
||||
<string name="settings_shutdown_delay_30_seconds">30 secondi</string>
|
||||
<string name="settings_shutdown_delay_1_minute">1 minuto</string>
|
||||
<string name="settings_shutdown_delay_5_minutes">5 minuti</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,44 @@
|
||||
<resources>
|
||||
<string name="app_name">Syncthing Lite</string>
|
||||
<string name="folder_list_empty_message">フォルダーがありません</string>
|
||||
<string name="clear_local_cache_index_label">ローカルキャッシュ/索引をクリア</string>
|
||||
<string name="devices_list_view_empty_message">デバイスがありません</string>
|
||||
<string name="invalid_device_id">エラー: デバイス ID が無効です</string>
|
||||
<string name="dialog_downloading_file">ファイル %1$s のダウンロード中</string>
|
||||
<string name="toast_file_download_failed">ファイルのダウンロードに失敗しました</string>
|
||||
<string name="toast_open_file_failed">利用できるアプリが見つかりません</string>
|
||||
<string name="toast_file_upload_failed">ファイルのアップロードに失敗しました</string>
|
||||
<string name="toast_upload_complete">ファイルのアップロードが完了しました</string>
|
||||
<string name="dialog_uploading_file">ファイル %1$s のアップロード中</string>
|
||||
<string name="clear_cache_and_index_title">ローカルキャッシュと索引をクリアしますか?</string>
|
||||
<string name="clear_cache_and_index_body">すべてのローカルキャッシュデータと索引データをクリアしますか?</string>
|
||||
<string name="index_update_progress_label">フォルダー %1$s の索引を更新しました。 %2$d%% 同期しました</string>
|
||||
<string name="loading_config_starting_syncthing_client">設定の読み込み中、syncthing クライアントの開始中…</string>
|
||||
<string name="last_modified_time">最終更新: %1$s</string>
|
||||
<string name="remove_device_title">デバイス %1$sを削除しますか?</string>
|
||||
<string name="remove_device_message">既存のデバイスのリストから %1$s を削除しますか?</string>
|
||||
<string name="device_import_success">デバイス %1$s のインポートに成功しました</string>
|
||||
<string name="device_already_known">デバイスは既に存在します %1$s</string>
|
||||
<string name="folders_label">フォルダー</string>
|
||||
<string name="devices_label">デバイス</string>
|
||||
<string name="folder_label_format">%1$s (%2$s)</string>
|
||||
<string name="folder_content_info">%1$s, %2$d ファイル, %3$d ディレクトリー</string>
|
||||
<string name="file_info">%1$s, 最終更新 %2$s</string>
|
||||
<string name="show_device_id">デバイス ID を表示</string>
|
||||
<string name="device_id">デバイス ID</string>
|
||||
<string name="device_id_copied">デバイス ID をクリップボードにコピーしました</string>
|
||||
<string name="share_device_id_chooser">次とデバイス ID を共有</string>
|
||||
<string name="other_syncthing_instance_title">別の Syncthing インスタンスが実行中です</string>
|
||||
<string name="other_syncthing_instance_message">ローカルの探索は動作しません。他の Syncthing インスタンスを停止して、ローカルの探索を有効にしてください。</string>
|
||||
<string name="intro_page_one_title">Syncthing Lite へようこそ</string>
|
||||
<string name="intro_page_one_description">Syncthing は、プロプライエタリな同期およびクラウドサービスを、オープンで信頼性があり、分散化されたものに置き換えます。 あなたのデータはあなただけのものであり、それが第三者と共有され、インターネット経由で送信される場合、保存される場所を選択する必要があります。</string>
|
||||
<string name="intro_page_two_title">デバイスを追加</string>
|
||||
<string name="intro_page_three_title">フォルダーを共有</string>
|
||||
<string name="intro_page_two_description">Syncthing デバイス ID を入力、または QR コードからデバイス ID をスキャンしてください</string>
|
||||
<string name="intro_page_three_description">ID %1$s のデバイスを承認して、フォルダーを共有しました。デバイスが接続されるまで数分かかることがあります。</string>
|
||||
<string name="settings">設定</string>
|
||||
<string name="settings_app_version_title">アプリバージョン</string>
|
||||
<string name="settings_local_device_name">ローカルのデバイス名</string>
|
||||
<string name="settings_local_device_summary">他のデバイスがこのデバイスを表示する名前</string>
|
||||
<string name="device_id_dialog_title">デバイス ID を入力</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,44 @@
|
||||
<resources>
|
||||
<string name="app_name">Syncthing Lite</string>
|
||||
<string name="folder_list_empty_message">Geen map beschikbaar</string>
|
||||
<string name="clear_local_cache_index_label">Lokaal cachegeheugen/index wissen</string>
|
||||
<string name="devices_list_view_empty_message">Geen apparaten beschikbaar</string>
|
||||
<string name="invalid_device_id">Fout: ongeldigen apparaats-ID</string>
|
||||
<string name="dialog_downloading_file">Bestand %1$s wordt gedownload</string>
|
||||
<string name="toast_file_download_failed">Download van bestand mislukt</string>
|
||||
<string name="toast_open_file_failed">Gene compatibelen app gevonden</string>
|
||||
<string name="toast_file_upload_failed">Uploaden van bestand mislukt</string>
|
||||
<string name="toast_upload_complete">Uploaden van bestand voltooid</string>
|
||||
<string name="dialog_uploading_file">Bestand %1$s wordt geüpload</string>
|
||||
<string name="clear_cache_and_index_title">Lokaal cachegeheugen en index wissen?</string>
|
||||
<string name="clear_cache_and_index_body">Alle lokale cache- en indexgegevens wissen?</string>
|
||||
<string name="index_update_progress_label">Index voor map %1$s wordt bijgewerkt, %2$d%% gesynchroniseerd</string>
|
||||
<string name="loading_config_starting_syncthing_client">Configuratie wordt geladen, Syncthing-cliënt wordt opgestart…</string>
|
||||
<string name="last_modified_time">Laatst gewijzigd: %1$s</string>
|
||||
<string name="remove_device_title">Apparaat %1$s verwijderen?</string>
|
||||
<string name="remove_device_message">%1$s verwijderen uit de lijst van gekende apparaten?</string>
|
||||
<string name="device_import_success">Apparaat %1$s geïmporteerd</string>
|
||||
<string name="device_already_known">Apparaat %1$s reeds aanwezig</string>
|
||||
<string name="folders_label">Mappen</string>
|
||||
<string name="devices_label">Apparaten</string>
|
||||
<string name="folder_label_format">%1$s (%2$s)</string>
|
||||
<string name="folder_content_info">%1$s, %2$d bestanden, %3$d mappen</string>
|
||||
<string name="file_info">%1$s, laatst gewijzigd %2$s</string>
|
||||
<string name="show_device_id">Apparaats-ID tonen</string>
|
||||
<string name="device_id">Apparaats-ID</string>
|
||||
<string name="device_id_copied">Apparaats-ID gekopieerd naar klembord</string>
|
||||
<string name="share_device_id_chooser">Apparaats-ID delen met</string>
|
||||
<string name="other_syncthing_instance_title">Een andere Syncthing-instantie wordt reeds uitgevoerd</string>
|
||||
<string name="other_syncthing_instance_message">Lokale ontdekking gaat niet werken. Stopt de andere Syncthing-instantie voor lokale ontdekking in te schakelen.</string>
|
||||
<string name="intro_page_one_title">Welkom bij Syncthing Lite</string>
|
||||
<string name="intro_page_one_description">Syncthing vervangt niet-vrije synchronisatie- en clouddiensten door iets opens, betrouwbaars en gedecentraliseerds. Uw gegevens behoren enkel u toe en gij bepaalt waar dat ze worden opgeslagen, of dat ze worden gedeeld met een derde partij en hoe dat ze over het internet worden verzonden.</string>
|
||||
<string name="intro_page_two_title">Voegt een apparaat toe</string>
|
||||
<string name="intro_page_three_title">Deelt uw mappen</string>
|
||||
<string name="intro_page_two_description">Voert ne Syncthing-apparaats-ID in, of scant nen apparaats-ID van een QR-code</string>
|
||||
<string name="intro_page_three_description">Aanvaard nu het apparaat met ID %1$s, en deelt er een map mee. Het kan enkele minuten duren vooraleer dat de apparaten verbinding maken.</string>
|
||||
<string name="settings">Instellingen</string>
|
||||
<string name="settings_app_version_title">Appversie</string>
|
||||
<string name="settings_local_device_name">Naam van lokaal apparaat</string>
|
||||
<string name="settings_local_device_summary">De naam die dat andere apparaten voor dit apparaat gaan zien</string>
|
||||
<string name="device_id_dialog_title">Voert nen apparaats-ID in</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,2 @@
|
||||
<resources>
|
||||
</resources>
|
||||
@@ -0,0 +1,44 @@
|
||||
<resources>
|
||||
<string name="app_name">Syncthing Lite</string>
|
||||
<string name="folder_list_empty_message">Geen map beschikbaar</string>
|
||||
<string name="clear_local_cache_index_label">Lokale cache/index wissen</string>
|
||||
<string name="devices_list_view_empty_message">Geen apparaten beschikbaar</string>
|
||||
<string name="invalid_device_id">Fout: ongeldige apparaats-ID</string>
|
||||
<string name="dialog_downloading_file">Bestand %1$s wordt gedownload</string>
|
||||
<string name="toast_file_download_failed">Download van bestand mislukt</string>
|
||||
<string name="toast_open_file_failed">Geen compatibele app gevonden</string>
|
||||
<string name="toast_file_upload_failed">Uploaden van bestand mislukt</string>
|
||||
<string name="toast_upload_complete">Uploaden van bestand voltooid</string>
|
||||
<string name="dialog_uploading_file">Bestand %1$s wordt geüpload</string>
|
||||
<string name="clear_cache_and_index_title">Lokale cache en index wissen?</string>
|
||||
<string name="clear_cache_and_index_body">Alle lokale cache- en indexgegevens wissen?</string>
|
||||
<string name="index_update_progress_label">Index voor map %1$s wordt bijgewerkt, %2$d%% gesynchroniseerd</string>
|
||||
<string name="loading_config_starting_syncthing_client">Configuratie wordt geladen, Syncthing-cliënt wordt opgestart…</string>
|
||||
<string name="last_modified_time">Laatst gewijzigd: %1$s</string>
|
||||
<string name="remove_device_title">Apparaat %1$s verwijderen?</string>
|
||||
<string name="remove_device_message">%1$s verwijderen uit de lijst van gekende apparaten?</string>
|
||||
<string name="device_import_success">Apparaat %1$s geïmporteerd</string>
|
||||
<string name="device_already_known">Apparaat %1$s reeds aanwezig</string>
|
||||
<string name="folders_label">Mappen</string>
|
||||
<string name="devices_label">Apparaten</string>
|
||||
<string name="folder_label_format">%1$s (%2$s)</string>
|
||||
<string name="folder_content_info">%1$s, %2$d bestanden, %3$d mappen</string>
|
||||
<string name="file_info">%1$s, laatst gewijzigd %2$s</string>
|
||||
<string name="show_device_id">Apparaats-ID tonen</string>
|
||||
<string name="device_id">Apparaats-ID</string>
|
||||
<string name="device_id_copied">Apparaats-ID gekopieerd naar klembord</string>
|
||||
<string name="share_device_id_chooser">Apparaats-ID delen met</string>
|
||||
<string name="other_syncthing_instance_title">Een andere Syncthing-instantie wordt reeds uitgevoerd</string>
|
||||
<string name="other_syncthing_instance_message">Lokale ontdekking zal niet werken. Stop de andere Syncthing-instantie om lokale ontdekking in te schakelen.</string>
|
||||
<string name="intro_page_one_title">Welkom bij Syncthing Lite</string>
|
||||
<string name="intro_page_one_description">Syncthing vervangt niet-vrije synchronisatie- en clouddiensten door iets opens, betrouwbaars en gedecentraliseerds. Je gegevens behoren enkel jou toe en jij bepaalt waar ze worden opgeslagen, of ze gedeeld worden met een derde partij en hoe ze over het internet verstuurd worden.</string>
|
||||
<string name="intro_page_two_title">Voeg een apparaat toe</string>
|
||||
<string name="intro_page_three_title">Deel je mappen</string>
|
||||
<string name="intro_page_two_description">Voer een Syncthing-apparaats-ID in, of scan een apparaats-ID van een QR-code</string>
|
||||
<string name="intro_page_three_description">Accepteer nu het apparaat met ID %1$s, en deel er een map mee. Het kan enkele minuten duren voordat de apparaten verbinden.</string>
|
||||
<string name="settings">Instellingen</string>
|
||||
<string name="settings_app_version_title">Appversie</string>
|
||||
<string name="settings_local_device_name">Naam van lokaal apparaat</string>
|
||||
<string name="settings_local_device_summary">De naam die andere apparaten voor dit apparaat zullen zien</string>
|
||||
<string name="device_id_dialog_title">Voer een apparaats-ID in</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,2 @@
|
||||
<resources>
|
||||
</resources>
|
||||
@@ -0,0 +1,54 @@
|
||||
<resources>
|
||||
<string name="app_name">Syncthing Lite</string>
|
||||
<string name="folder_list_empty_message">Nici un director disponibil</string>
|
||||
<string name="clear_local_cache_index_label">Curăță indexul/memoria locală</string>
|
||||
<string name="devices_list_view_empty_message">Nici un dispozitiv disponibil</string>
|
||||
<string name="invalid_device_id">Eroare: ID dispozitiv invalid</string>
|
||||
<string name="dialog_downloading_file">Se descarcă fișierul %1$s</string>
|
||||
<string name="toast_file_download_failed">Descărcarea fișierului a eșuat</string>
|
||||
<string name="toast_open_file_failed">Nu a fost găsită nici o aplicație compatibilă</string>
|
||||
<string name="toast_file_upload_failed">Încărcarea fișierului a eșuat</string>
|
||||
<string name="toast_upload_complete">Încărcarea fișierelor finalizată</string>
|
||||
<string name="dialog_uploading_file">Se încarcă fișierul %1$s</string>
|
||||
<string name="clear_cache_and_index_title">Se curăță memoria locală și indexul?</string>
|
||||
<string name="clear_cache_and_index_body">Se curăță datele din memoria locală și datele indexului?</string>
|
||||
<string name="index_update_progress_label">Actualizarea indexul pentru directorul %1$s, %2$d %% sincronizat</string>
|
||||
<string name="loading_config_starting_syncthing_client">Încărcare setări, pornire client syncthing…</string>
|
||||
<string name="last_modified_time">Modificat ultima dată pe: %1$s</string>
|
||||
<string name="remove_device_title">Ștergere dispozitiv %1$s\?</string>
|
||||
<string name="remove_device_message">Șterge %1$s din lista dispozitivelor cunoscute?</string>
|
||||
<string name="device_import_success">Dispozitiv importat cu succes %1$s</string>
|
||||
<string name="device_already_known">Dispozitiv deja prezent %1$s</string>
|
||||
<string name="folders_label">Directoare</string>
|
||||
<string name="devices_label">Dispozitive</string>
|
||||
<string name="folder_label_format">%1$s (%2$s)</string>
|
||||
<string name="folder_content_info">%1$s, %2$d fișier(e), %3$d director(oare)</string>
|
||||
<string name="file_info">%1$s, modificat ultima dată pe %2$s</string>
|
||||
<string name="show_device_id">Arată ID dispozitiv</string>
|
||||
<string name="device_id">ID dispozitiv</string>
|
||||
<string name="device_id_copied">ID dispozitiv copiat în memorie</string>
|
||||
<string name="share_device_id_chooser">Partajează ID dispozitiv cu</string>
|
||||
<string name="other_syncthing_instance_title">O altă instanță de Syncthing rulează</string>
|
||||
<string name="other_syncthing_instance_message">Descoperire locală nu va funcționa. Opriți cealaltă instanță de Syncthing pentru a activa descoperirea locală.</string>
|
||||
<string name="intro_page_one_title">Bine ați venit la Syncthing Lite</string>
|
||||
<string name="intro_page_one_description">Syncthing înlocuiește serviciile proprietare de sincronizare și stocare
|
||||
tip cloud cu ceva deschis, de încredere și descentralizat. Datele
|
||||
dumneavoastră vă aparțin în totalitate și meritați să decideți unde vor
|
||||
fi stocate, dacă vor fi partajate cu terțe entități precum și cum vor fi
|
||||
trimise prin Internet.</string>
|
||||
<string name="intro_page_two_title">Adaugă un dispozitiv</string>
|
||||
<string name="intro_page_three_title">Partajați-vă directoarele</string>
|
||||
<string name="intro_page_two_description">Introduceți ID-ul Syncthing al unui dispozitiv sau scanați ID-ul unui dispozitiv dintr-un cod QR</string>
|
||||
<string name="intro_page_three_description">Acceptați acum dispozitivul cu ID-ul %1$s, și partajați un director cu el. S-ar putea să dureze câteva minute până când dispozitivele se vor conecta.</string>
|
||||
<string name="settings">Setări</string>
|
||||
<string name="settings_app_version_title">Versiune aplicație</string>
|
||||
<string name="settings_local_device_name">Nume local dispozitiv</string>
|
||||
<string name="settings_local_device_summary">Numele pe care celălalt dispozitiv îl va vedea pentru acest dispozitiv</string>
|
||||
<string name="settings_shutdown_delay_title">Temporizare oprire</string>
|
||||
<string name="settings_shutdown_delay_summary">După cât timp se va închide clientul Syncthing în funcție de ultima utilizare</string>
|
||||
<string name="device_id_dialog_title">Introduceți ID dispozitiv</string>
|
||||
<string name="settings_shutdown_delay_10_seconds">10 secunde</string>
|
||||
<string name="settings_shutdown_delay_30_seconds">30 secunde</string>
|
||||
<string name="settings_shutdown_delay_1_minute">1 minut</string>
|
||||
<string name="settings_shutdown_delay_5_minutes">5 minute</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,50 @@
|
||||
<resources>
|
||||
<string name="app_name">Syncthing Lite</string>
|
||||
<string name="folder_list_empty_message">Ingen mapp tillgänglig</string>
|
||||
<string name="clear_local_cache_index_label">Rensa lokala cache/index</string>
|
||||
<string name="devices_list_view_empty_message">Inga enheter tillgängliga</string>
|
||||
<string name="invalid_device_id">Fel: Ogiltigt enhets-ID</string>
|
||||
<string name="dialog_downloading_file">Hämtar filen %1$s</string>
|
||||
<string name="toast_file_download_failed">Misslyckades med att hämta filen</string>
|
||||
<string name="toast_open_file_failed">Ingen kompatibel app hittades</string>
|
||||
<string name="toast_file_upload_failed">Filöverföring misslyckades</string>
|
||||
<string name="toast_upload_complete">Filöverföring färdig</string>
|
||||
<string name="dialog_uploading_file">Överför filen %1$s</string>
|
||||
<string name="clear_cache_and_index_title">Rensa cache och index lokalt?</string>
|
||||
<string name="clear_cache_and_index_body">Rensa all cachedata och indexdata lokalt?</string>
|
||||
<string name="index_update_progress_label">Index uppdatering för mappen %1$s, %2$d%% synkroniserad</string>
|
||||
<string name="loading_config_starting_syncthing_client">Läser in konfiguration, starta synkroniserande klient...</string>
|
||||
<string name="last_modified_time">Senast ändrad: %1$s</string>
|
||||
<string name="remove_device_title">Ta bort enheten %1$s\?</string>
|
||||
<string name="remove_device_message">Ta bort %1$s från listan över kända enheter?</string>
|
||||
<string name="device_import_success">Importerad enheten %1$s</string>
|
||||
<string name="device_already_known">Enhet som redan finns %1$s</string>
|
||||
<string name="folders_label">Mappar</string>
|
||||
<string name="devices_label">Enheter</string>
|
||||
<string name="folder_label_format">%1$s (%2$s)</string>
|
||||
<string name="folder_content_info">%1$s, %2$d filer, %3$d kataloger</string>
|
||||
<string name="file_info">%1$s, senast ändrad %2$s</string>
|
||||
<string name="show_device_id">Visa enhets-ID</string>
|
||||
<string name="device_id">Enhets-ID</string>
|
||||
<string name="device_id_copied">Enhets-ID kopierad till urklipp</string>
|
||||
<string name="share_device_id_chooser">Dela enhets-ID med</string>
|
||||
<string name="other_syncthing_instance_title">En annan Syncthing-instans körs</string>
|
||||
<string name="other_syncthing_instance_message">Lokal upptäckt kommer inte att fungera. Stoppa den andra Syncthing-instansen för att möjliggöra lokal upptäckt.</string>
|
||||
<string name="intro_page_one_title">Välkommen till Syncthing Lite</string>
|
||||
<string name="intro_page_one_description">Syncthing ersätter proprietära synkroniserings- och molntjänster med något öppet, pålitligt och decentraliserat. Din data är endast din data och du förtjänar att välja var den lagras, om den delas med en tredjepart och hur den överförs över Internet.</string>
|
||||
<string name="intro_page_two_title">Lägg till en enhet</string>
|
||||
<string name="intro_page_three_title">Dela dina mappar</string>
|
||||
<string name="intro_page_two_description">Ange ett Syncthing enhets-ID, eller skanna ett enhets-ID-nummer från en QR-kod</string>
|
||||
<string name="intro_page_three_description">Acceptera nu enheten med ID %1$s och dela en mapp med den. Det kan ta några minuter tills enheterna ansluter.</string>
|
||||
<string name="settings">Inställningar</string>
|
||||
<string name="settings_app_version_title">Appversion</string>
|
||||
<string name="settings_local_device_name">Lokala enhetens namn</string>
|
||||
<string name="settings_local_device_summary">Namnet som andra enheter kommer att se för den här enheten</string>
|
||||
<string name="settings_shutdown_delay_title">Avstängningsfördröjning</string>
|
||||
<string name="settings_shutdown_delay_summary">Tid innan du stänger av Syncthing-klienten efter den senaste användningen</string>
|
||||
<string name="device_id_dialog_title">Ange enhets-ID</string>
|
||||
<string name="settings_shutdown_delay_10_seconds">10 sekunder</string>
|
||||
<string name="settings_shutdown_delay_30_seconds">30 sekunder</string>
|
||||
<string name="settings_shutdown_delay_1_minute">1 minut</string>
|
||||
<string name="settings_shutdown_delay_5_minutes">5 minuter</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,2 @@
|
||||
<resources>
|
||||
</resources>
|
||||
@@ -0,0 +1,35 @@
|
||||
<resources>
|
||||
<string name="app_name">Syncthing Lite</string>
|
||||
<string name="folder_list_empty_message">没有可用的文件夹</string>
|
||||
<string name="clear_local_cache_index_label">清除本地缓存/索引</string>
|
||||
<string name="devices_list_view_empty_message">没有可用的设备</string>
|
||||
<string name="invalid_device_id">无效的设备 ID</string>
|
||||
<string name="dialog_downloading_file">正下载文件 %1$s</string>
|
||||
<string name="toast_file_download_failed">下载文件失败</string>
|
||||
<string name="toast_open_file_failed">没有找到兼容的程序</string>
|
||||
<string name="toast_file_upload_failed">上传文件失败</string>
|
||||
<string name="toast_upload_complete">文件上传完成</string>
|
||||
<string name="dialog_uploading_file">正上传文件 %1$s</string>
|
||||
<string name="clear_cache_and_index_title">确定清除本地缓存和索引?</string>
|
||||
<string name="clear_cache_and_index_body">确定清除全部本地缓存数据和索引数据?</string>
|
||||
<string name="loading_config_starting_syncthing_client">载入配置,正在启动 syncthing 客户端</string>
|
||||
<string name="remove_device_title">移除设备: %1$s</string>
|
||||
<string name="device_import_success">成功导入的设备: %1$s</string>
|
||||
<string name="device_already_known">已经存在的设备: %1$s</string>
|
||||
<string name="folders_label">文件夹</string>
|
||||
<string name="devices_label">设备</string>
|
||||
<string name="show_device_id">显示设备 ID</string>
|
||||
<string name="device_id">设备 ID</string>
|
||||
<string name="device_id_copied">设备 ID 已复制到剪贴板</string>
|
||||
<string name="share_device_id_chooser">分享设备 ID 于</string>
|
||||
<string name="other_syncthing_instance_title">另一个 Syncthing 实例正在运行</string>
|
||||
<string name="intro_page_one_title">欢迎使用 Syncthing Lite</string>
|
||||
<string name="intro_page_two_title">添加一个设备</string>
|
||||
<string name="intro_page_three_title">分享您的文件夹</string>
|
||||
<string name="intro_page_two_description">输入一个 Syncthing 设备 ID,或者通过 QR 码扫描一个设备 ID</string>
|
||||
<string name="settings">设定</string>
|
||||
<string name="settings_app_version_title">应用版本</string>
|
||||
<string name="settings_local_device_name">本地设备名称</string>
|
||||
<string name="settings_local_device_summary">其他设备将会看到这台设备的名字</string>
|
||||
<string name="device_id_dialog_title">输入设备 ID</string>
|
||||
</resources>
|
||||
@@ -1,12 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="primary">#f43703</color>
|
||||
<color name="primary_dark">#d13602</color>
|
||||
<color name="white_on_primary">#fefefe</color>
|
||||
<color name="primary_dark">#b90000</color>
|
||||
<color name="accent">#FFC107</color>
|
||||
<color name="divider">#1F000000</color>
|
||||
|
||||
<color name="device_online_active">#ff99cc00</color>
|
||||
<color name="device_online_inactive">#f43703</color>
|
||||
<color name="device_offline">#aaaaaa</color>
|
||||
<color name="intro_primary">#ff5252</color>
|
||||
<color name="intro_primary_dark">#c50e29</color>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string-array name="shutdown_delay_labels">
|
||||
<item>@string/settings_shutdown_delay_10_seconds</item>
|
||||
<item>@string/settings_shutdown_delay_30_seconds</item>
|
||||
<item>@string/settings_shutdown_delay_1_minute</item>
|
||||
<item>@string/settings_shutdown_delay_5_minutes</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="shutdown_delay_values">
|
||||
<item>10000</item>
|
||||
<item>30000</item>
|
||||
<item>60000</item>
|
||||
<item>300000</item>
|
||||
</string-array>
|
||||
|
||||
<string translatable="false" name="default_shutdown_delay">60000</string>
|
||||
</resources>
|
||||
@@ -1,21 +1,56 @@
|
||||
<resources>
|
||||
<string name="app_name">Syncthing Lite</string>
|
||||
<string name="index_update_progress_message">index update…</string>
|
||||
<string name="folder_list_empty_message">no folder available</string>
|
||||
<string name="clear_local_cache_index_label">clear local cache/index</string>
|
||||
<string name="update_remote_index_label">update remote index</string>
|
||||
<string name="devices_list_view_empty_message">no devices available</string>
|
||||
<string name="toast_write_storage_permission_required">Write storage permission is required for this functionality</string>
|
||||
<string name="scan_qr_code">Scan QR code</string>
|
||||
<string name="enter_device_id">Enter device ID</string>
|
||||
<string name="invalid_device_id">Invalid device ID</string>
|
||||
<string name="device_id_dialog_title">Enter Device ID</string>
|
||||
<string name="toast_index_update_successful">Index update successful</string>
|
||||
<string name="toast_index_update_failed">Index update failed for %1$d devices</string>
|
||||
<string name="folder_list_empty_message">No folder available</string>
|
||||
<string name="clear_local_cache_index_label">Clear local cache/index</string>
|
||||
<string name="devices_list_view_empty_message">No devices available</string>
|
||||
<string name="invalid_device_id">Error: Invalid device ID</string>
|
||||
<string name="dialog_downloading_file">Downloading file %1$s</string>
|
||||
<string name="toast_file_download_failed">Failed to download file</string>
|
||||
<string name="toast_open_file_failed">No compatible app found</string>
|
||||
<string name="toast_file_upload_failed">File upload failed</string>
|
||||
<string name="toast_upload_complete">File upload complete</string>
|
||||
<string name="dialog_uploading_file">Uploading file %1$s</string>
|
||||
<string name="clear_cache_and_index_title">Clear local cache and index?</string>
|
||||
<string name="clear_cache_and_index_body">Clear all local cache data and index data?</string>
|
||||
<string name="index_update_progress_label">Index update for folder %1$s, %2$d%% synchronized</string>
|
||||
<string name="loading_config_starting_syncthing_client">Loading config, starting syncthing client…</string>
|
||||
<string name="last_modified_time">Last modified: %1$s</string>
|
||||
<string name="remove_device_title">Remove device %1$s\?</string>
|
||||
<string name="remove_device_message">Remove %1$s from the list of known devices?</string>
|
||||
<string name="device_import_success">Successfully imported device %1$s</string>
|
||||
<string name="device_already_known">Device already present %1$s</string>
|
||||
<string name="folders_label">Folders</string>
|
||||
<string name="devices_label">Devices</string>
|
||||
<string name="folder_label_format">%1$s (%2$s)</string>
|
||||
<string name="folder_content_info">%1$s, %2$d files, %3$d directories</string>
|
||||
<string name="file_info">%1$s, last modified %2$s</string>
|
||||
<string name="show_device_id">Show device ID</string>
|
||||
<string name="device_id">Device ID</string>
|
||||
<string name="device_id_copied">Device ID copied to clipboard</string>
|
||||
<string name="share_device_id_chooser">Share device ID with</string>
|
||||
<string name="other_syncthing_instance_title">Another Syncthing instance is running</string>
|
||||
<string name="other_syncthing_instance_message">Local discovery will not work. Stop the other Syncthing instance to enable local discovery.</string>
|
||||
<string name="intro_page_one_title">Welcome to Syncthing Lite</string>
|
||||
<string name="intro_page_one_description">Syncthing replaces proprietary sync and cloud services with something open, trustworthy and decentralized. Your data is your data alone and you deserve to choose where it is stored, if it is shared with some third party and how it\'s transmitted over the Internet.</string>
|
||||
<string name="intro_page_two_title">Add a device</string>
|
||||
<string name="intro_page_three_title">Share your folders</string>
|
||||
<string name="intro_page_two_description">Enter a Syncthing device ID, or scan a device ID from a QR code</string>
|
||||
<string name="intro_page_three_description">Now accept the device with ID %1$s, and share a folder with it. It may take a few minutes until the devices connect.</string>
|
||||
<string name="settings">Settings</string>
|
||||
<string name="settings_app_version_title">App version</string>
|
||||
<string name="settings_local_device_name">Local device name</string>
|
||||
<string name="settings_local_device_summary">The name that other devices will see for this device</string>
|
||||
<string name="settings_shutdown_delay_title">Shutdown delay</string>
|
||||
<string name="settings_shutdown_delay_summary">Time before shuting down the Syncthing client after its last usage</string>
|
||||
<string name="device_id_dialog_title">Enter Device ID</string>
|
||||
<string name="settings_shutdown_delay_10_seconds">10 seconds</string>
|
||||
<string name="settings_shutdown_delay_30_seconds">30 seconds</string>
|
||||
<string name="settings_shutdown_delay_1_minute">1 minute</string>
|
||||
<string name="settings_shutdown_delay_5_minutes">5 minutes</string>
|
||||
<string name="dialog_warning_reconnect_problem">
|
||||
Due to the behaviour of this App and the behaviour of the Syncthing Server,
|
||||
you can\'t reconnect for some minutes if the App was killed (due to removing from the recent App list)
|
||||
or the connection was interrupted.
|
||||
This does not apply to local discovery connections.
|
||||
</string>
|
||||
</resources>
|
||||
|
||||
@@ -4,19 +4,15 @@
|
||||
<item name="colorPrimary">@color/primary</item>
|
||||
<item name="colorPrimaryDark">@color/primary_dark</item>
|
||||
<item name="colorAccent">@color/accent</item>
|
||||
<item name="preferenceTheme">@style/PreferenceThemeOverlay.v14.Material</item>
|
||||
</style>
|
||||
|
||||
<style name="FilePickerTheme" parent="NNF_BaseTheme">
|
||||
<item name="colorPrimary">@color/primary</item>
|
||||
<item name="colorPrimaryDark">@color/primary_dark</item>
|
||||
<item name="colorAccent">@color/accent</item>
|
||||
<item name="alertDialogTheme">@style/FilePickerAlertDialogTheme</item>
|
||||
</style>
|
||||
|
||||
<style name="FilePickerAlertDialogTheme" parent="Theme.AppCompat.Dialog.Alert">
|
||||
<item name="colorPrimary">@color/primary</item>
|
||||
<item name="colorPrimaryDark">@color/primary_dark</item>
|
||||
<style name="Theme.Syncthing.NoActionBar" parent="@style/Theme.AppCompat">
|
||||
<item name="colorPrimary">@color/intro_primary</item>
|
||||
<item name="colorPrimaryDark">@color/intro_primary_dark</item>
|
||||
<item name="colorAccent">@color/accent</item>
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<external-cache-path name="files" path="/" />
|
||||
</paths>
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<PreferenceCategory
|
||||
android:title="@string/settings">
|
||||
|
||||
<EditTextPreference
|
||||
android:key="local_device_name"
|
||||
android:title="@string/settings_local_device_name"
|
||||
android:summary="@string/settings_local_device_summary"
|
||||
android:persistent="false"/>
|
||||
|
||||
<!--
|
||||
|
||||
This is something for the advanced preferences (later), but it still has got an effect
|
||||
|
||||
<ListPreference
|
||||
android:key="shutdown_delay"
|
||||
android:title="@string/settings_shutdown_delay_title"
|
||||
android:summary="@string/settings_shutdown_delay_summary"
|
||||
android:entries="@array/shutdown_delay_labels"
|
||||
android:entryValues="@array/shutdown_delay_values"
|
||||
android:defaultValue="@string/default_shutdown_delay" />
|
||||
|
||||
-->
|
||||
|
||||
<Preference
|
||||
android:key="app_version"
|
||||
android:title="@string/settings_app_version_title"/>
|
||||
|
||||
</PreferenceCategory>
|
||||
|
||||
</PreferenceScreen>
|
||||
+8
-7
@@ -1,9 +1,11 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.2.0'
|
||||
ext.kotlin_version = '1.2.61'
|
||||
ext.support_version = '27.0.2'
|
||||
ext.build_tools_version = '3.0.1'
|
||||
ext.build_tools_version = '3.2.0'
|
||||
ext.anko_version = '0.10.7'
|
||||
ext.protobuf_lite_version = '3.0.1'
|
||||
repositories {
|
||||
mavenLocal()
|
||||
jcenter()
|
||||
@@ -13,19 +15,18 @@ buildscript {
|
||||
dependencies {
|
||||
classpath "com.android.tools.build:gradle:$build_tools_version"
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
classpath 'com.github.ben-manes:gradle-versions-plugin:0.17.0'
|
||||
classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.6'
|
||||
classpath 'com.github.triplet.gradle:play-publisher:1.2.0'
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
mavenLocal()
|
||||
google()
|
||||
jcenter()
|
||||
maven {
|
||||
url "https://jitpack.io"
|
||||
}
|
||||
google()
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
#Mon Dec 28 10:00:20 PST 2015
|
||||
#Fri Sep 14 08:50:38 CEST 2018
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-4.3.1-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-4.6-all.zip
|
||||
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
NEW_VERSION_NAME=$1
|
||||
OLD_VERSION_NAME=$(grep "versionName" "app/build.gradle" | awk '{print $2}' | tr -d "\"")
|
||||
if [[ -z ${NEW_VERSION_NAME} ]]
|
||||
then
|
||||
echo "New version name is empty. Please set a new version. Current version: $OLD_VERSION_NAME"
|
||||
exit
|
||||
fi
|
||||
|
||||
echo "
|
||||
|
||||
Updating Translations
|
||||
-----------------------------
|
||||
"
|
||||
tx push -s
|
||||
# Force push/pull to make sure this is executed. Apparently tx only compares timestamps, not file
|
||||
# contents. So if a file was `touch`ed, it won't be updated by default.
|
||||
tx pull -a -f
|
||||
git add -A "app/src/main/res/values-*/strings.xml"
|
||||
if ! git diff --cached --exit-code;
|
||||
then
|
||||
git commit -m "Imported translations"
|
||||
fi
|
||||
|
||||
echo "
|
||||
|
||||
Updating Version
|
||||
-----------------------------
|
||||
"
|
||||
OLD_VERSION_CODE=$(grep "versionCode" "app/build.gradle" -m 1 | awk '{print $2}')
|
||||
NEW_VERSION_CODE=$(($OLD_VERSION_CODE + 1))
|
||||
sed -i "s/versionCode $OLD_VERSION_CODE/versionCode $NEW_VERSION_CODE/" "app/build.gradle"
|
||||
sed -i "s/versionName \"$OLD_VERSION_NAME\"/versionName \"$NEW_VERSION_NAME\"/" "app/build.gradle"
|
||||
|
||||
LIBRARY_NAME="com.github.Nutomic:syncthing-java"
|
||||
sed -i "s/$LIBRARY_NAME:$OLD_VERSION_NAME/$LIBRARY_NAME:$NEW_VERSION_NAME/" "app/build.gradle"
|
||||
|
||||
git add "app/build.gradle"
|
||||
git commit -m "Version $NEW_VERSION_NAME"
|
||||
git tag ${NEW_VERSION_NAME}
|
||||
|
||||
echo "
|
||||
|
||||
Running Lint
|
||||
-----------------------------
|
||||
"
|
||||
./gradlew clean lintVitalRelease
|
||||
|
||||
echo "
|
||||
Update ready.
|
||||
"
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
version=$(git describe --tags)
|
||||
regex='^[0-9]+\.[0-9]+\.[0-9]+$'
|
||||
if [[ ! ${version} =~ $regex ]]
|
||||
then
|
||||
echo "Current commit is not a release"
|
||||
exit;
|
||||
fi
|
||||
|
||||
echo "
|
||||
|
||||
Pushing to Github
|
||||
-----------------------------
|
||||
"
|
||||
git push
|
||||
git push --tags
|
||||
|
||||
echo "
|
||||
|
||||
Push to Google Play
|
||||
-----------------------------
|
||||
"
|
||||
|
||||
read -s -p "Enter signing password: " password
|
||||
|
||||
SIGNING_PASSWORD=${password} ./gradlew assembleRelease
|
||||
|
||||
# Upload apk and listing to Google Play
|
||||
SIGNING_PASSWORD=${password} ./gradlew publishRelease
|
||||
|
||||
echo "
|
||||
|
||||
Release published!
|
||||
"
|
||||
+1
-1
@@ -1 +1 @@
|
||||
include ':app'
|
||||
include ':app', ':syncthing-repository-android', ':syncthing-repository-default', ':syncthing-relay-client', ':syncthing-bep', ':syncthing-core', ':syncthing-client', ':syncthing-discovery', ':syncthing-client-cli', ':syncthing-http-relay-client'
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
apply plugin: 'java-library'
|
||||
apply plugin: 'kotlin'
|
||||
apply plugin: 'com.google.protobuf'
|
||||
|
||||
dependencies {
|
||||
compile fileTree(dir: 'libs', include: ['*.jar'])
|
||||
compile project(':syncthing-core')
|
||||
compile project(':syncthing-relay-client')
|
||||
compile project(':syncthing-http-relay-client')
|
||||
compile "net.jpountz.lz4:lz4:1.3.0"
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:0.30.2'
|
||||
implementation "com.google.protobuf:protobuf-lite:$protobuf_lite_version"
|
||||
}
|
||||
|
||||
protobuf {
|
||||
protoc {
|
||||
artifact = "com.google.protobuf:protoc:3.5.1-1"
|
||||
}
|
||||
plugins {
|
||||
javalite {
|
||||
// The codegen for lite comes as a separate artifact
|
||||
artifact = "com.google.protobuf:protoc-gen-javalite:3.0.0"
|
||||
}
|
||||
}
|
||||
generateProtoTasks {
|
||||
all().each { task ->
|
||||
task.builtins {
|
||||
// In most cases you don't need the full Java output
|
||||
// if you use the lite output.
|
||||
remove java
|
||||
}
|
||||
task.plugins {
|
||||
javalite { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Workaround for https://github.com/google/protobuf-gradle-plugin/issues/100
|
||||
compileKotlin.dependsOn('generateProto')
|
||||
sourceSets.main.kotlin.srcDirs += file("${protobuf.generatedFilesBaseDir}/main/javalite")
|
||||
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
* Copyright (C) 2016 Davide Imbriaco
|
||||
* Copyright (C) 2018 Jonas Lochmann
|
||||
*
|
||||
* This Java file is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package net.syncthing.java.bep
|
||||
|
||||
import com.google.protobuf.ByteString
|
||||
import kotlinx.coroutines.experimental.*
|
||||
import kotlinx.coroutines.experimental.channels.Channel
|
||||
import net.syncthing.java.bep.BlockExchangeProtos.ErrorCode
|
||||
import net.syncthing.java.bep.BlockExchangeProtos.Request
|
||||
import net.syncthing.java.bep.utils.longSumBy
|
||||
import net.syncthing.java.core.beans.BlockInfo
|
||||
import net.syncthing.java.core.beans.FileBlocks
|
||||
import net.syncthing.java.core.beans.FileInfo
|
||||
import net.syncthing.java.core.interfaces.TempRepository
|
||||
import net.syncthing.java.core.utils.NetworkUtils
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.bouncycastle.util.encoders.Hex
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.io.*
|
||||
import java.lang.Exception
|
||||
import java.security.MessageDigest
|
||||
import java.util.*
|
||||
import kotlin.collections.HashMap
|
||||
|
||||
class BlockPuller internal constructor(private val connectionHandler: ConnectionHandler,
|
||||
private val indexHandler: IndexHandler,
|
||||
private val responseHandler: ResponseHandler,
|
||||
private val tempRepository: TempRepository) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
fun pullFileSync(
|
||||
fileInfo: FileInfo,
|
||||
progressListener: (status: BlockPullerStatus) -> Unit = { }
|
||||
): InputStream {
|
||||
return runBlocking {
|
||||
pullFileCoroutine(fileInfo, progressListener)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun pullFileCoroutine(
|
||||
fileInfo: FileInfo,
|
||||
progressListener: (status: BlockPullerStatus) -> Unit = { }
|
||||
): InputStream {
|
||||
val fileBlocks = indexHandler.waitForRemoteIndexAcquired(connectionHandler)
|
||||
.getFileInfoAndBlocksByPath(fileInfo.folder, fileInfo.path)
|
||||
?.value
|
||||
?: throw IOException("file not found in local index for folder = ${fileInfo.folder} path = ${fileInfo.path}")
|
||||
logger.info("pulling file = {}", fileBlocks)
|
||||
NetworkUtils.assertProtocol(connectionHandler.hasFolder(fileBlocks.folder), { "supplied connection handler $connectionHandler will not share folder ${fileBlocks.folder}" })
|
||||
|
||||
// the file could have changed since the caller read it
|
||||
// this would save the file using a wrong name, so throw here
|
||||
if (fileBlocks.hash != fileInfo.hash) {
|
||||
throw IllegalStateException("the current file entry hash does not match the hash of the provided one")
|
||||
}
|
||||
|
||||
val blockTempIdByHash = Collections.synchronizedMap(HashMap<String, String>())
|
||||
|
||||
var status = BlockPullerStatus(
|
||||
downloadedBytes = 0,
|
||||
totalTransferSize = fileBlocks.blocks.distinctBy { it.hash }.longSumBy { it.size.toLong() },
|
||||
totalFileSize = fileBlocks.size
|
||||
)
|
||||
|
||||
try {
|
||||
val reportProgressLock = Object()
|
||||
|
||||
fun updateProgress(additionalDownloadedBytes: Long) {
|
||||
synchronized(reportProgressLock) {
|
||||
status = status.copy(
|
||||
downloadedBytes = status.downloadedBytes + additionalDownloadedBytes
|
||||
)
|
||||
|
||||
progressListener(status)
|
||||
}
|
||||
}
|
||||
|
||||
coroutineScope {
|
||||
val pipe = Channel<BlockInfo>()
|
||||
|
||||
repeat(4 /* 4 blocks per time */) { workerNumber ->
|
||||
async {
|
||||
for (block in pipe) {
|
||||
logger.debug("request block with hash = {} from worker {}", block.hash, workerNumber)
|
||||
|
||||
val blockContent = pullBlock(fileBlocks, block, 1000 * 60 /* 60 seconds timeout per block */)
|
||||
|
||||
blockTempIdByHash[block.hash] = tempRepository.pushTempData(blockContent)
|
||||
|
||||
updateProgress(blockContent.size.toLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileBlocks.blocks.distinctBy { it.hash }.forEach { block ->
|
||||
pipe.send(block)
|
||||
}
|
||||
|
||||
pipe.close()
|
||||
}
|
||||
|
||||
// the sequence is evaluated lazy -> only one block per time is loaded
|
||||
val fileBlocksIterator = fileBlocks.blocks
|
||||
.asSequence()
|
||||
.map { tempRepository.popTempData(blockTempIdByHash[it.hash]!!) }
|
||||
.map { ByteArrayInputStream(it) }
|
||||
.iterator()
|
||||
|
||||
return object : SequenceInputStream(object : Enumeration<InputStream> {
|
||||
override fun hasMoreElements() = fileBlocksIterator.hasNext()
|
||||
override fun nextElement() = fileBlocksIterator.next()
|
||||
}) {
|
||||
override fun close() {
|
||||
super.close()
|
||||
|
||||
// delete all temp blocks now
|
||||
// they are deleted after reading, but the consumer could stop before reading the whole stream
|
||||
tempRepository.deleteTempData(blockTempIdByHash.values.toList())
|
||||
}
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
// delete all temp blocks now
|
||||
tempRepository.deleteTempData(blockTempIdByHash.values.toList())
|
||||
|
||||
throw ex
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pullBlock(fileBlocks: FileBlocks, block: BlockInfo, timeoutInMillis: Long): ByteArray {
|
||||
logger.debug("sent request for block, hash = {}", block.hash)
|
||||
|
||||
val response =
|
||||
withTimeout(timeoutInMillis) {
|
||||
try {
|
||||
doRequest(
|
||||
Request.newBuilder()
|
||||
.setFolder(fileBlocks.folder)
|
||||
.setName(fileBlocks.path)
|
||||
.setOffset(block.offset)
|
||||
.setSize(block.size)
|
||||
.setHash(ByteString.copyFrom(Hex.decode(block.hash)))
|
||||
)
|
||||
} catch (ex: TimeoutCancellationException) {
|
||||
// It seems like the TimeoutCancellationException
|
||||
// is handled differently so that the timeout is ignored.
|
||||
// Due to that, it's converted to an IOException.
|
||||
|
||||
throw IOException("timeout during requesting block")
|
||||
}
|
||||
}
|
||||
|
||||
NetworkUtils.assertProtocol(response.code == ErrorCode.NO_ERROR) {
|
||||
"received error response, code = ${response.code}"
|
||||
}
|
||||
|
||||
val data = response.data.toByteArray()
|
||||
val hash = Hex.toHexString(MessageDigest.getInstance("SHA-256").digest(data))
|
||||
|
||||
if (hash != block.hash) {
|
||||
throw IllegalStateException("expected block with hash ${block.hash}, but got block with hash $hash")
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
private suspend fun doRequest(request: Request.Builder): BlockExchangeProtos.Response {
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val requestId = responseHandler.registerListener { response ->
|
||||
continuation.resume(response)
|
||||
}
|
||||
|
||||
connectionHandler.sendMessage(
|
||||
request
|
||||
.setId(requestId)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class BlockPullerStatus(
|
||||
val downloadedBytes: Long,
|
||||
val totalTransferSize: Long,
|
||||
val totalFileSize: Long
|
||||
)
|
||||
@@ -0,0 +1,308 @@
|
||||
/*
|
||||
* Copyright (C) 2016 Davide Imbriaco
|
||||
*
|
||||
* This Java file is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package net.syncthing.java.bep
|
||||
|
||||
import com.google.protobuf.ByteString
|
||||
import net.syncthing.java.bep.BlockExchangeProtos.Vector
|
||||
import net.syncthing.java.core.beans.*
|
||||
import net.syncthing.java.core.beans.FileInfo.Version
|
||||
import net.syncthing.java.core.configuration.Configuration
|
||||
import net.syncthing.java.core.utils.BlockUtils
|
||||
import net.syncthing.java.core.utils.NetworkUtils
|
||||
import net.syncthing.java.core.utils.submitLogging
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.tuple.Pair
|
||||
import org.bouncycastle.util.encoders.Hex
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.io.Closeable
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.security.MessageDigest
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.ExecutionException
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.Future
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
class BlockPusher internal constructor(private val localDeviceId: DeviceId,
|
||||
private val connectionHandler: ConnectionHandler,
|
||||
private val indexHandler: IndexHandler) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
|
||||
fun pushDelete(folderId: String, targetPath: String): IndexEditObserver {
|
||||
val fileInfo = indexHandler.waitForRemoteIndexAcquired(connectionHandler).getFileInfoByPath(folderId, targetPath)!!
|
||||
NetworkUtils.assertProtocol(connectionHandler.hasFolder(fileInfo.folder), {"supplied connection handler $connectionHandler will not share folder ${fileInfo.folder}"})
|
||||
return IndexEditObserver(sendIndexUpdate(folderId, BlockExchangeProtos.FileInfo.newBuilder()
|
||||
.setName(targetPath)
|
||||
.setType(BlockExchangeProtos.FileInfoType.valueOf(fileInfo.type.name))
|
||||
.setDeleted(true), fileInfo.versionList))
|
||||
}
|
||||
|
||||
fun pushDir(folder: String, path: String): IndexEditObserver {
|
||||
NetworkUtils.assertProtocol(connectionHandler.hasFolder(folder), {"supplied connection handler $connectionHandler will not share folder $folder"})
|
||||
return IndexEditObserver(sendIndexUpdate(folder, BlockExchangeProtos.FileInfo.newBuilder()
|
||||
.setName(path)
|
||||
.setType(BlockExchangeProtos.FileInfoType.DIRECTORY), null))
|
||||
}
|
||||
|
||||
fun pushFile(inputStream: InputStream, folderId: String, targetPath: String): FileUploadObserver {
|
||||
val fileInfo = indexHandler.waitForRemoteIndexAcquired(connectionHandler).getFileInfoByPath(folderId, targetPath)
|
||||
NetworkUtils.assertProtocol(connectionHandler.hasFolder(folderId), {"supplied connection handler $connectionHandler will not share folder $folderId"})
|
||||
assert(fileInfo == null || fileInfo.folder == folderId)
|
||||
assert(fileInfo == null || fileInfo.path == targetPath)
|
||||
val monitoringProcessExecutorService = Executors.newCachedThreadPool()
|
||||
val dataSource = DataSource(inputStream)
|
||||
val fileSize = dataSource.size
|
||||
val sentBlocks = Collections.newSetFromMap(ConcurrentHashMap<String, Boolean>())
|
||||
val uploadError = AtomicReference<Exception>()
|
||||
val isCompleted = AtomicBoolean(false)
|
||||
val updateLock = Object()
|
||||
val listener = {request: BlockExchangeProtos.Request ->
|
||||
if (request.folder == folderId && request.name == targetPath) {
|
||||
val hash = Hex.toHexString(request.hash.toByteArray())
|
||||
logger.debug("handling block request = {}:{}-{} ({})", request.name, request.offset, request.size, hash)
|
||||
val data = dataSource.getBlock(request.offset, request.size, hash)
|
||||
val future = connectionHandler.sendMessage(BlockExchangeProtos.Response.newBuilder()
|
||||
.setCode(BlockExchangeProtos.ErrorCode.NO_ERROR)
|
||||
.setData(ByteString.copyFrom(data))
|
||||
.setId(request.id)
|
||||
.build())
|
||||
monitoringProcessExecutorService.submitLogging {
|
||||
try {
|
||||
future.get()
|
||||
sentBlocks.add(hash)
|
||||
synchronized(updateLock) {
|
||||
updateLock.notifyAll()
|
||||
}
|
||||
//TODO retry on error, register error and throw on watcher
|
||||
} catch (ex: InterruptedException) {
|
||||
//return and do nothing
|
||||
} catch (ex: ExecutionException) {
|
||||
uploadError.set(ex)
|
||||
synchronized(updateLock) {
|
||||
updateLock.notifyAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
connectionHandler.registerOnRequestMessageReceivedListeners(listener)
|
||||
logger.debug("send index update for file = {}", targetPath)
|
||||
val indexListener = { folderInfo: FolderInfo, newRecords: List<FileInfo>, indexInfo: IndexInfo ->
|
||||
if (folderInfo.folderId == folderId) {
|
||||
for (fileInfo2 in newRecords) {
|
||||
if (fileInfo2.path == targetPath && fileInfo2.hash == dataSource.getHash()) { //TODO check not invalid
|
||||
// sentBlocks.addAll(dataSource.getHashes());
|
||||
isCompleted.set(true)
|
||||
synchronized(updateLock) {
|
||||
updateLock.notifyAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
indexHandler.registerOnIndexRecordAcquiredListener(indexListener)
|
||||
val indexUpdate = sendIndexUpdate(folderId, BlockExchangeProtos.FileInfo.newBuilder()
|
||||
.setName(targetPath)
|
||||
.setSize(fileSize)
|
||||
.setType(BlockExchangeProtos.FileInfoType.FILE)
|
||||
.addAllBlocks(dataSource.blocks), fileInfo?.versionList).right
|
||||
return object : FileUploadObserver() {
|
||||
|
||||
override fun progressPercentage() = if (isCompleted.get()) 100 else (sentBlocks.size.toFloat() / dataSource.getHashes().size).toInt()
|
||||
|
||||
// return sentBlocks.size() == dataSource.getHashes().size();
|
||||
override fun isCompleted() = isCompleted.get()
|
||||
|
||||
override fun close() {
|
||||
logger.debug("closing upload process")
|
||||
monitoringProcessExecutorService.shutdown()
|
||||
indexHandler.unregisterOnIndexRecordAcquiredListener(indexListener)
|
||||
connectionHandler.unregisterOnRequestMessageReceivedListeners(listener)
|
||||
val fileInfo1 = indexHandler.pushRecord(indexUpdate.folder, indexUpdate.filesList.single())
|
||||
logger.info("sent file info record = {}", fileInfo1)
|
||||
}
|
||||
|
||||
@Throws(InterruptedException::class, IOException::class)
|
||||
override fun waitForProgressUpdate(): Int {
|
||||
synchronized(updateLock) {
|
||||
updateLock.wait()
|
||||
}
|
||||
if (uploadError.get() != null) {
|
||||
throw IOException(uploadError.get())
|
||||
}
|
||||
return progressPercentage()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendIndexUpdate(folderId: String, fileInfoBuilder: BlockExchangeProtos.FileInfo.Builder,
|
||||
oldVersions: Iterable<Version>?): Pair<Future<*>, BlockExchangeProtos.IndexUpdate> {
|
||||
run {
|
||||
val nextSequence = indexHandler.sequencer().nextSequence()
|
||||
val list = oldVersions ?: emptyList()
|
||||
logger.debug("version list = {}", list)
|
||||
val id = ByteBuffer.wrap(localDeviceId.toHashData()).long
|
||||
val version = BlockExchangeProtos.Counter.newBuilder()
|
||||
.setId(id)
|
||||
.setValue(nextSequence)
|
||||
.build()
|
||||
logger.debug("append new version = {}", version)
|
||||
fileInfoBuilder
|
||||
.setSequence(nextSequence)
|
||||
.setVersion(Vector.newBuilder().addAllCounters(list.map { record ->
|
||||
BlockExchangeProtos.Counter.newBuilder().setId(record.id).setValue(record.value).build()
|
||||
})
|
||||
.addCounters(version))
|
||||
}
|
||||
val lastModified = Date()
|
||||
val fileInfo = fileInfoBuilder
|
||||
.setModifiedS(lastModified.time / 1000)
|
||||
.setModifiedNs((lastModified.time % 1000 * 1000000).toInt())
|
||||
.setNoPermissions(true)
|
||||
.build()
|
||||
val indexUpdate = BlockExchangeProtos.IndexUpdate.newBuilder()
|
||||
.setFolder(folderId)
|
||||
.addFiles(fileInfo)
|
||||
.build()
|
||||
logger.debug("index update = {}", fileInfo)
|
||||
return Pair.of(connectionHandler.sendMessage(indexUpdate), indexUpdate)
|
||||
}
|
||||
|
||||
abstract inner class FileUploadObserver : Closeable {
|
||||
|
||||
abstract fun progressPercentage(): Int
|
||||
|
||||
abstract fun isCompleted(): Boolean
|
||||
|
||||
@Throws(InterruptedException::class)
|
||||
abstract fun waitForProgressUpdate(): Int
|
||||
|
||||
@Throws(InterruptedException::class)
|
||||
fun waitForComplete(): FileUploadObserver {
|
||||
while (!isCompleted()) {
|
||||
waitForProgressUpdate()
|
||||
}
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
inner class IndexEditObserver(private val future: Future<*>, private val indexUpdate: BlockExchangeProtos.IndexUpdate) : Closeable {
|
||||
|
||||
//throw exception if job has errors
|
||||
@Throws(InterruptedException::class, ExecutionException::class)
|
||||
fun isCompleted(): Boolean {
|
||||
return if (future.isDone) {
|
||||
future.get()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
constructor(pair: Pair<Future<*>, BlockExchangeProtos.IndexUpdate>) : this(pair.left, pair.right)
|
||||
|
||||
@Throws(InterruptedException::class, ExecutionException::class)
|
||||
fun waitForComplete() {
|
||||
future.get()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun close() {
|
||||
indexHandler.pushRecord(indexUpdate.folder, indexUpdate.filesList.single())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private class DataSource @Throws(IOException::class) constructor(private val inputStream: InputStream) {
|
||||
|
||||
var size: Long = 0
|
||||
private set
|
||||
lateinit var blocks: List<BlockExchangeProtos.BlockInfo>
|
||||
private set
|
||||
private var hashes: Set<String>? = null
|
||||
|
||||
private var hash: String? = null
|
||||
|
||||
init {
|
||||
inputStream.use { it ->
|
||||
val list = mutableListOf<BlockExchangeProtos.BlockInfo>()
|
||||
var offset: Long = 0
|
||||
while (true) {
|
||||
var block = ByteArray(BLOCK_SIZE)
|
||||
val blockSize = it.read(block)
|
||||
if (blockSize <= 0) {
|
||||
break
|
||||
}
|
||||
if (blockSize < block.size) {
|
||||
block = Arrays.copyOf(block, blockSize)
|
||||
}
|
||||
|
||||
val hash = MessageDigest.getInstance("SHA-256").digest(block)
|
||||
list.add(BlockExchangeProtos.BlockInfo.newBuilder()
|
||||
.setHash(ByteString.copyFrom(hash))
|
||||
.setOffset(offset)
|
||||
.setSize(blockSize)
|
||||
.build())
|
||||
offset += blockSize.toLong()
|
||||
}
|
||||
size = offset
|
||||
blocks = list
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun getBlock(offset: Long, size: Int, hash: String): ByteArray {
|
||||
val buffer = ByteArray(size)
|
||||
inputStream.use { it ->
|
||||
IOUtils.skipFully(it, offset)
|
||||
IOUtils.readFully(it, buffer)
|
||||
NetworkUtils.assertProtocol(Hex.toHexString(MessageDigest.getInstance("SHA-256").digest(buffer)) == hash, {"block hash mismatch!"})
|
||||
return buffer
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun getHashes(): Set<String> {
|
||||
return hashes ?: let {
|
||||
val hashes2 = blocks.map { input -> Hex.toHexString(input.hash.toByteArray()) }.toSet()
|
||||
hashes = hashes2
|
||||
return hashes2
|
||||
}
|
||||
}
|
||||
|
||||
fun getHash(): String {
|
||||
return hash ?: let {
|
||||
val blockInfo = blocks.map { input ->
|
||||
BlockInfo(input.offset, input.size, Hex.toHexString(input.hash.toByteArray()))
|
||||
}
|
||||
val hash2 = BlockUtils.hashBlocks(blockInfo)
|
||||
hash = hash2
|
||||
hash2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val BLOCK_SIZE = 128 * 1024
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (C) 2016 Davide Imbriaco
|
||||
*
|
||||
* This Java file is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package net.syncthing.java.bep
|
||||
|
||||
internal data class ClusterConfigFolderInfo(val folderId: String, var label: String = folderId,
|
||||
var isAnnounced: Boolean = false, var isShared: Boolean = false) {
|
||||
|
||||
init {
|
||||
assert(folderId.isNotEmpty())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
/*
|
||||
* Copyright (C) 2016 Davide Imbriaco
|
||||
* Copyright (C) 2018 Jonas Lochmann
|
||||
*
|
||||
* This Java file is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package net.syncthing.java.bep
|
||||
|
||||
import com.google.protobuf.ByteString
|
||||
import com.google.protobuf.MessageLite
|
||||
import net.jpountz.lz4.LZ4Factory
|
||||
import net.syncthing.java.bep.BlockExchangeProtos.*
|
||||
import net.syncthing.java.client.protocol.rp.RelayClient
|
||||
import net.syncthing.java.core.beans.DeviceAddress
|
||||
import net.syncthing.java.core.beans.DeviceId
|
||||
import net.syncthing.java.core.beans.DeviceInfo
|
||||
import net.syncthing.java.core.beans.FolderInfo
|
||||
import net.syncthing.java.core.configuration.Configuration
|
||||
import net.syncthing.java.core.interfaces.TempRepository
|
||||
import net.syncthing.java.core.security.KeystoreHandler
|
||||
import net.syncthing.java.core.utils.NetworkUtils
|
||||
import net.syncthing.java.core.utils.submitLogging
|
||||
import net.syncthing.java.httprelay.HttpRelayClient
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.tuple.Pair
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.io.Closeable
|
||||
import java.io.DataInputStream
|
||||
import java.io.DataOutputStream
|
||||
import java.io.IOException
|
||||
import java.lang.reflect.InvocationTargetException
|
||||
import java.nio.ByteBuffer
|
||||
import java.security.cert.CertificateException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.Future
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.net.ssl.SSLSocket
|
||||
|
||||
class ConnectionHandler(private val configuration: Configuration, val address: DeviceAddress,
|
||||
private val indexHandler: IndexHandler,
|
||||
private val tempRepository: TempRepository,
|
||||
private val onNewFolderSharedListener: (ConnectionHandler, FolderInfo) -> Unit,
|
||||
private val onConnectionChangedListener: (ConnectionHandler) -> Unit) : Closeable {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
private val outExecutorService = Executors.newSingleThreadExecutor()
|
||||
private val inExecutorService = Executors.newSingleThreadExecutor()
|
||||
private val messageProcessingService = Executors.newCachedThreadPool()
|
||||
private val periodicExecutorService = Executors.newSingleThreadScheduledExecutor()
|
||||
private lateinit var socket: SSLSocket
|
||||
private var inputStream: DataInputStream? = null
|
||||
private var outputStream: DataOutputStream? = null
|
||||
private var lastActive = Long.MIN_VALUE
|
||||
internal var clusterConfigInfo: ClusterConfigInfo? = null
|
||||
private set
|
||||
private val clusterConfigWaitingLock = Object()
|
||||
private val responseHandler = ResponseHandler()
|
||||
private val blockPuller = BlockPuller(this, indexHandler, responseHandler, tempRepository)
|
||||
private val blockPusher = BlockPusher(configuration.localDeviceId, this, indexHandler)
|
||||
private val onRequestMessageReceivedListeners = mutableSetOf<(Request) -> Unit>()
|
||||
private var isClosed = false
|
||||
var isConnected = false
|
||||
private set
|
||||
|
||||
fun deviceId(): DeviceId = address.deviceId()
|
||||
|
||||
private fun checkNotClosed() {
|
||||
NetworkUtils.assertProtocol(!isClosed, {"connection $this closed"})
|
||||
}
|
||||
|
||||
internal fun registerOnRequestMessageReceivedListeners(listener: (Request) -> Unit) {
|
||||
onRequestMessageReceivedListeners.add(listener)
|
||||
}
|
||||
|
||||
internal fun unregisterOnRequestMessageReceivedListeners(listener: (Request) -> Unit) {
|
||||
assert(onRequestMessageReceivedListeners.contains(listener))
|
||||
onRequestMessageReceivedListeners.remove(listener)
|
||||
}
|
||||
|
||||
@Throws(IOException::class, KeystoreHandler.CryptoException::class)
|
||||
fun connect(): ConnectionHandler {
|
||||
checkNotClosed()
|
||||
assert(!isConnected, {"already connected!"})
|
||||
logger.info("connecting to {}", address.address)
|
||||
|
||||
val keystoreHandler = KeystoreHandler.Loader().loadKeystore(configuration)
|
||||
|
||||
socket = when (address.getType()) {
|
||||
DeviceAddress.AddressType.TCP -> {
|
||||
logger.debug("opening tcp ssl connection")
|
||||
keystoreHandler.createSocket(address.getSocketAddress(), KeystoreHandler.BEP)
|
||||
}
|
||||
DeviceAddress.AddressType.RELAY -> {
|
||||
logger.debug("opening relay connection")
|
||||
keystoreHandler.wrapSocket(RelayClient(configuration).openRelayConnection(address), KeystoreHandler.BEP)
|
||||
}
|
||||
DeviceAddress.AddressType.HTTP_RELAY, DeviceAddress.AddressType.HTTPS_RELAY -> {
|
||||
logger.debug("opening http relay connection")
|
||||
keystoreHandler.wrapSocket(HttpRelayClient().openRelayConnection(address), KeystoreHandler.BEP)
|
||||
}
|
||||
else -> throw UnsupportedOperationException("unsupported address type = " + address.getType())
|
||||
}
|
||||
inputStream = DataInputStream(socket.inputStream)
|
||||
outputStream = DataOutputStream(socket.outputStream)
|
||||
|
||||
sendHelloMessage(BlockExchangeProtos.Hello.newBuilder()
|
||||
.setClientName(configuration.clientName)
|
||||
.setClientVersion(configuration.clientVersion)
|
||||
.setDeviceName(configuration.localDeviceName)
|
||||
.build().toByteArray())
|
||||
markActivityOnSocket()
|
||||
|
||||
receiveHelloMessage()
|
||||
try {
|
||||
keystoreHandler.checkSocketCertificate(socket, address.deviceId())
|
||||
} catch (e: CertificateException) {
|
||||
throw IOException(e)
|
||||
}
|
||||
|
||||
run {
|
||||
val clusterConfigBuilder = ClusterConfig.newBuilder()
|
||||
for (folder in configuration.folders) {
|
||||
val folderBuilder = Folder.newBuilder()
|
||||
.setId(folder.folderId)
|
||||
.setLabel(folder.label)
|
||||
run {
|
||||
//our device
|
||||
val deviceBuilder = Device.newBuilder()
|
||||
.setId(ByteString.copyFrom(configuration.localDeviceId.toHashData()))
|
||||
.setIndexId(indexHandler.sequencer().indexId())
|
||||
.setMaxSequence(indexHandler.sequencer().currentSequence())
|
||||
folderBuilder.addDevices(deviceBuilder)
|
||||
}
|
||||
run {
|
||||
//other device
|
||||
val deviceBuilder = Device.newBuilder()
|
||||
.setId(ByteString.copyFrom(DeviceId(address.deviceId).toHashData()))
|
||||
val indexSequenceInfo = indexHandler.indexRepository.findIndexInfoByDeviceAndFolder(address.deviceId(), folder.folderId)
|
||||
indexSequenceInfo?.let {
|
||||
deviceBuilder
|
||||
.setIndexId(indexSequenceInfo.indexId)
|
||||
.setMaxSequence(indexSequenceInfo.localSequence)
|
||||
logger.info("send delta index info device = {} index = {} max (local) sequence = {}",
|
||||
indexSequenceInfo.deviceId,
|
||||
indexSequenceInfo.indexId,
|
||||
indexSequenceInfo.localSequence)
|
||||
}
|
||||
folderBuilder.addDevices(deviceBuilder)
|
||||
}
|
||||
clusterConfigBuilder.addFolders(folderBuilder)
|
||||
//TODO other devices??
|
||||
}
|
||||
sendMessage(clusterConfigBuilder.build())
|
||||
}
|
||||
synchronized(clusterConfigWaitingLock) {
|
||||
startMessageListenerService()
|
||||
while (clusterConfigInfo == null && !isClosed) {
|
||||
logger.debug("wait for cluster config")
|
||||
try {
|
||||
clusterConfigWaitingLock.wait()
|
||||
} catch (e: InterruptedException) {
|
||||
throw IOException(e)
|
||||
}
|
||||
}
|
||||
if (clusterConfigInfo == null) {
|
||||
throw IOException("unable to retrieve cluster config from peer!")
|
||||
}
|
||||
}
|
||||
for (folder in configuration.folders) {
|
||||
if (hasFolder(folder.folderId)) {
|
||||
sendIndexMessage(folder.folderId)
|
||||
}
|
||||
}
|
||||
periodicExecutorService.scheduleWithFixedDelay({ this.sendPing() }, 90, 90, TimeUnit.SECONDS)
|
||||
isConnected = true
|
||||
onConnectionChangedListener(this)
|
||||
return this
|
||||
}
|
||||
|
||||
fun getBlockPuller(): BlockPuller {
|
||||
return blockPuller
|
||||
}
|
||||
|
||||
fun getBlockPusher(): BlockPusher {
|
||||
return blockPusher
|
||||
}
|
||||
|
||||
private fun sendIndexMessage(folderId: String) {
|
||||
sendMessage(Index.newBuilder()
|
||||
.setFolder(folderId)
|
||||
.build())
|
||||
}
|
||||
|
||||
fun closeBg() {
|
||||
Thread { close() }.start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive hello message and save device name to configuration.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
private fun receiveHelloMessage() {
|
||||
val magic = inputStream!!.readInt()
|
||||
NetworkUtils.assertProtocol(magic == MAGIC, {"magic mismatch, expected $MAGIC, got $magic"})
|
||||
val length = inputStream!!.readShort().toInt()
|
||||
NetworkUtils.assertProtocol(length > 0, {"invalid lenght, must be >0, got $length"})
|
||||
val buffer = ByteArray(length)
|
||||
inputStream!!.readFully(buffer)
|
||||
val hello = BlockExchangeProtos.Hello.parseFrom(buffer)
|
||||
logger.info("Received hello message, deviceName=${hello.deviceName}, clientName=${hello.clientName}, clientVersion=${hello.clientVersion}")
|
||||
configuration.peers = configuration.peers.map { peer ->
|
||||
if (peer.deviceId == deviceId()) {
|
||||
DeviceInfo(deviceId(), hello.deviceName)
|
||||
} else {
|
||||
peer
|
||||
}
|
||||
}.toSet()
|
||||
configuration.persistLater()
|
||||
}
|
||||
|
||||
private fun sendHelloMessage(payload: ByteArray): Future<*> {
|
||||
return outExecutorService.submitLogging {
|
||||
try {
|
||||
logger.debug("Sending hello message")
|
||||
val header = ByteBuffer.allocate(6)
|
||||
header.putInt(MAGIC)
|
||||
header.putShort(payload.size.toShort())
|
||||
outputStream!!.write(header.array())
|
||||
outputStream!!.write(payload)
|
||||
outputStream!!.flush()
|
||||
} catch (ex: IOException) {
|
||||
if (outExecutorService.isShutdown) {
|
||||
return@submitLogging
|
||||
}
|
||||
logger.error("error writing to output stream", ex)
|
||||
closeBg()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendPing(): Future<*> {
|
||||
return sendMessage(Ping.newBuilder().build())
|
||||
}
|
||||
|
||||
private fun markActivityOnSocket() {
|
||||
lastActive = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun receiveMessage(): Pair<BlockExchangeProtos.MessageType, MessageLite> {
|
||||
var headerLength = inputStream!!.readShort().toInt()
|
||||
while (headerLength == 0) {
|
||||
logger.warn("got headerLength == 0, skipping short")
|
||||
headerLength = inputStream!!.readShort().toInt()
|
||||
}
|
||||
markActivityOnSocket()
|
||||
NetworkUtils.assertProtocol(headerLength > 0, {"invalid lenght, must be >0, got $headerLength"})
|
||||
val headerBuffer = ByteArray(headerLength)
|
||||
inputStream!!.readFully(headerBuffer)
|
||||
val header = BlockExchangeProtos.Header.parseFrom(headerBuffer)
|
||||
var messageLength = 0
|
||||
while (messageLength == 0) {
|
||||
logger.warn("received readInt() == 0, expecting 'bep message header length' (int >0), ignoring (keepalive?)")
|
||||
messageLength = inputStream!!.readInt()
|
||||
}
|
||||
NetworkUtils.assertProtocol(messageLength >= 0, {"invalid lenght, must be >=0, got $messageLength"})
|
||||
var messageBuffer = ByteArray(messageLength)
|
||||
inputStream!!.readFully(messageBuffer)
|
||||
markActivityOnSocket()
|
||||
if (header.compression == BlockExchangeProtos.MessageCompression.LZ4) {
|
||||
val uncompressedLength = ByteBuffer.wrap(messageBuffer).int
|
||||
messageBuffer = LZ4Factory.fastestInstance().fastDecompressor().decompress(messageBuffer, 4, uncompressedLength)
|
||||
}
|
||||
val messageTypeInfo = messageTypesByProtoMessageType[header.type]
|
||||
NetworkUtils.assertProtocol(messageTypeInfo != null, {"unsupported message type = ${header.type}"})
|
||||
try {
|
||||
val message = messageTypeInfo!!.parseFrom(messageBuffer)
|
||||
return Pair.of(header.type, message)
|
||||
} catch (e: Exception) {
|
||||
when (e) {
|
||||
is IllegalAccessException, is IllegalArgumentException, is InvocationTargetException, is NoSuchMethodException, is SecurityException ->
|
||||
throw IOException(e)
|
||||
else -> throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun sendMessage(message: MessageLite): Future<*> {
|
||||
checkNotClosed()
|
||||
val messageTypeInfo = messageTypesByJavaClass[message.javaClass]
|
||||
messageTypeInfo!!
|
||||
val header = BlockExchangeProtos.Header.newBuilder()
|
||||
.setCompression(BlockExchangeProtos.MessageCompression.NONE)
|
||||
// invert map
|
||||
.setType(messageTypeInfo.protoMessageType)
|
||||
.build()
|
||||
val headerData = header.toByteArray()
|
||||
val messageData = message.toByteArray() //TODO compression
|
||||
return outExecutorService.submit<Any> {
|
||||
try {
|
||||
logger.debug("sending message type = {} {}", header.type, getIdForMessage(message))
|
||||
markActivityOnSocket()
|
||||
outputStream!!.writeShort(headerData.size)
|
||||
outputStream!!.write(headerData)
|
||||
outputStream!!.writeInt(messageData.size)//with compression, check this
|
||||
outputStream!!.write(messageData)
|
||||
outputStream!!.flush()
|
||||
markActivityOnSocket()
|
||||
} catch (ex: IOException) {
|
||||
if (!outExecutorService.isShutdown) {
|
||||
logger.error("error writing to output stream", ex)
|
||||
closeBg()
|
||||
}
|
||||
throw ex
|
||||
}
|
||||
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (!isClosed) {
|
||||
sendMessage(Close.getDefaultInstance())
|
||||
isClosed = true
|
||||
isConnected = false
|
||||
periodicExecutorService.shutdown()
|
||||
outExecutorService.shutdown()
|
||||
inExecutorService.shutdown()
|
||||
messageProcessingService.shutdown()
|
||||
assert(onRequestMessageReceivedListeners.isEmpty())
|
||||
if (outputStream != null) {
|
||||
IOUtils.closeQuietly(outputStream)
|
||||
outputStream = null
|
||||
}
|
||||
if (inputStream != null) {
|
||||
IOUtils.closeQuietly(inputStream)
|
||||
inputStream = null
|
||||
}
|
||||
try {
|
||||
IOUtils.closeQuietly(socket)
|
||||
} catch (ex: Exception) {
|
||||
// ignore this
|
||||
// this can throw an exception if socket was not yet initialized/ set
|
||||
// as Kotlin does an check about this, the closeQuietly does not catch it
|
||||
}
|
||||
logger.info("closed connection {}", address)
|
||||
synchronized(clusterConfigWaitingLock) {
|
||||
clusterConfigWaitingLock.notifyAll()
|
||||
}
|
||||
onConnectionChangedListener(this)
|
||||
try {
|
||||
periodicExecutorService.awaitTermination(2, TimeUnit.SECONDS)
|
||||
outExecutorService.awaitTermination(2, TimeUnit.SECONDS)
|
||||
inExecutorService.awaitTermination(2, TimeUnit.SECONDS)
|
||||
messageProcessingService.awaitTermination(2, TimeUnit.SECONDS)
|
||||
} catch (ex: InterruptedException) {
|
||||
logger.warn("", ex)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* return time elapsed since last activity on socket, inputStream millis
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
fun getLastActive(): Long {
|
||||
return System.currentTimeMillis() - lastActive
|
||||
}
|
||||
|
||||
private fun startMessageListenerService() {
|
||||
inExecutorService.submitLogging {
|
||||
try {
|
||||
while (!Thread.interrupted()) {
|
||||
val message = receiveMessage()
|
||||
messageProcessingService.submitLogging {
|
||||
logger.debug("received message type = {} {}", message.left, getIdForMessage(message.right))
|
||||
when (message.left) {
|
||||
BlockExchangeProtos.MessageType.INDEX -> {
|
||||
val index = message.value as Index
|
||||
indexHandler.handleIndexMessageReceivedEvent(index.folder, index.filesList, this)
|
||||
}
|
||||
BlockExchangeProtos.MessageType.INDEX_UPDATE -> {
|
||||
val update = message.value as IndexUpdate
|
||||
indexHandler.handleIndexMessageReceivedEvent(update.folder, update.filesList, this)
|
||||
}
|
||||
BlockExchangeProtos.MessageType.REQUEST -> {
|
||||
onRequestMessageReceivedListeners.forEach { it(message.value as Request) }
|
||||
}
|
||||
BlockExchangeProtos.MessageType.RESPONSE -> {
|
||||
responseHandler.handleResponse(message.value as Response)
|
||||
}
|
||||
BlockExchangeProtos.MessageType.PING -> logger.debug("ping message received")
|
||||
BlockExchangeProtos.MessageType.CLOSE -> {
|
||||
val close = message.value as BlockExchangeProtos.Close
|
||||
logger.info("received close message, reason=${close.reason}")
|
||||
closeBg()
|
||||
}
|
||||
BlockExchangeProtos.MessageType.CLUSTER_CONFIG -> {
|
||||
NetworkUtils.assertProtocol(clusterConfigInfo == null, {"received cluster config message twice!"})
|
||||
clusterConfigInfo = ClusterConfigInfo()
|
||||
val clusterConfig = message.value as ClusterConfig
|
||||
for (folder in clusterConfig.foldersList ?: emptyList()) {
|
||||
val folderInfo = ClusterConfigFolderInfo(folder.id, folder.label)
|
||||
val devicesById = (folder.devicesList ?: emptyList())
|
||||
.associateBy { input ->
|
||||
DeviceId.fromHashData(input.id!!.toByteArray())
|
||||
}
|
||||
val otherDevice = devicesById[address.deviceId()]
|
||||
val ourDevice = devicesById[configuration.localDeviceId]
|
||||
if (otherDevice != null) {
|
||||
folderInfo.isAnnounced = true
|
||||
}
|
||||
if (ourDevice != null) {
|
||||
folderInfo.isShared = true
|
||||
logger.info("folder shared from device = {} folder = {}", address.deviceId, folderInfo)
|
||||
val folderIds = configuration.folders.map { it.folderId }
|
||||
if (!folderIds.contains(folderInfo.folderId)) {
|
||||
val fi = FolderInfo(folderInfo.folderId, folderInfo.label)
|
||||
configuration.folders = configuration.folders + fi
|
||||
onNewFolderSharedListener(this, fi)
|
||||
logger.info("new folder shared = {}", folderInfo)
|
||||
}
|
||||
} else {
|
||||
logger.info("folder not shared from device = {} folder = {}", address.deviceId, folderInfo)
|
||||
}
|
||||
clusterConfigInfo!!.putFolderInfo(folderInfo)
|
||||
}
|
||||
configuration.persistLater()
|
||||
indexHandler.handleClusterConfigMessageProcessedEvent(clusterConfig)
|
||||
synchronized(clusterConfigWaitingLock) {
|
||||
clusterConfigWaitingLock.notifyAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ex: IOException) {
|
||||
if (inExecutorService.isShutdown) {
|
||||
return@submitLogging
|
||||
}
|
||||
logger.error("error receiving message", ex)
|
||||
closeBg()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "ConnectionHandler{" + "address=" + address + ", lastActive=" + getLastActive() / 1000.0 + "secs ago}"
|
||||
}
|
||||
|
||||
internal inner class ClusterConfigInfo {
|
||||
|
||||
private val folderInfoById = ConcurrentHashMap<String, ClusterConfigFolderInfo>()
|
||||
|
||||
fun getSharedFolders(): Set<String> = folderInfoById.values.filter { it.isShared }.map { it.folderId }.toSet()
|
||||
|
||||
fun putFolderInfo(folderInfo: ClusterConfigFolderInfo) {
|
||||
folderInfoById[folderInfo.folderId] = folderInfo
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun hasFolder(folder: String): Boolean {
|
||||
return clusterConfigInfo!!.getSharedFolders().contains(folder)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val MAGIC = 0x2EA7D90B
|
||||
|
||||
private val messageTypes = listOf(
|
||||
MessageTypeInfo(MessageType.CLOSE, Close::class.java) { Close.parseFrom(it) },
|
||||
MessageTypeInfo(MessageType.CLUSTER_CONFIG, ClusterConfig::class.java) { ClusterConfig.parseFrom(it) },
|
||||
MessageTypeInfo(MessageType.DOWNLOAD_PROGRESS, DownloadProgress::class.java) { DownloadProgress.parseFrom(it) },
|
||||
MessageTypeInfo(MessageType.INDEX, Index::class.java) { Index.parseFrom(it) },
|
||||
MessageTypeInfo(MessageType.INDEX_UPDATE, IndexUpdate::class.java) { IndexUpdate.parseFrom(it) },
|
||||
MessageTypeInfo(MessageType.PING, Ping::class.java) { Ping.parseFrom(it) },
|
||||
MessageTypeInfo(MessageType.REQUEST, Request::class.java) { Request.parseFrom(it) },
|
||||
MessageTypeInfo(MessageType.RESPONSE, Response::class.java) { Response.parseFrom(it) }
|
||||
)
|
||||
|
||||
private val messageTypesByProtoMessageType = messageTypes.map { it.protoMessageType to it }.toMap()
|
||||
private val messageTypesByJavaClass = messageTypes.map { it.javaClass to it }.toMap()
|
||||
|
||||
/**
|
||||
* get id for message bean/instance, for log tracking
|
||||
*
|
||||
* @param message
|
||||
* @return id for message bean
|
||||
*/
|
||||
private fun getIdForMessage(message: MessageLite): String {
|
||||
return when (message) {
|
||||
is Request -> Integer.toString(message.id)
|
||||
is Response -> Integer.toString(message.id)
|
||||
else -> Integer.toString(Math.abs(message.hashCode()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class MessageTypeInfo(
|
||||
val protoMessageType: MessageType,
|
||||
val javaClass: Class<out MessageLite>,
|
||||
val parseFrom: (data: ByteArray) -> MessageLite
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (C) 2016 Davide Imbriaco
|
||||
*
|
||||
* This Java file is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package net.syncthing.java.bep
|
||||
|
||||
import net.syncthing.java.core.beans.FolderInfo
|
||||
import net.syncthing.java.core.beans.FolderStats
|
||||
import net.syncthing.java.core.interfaces.IndexRepository
|
||||
import java.io.Closeable
|
||||
|
||||
class FolderBrowser internal constructor(private val indexHandler: IndexHandler) : Closeable {
|
||||
private val folderStatsCache = mutableMapOf<String, FolderStats>()
|
||||
private val indexRepositoryEventListener = { event: IndexRepository.FolderStatsUpdatedEvent ->
|
||||
addFolderStats(event.getFolderStats())
|
||||
}
|
||||
|
||||
fun folderInfoAndStatsList(): List<Pair<FolderInfo, FolderStats>> =
|
||||
indexHandler.folderInfoList()
|
||||
.map { folderInfo -> Pair(folderInfo, getFolderStats(folderInfo.folderId)) }
|
||||
.sortedBy { it.first.label }
|
||||
|
||||
init {
|
||||
indexHandler.indexRepository.setOnFolderStatsUpdatedListener(indexRepositoryEventListener)
|
||||
addFolderStats(indexHandler.indexRepository.findAllFolderStats())
|
||||
}
|
||||
|
||||
private fun addFolderStats(folderStatsList: List<FolderStats>) {
|
||||
for (folderStats in folderStatsList) {
|
||||
folderStatsCache.put(folderStats.folderId, folderStats)
|
||||
}
|
||||
}
|
||||
|
||||
fun getFolderStats(folder: String): FolderStats {
|
||||
return folderStatsCache[folder] ?: let {
|
||||
FolderStats.Builder()
|
||||
.setFolder(folder)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
fun getFolderInfo(folder: String): FolderInfo? {
|
||||
return indexHandler.getFolderInfo(folder)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
indexHandler.indexRepository.setOnFolderStatsUpdatedListener(null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* Copyright (C) 2016 Davide Imbriaco
|
||||
*
|
||||
* This Java file is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package net.syncthing.java.bep
|
||||
|
||||
import net.syncthing.java.core.beans.FileInfo
|
||||
import net.syncthing.java.core.interfaces.IndexRepository
|
||||
import net.syncthing.java.core.utils.PathUtils
|
||||
import net.syncthing.java.core.utils.awaitTerminationSafe
|
||||
import net.syncthing.java.core.utils.submitLogging
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.io.Closeable
|
||||
import java.util.*
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class IndexBrowser internal constructor(private val indexRepository: IndexRepository, private val indexHandler: IndexHandler,
|
||||
val folder: String, private val includeParentInList: Boolean = false,
|
||||
private val allowParentInRoot: Boolean = false, ordering: Comparator<FileInfo>?) : Closeable {
|
||||
|
||||
private fun isParent(fileInfo: FileInfo) = PathUtils.isParent(fileInfo.path)
|
||||
|
||||
val ALPHA_ASC_DIR_FIRST: Comparator<FileInfo> =
|
||||
compareBy<FileInfo>({!isParent(it)}, {!it.isDirectory()})
|
||||
.thenBy { it.fileName.toLowerCase() }
|
||||
val LAST_MOD_DESC: Comparator<FileInfo> =
|
||||
compareBy<FileInfo>({!isParent(it)}, {it.lastModified})
|
||||
.thenBy { it.fileName.toLowerCase() }
|
||||
|
||||
private val ordering = ordering ?: ALPHA_ASC_DIR_FIRST
|
||||
private val logger = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
var currentPath: String = PathUtils.ROOT_PATH
|
||||
private set
|
||||
private val PARENT_FILE_INFO: FileInfo
|
||||
private val ROOT_FILE_INFO: FileInfo
|
||||
private val executorService = Executors.newSingleThreadScheduledExecutor()
|
||||
private val preloadJobs = mutableSetOf<String>()
|
||||
private val preloadJobsLock = Any()
|
||||
private var mOnPathChangedListener: (() -> Unit)? = null
|
||||
|
||||
private fun isCacheReady(): Boolean {
|
||||
synchronized(preloadJobsLock) {
|
||||
return preloadJobs.isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun onIndexChangedevent(folder: String, newRecord: FileInfo) {
|
||||
if (folder == this.folder) {
|
||||
preloadFileInfoForCurrentPath()
|
||||
}
|
||||
}
|
||||
|
||||
fun currentPathInfo(): FileInfo = getFileInfoByAbsolutePath(currentPath)
|
||||
|
||||
fun currentPathFileName(): String? = PathUtils.getFileName(currentPath)
|
||||
|
||||
fun isRoot(): Boolean = PathUtils.isRoot(currentPath)
|
||||
|
||||
init {
|
||||
assert(folder.isNotEmpty())
|
||||
PARENT_FILE_INFO = FileInfo(folder = folder, type = FileInfo.FileType.DIRECTORY, path = PathUtils.PARENT_PATH)
|
||||
ROOT_FILE_INFO = FileInfo(folder = folder, type = FileInfo.FileType.DIRECTORY, path = PathUtils.ROOT_PATH)
|
||||
navigateToAbsolutePath(PathUtils.ROOT_PATH)
|
||||
}
|
||||
|
||||
fun setOnFolderChangedListener(onPathChangedListener: (() -> Unit)?) {
|
||||
mOnPathChangedListener = onPathChangedListener
|
||||
}
|
||||
|
||||
private fun preloadFileInfoForCurrentPath() {
|
||||
logger.debug("trigger preload for folder = '{}'", folder)
|
||||
synchronized(preloadJobsLock) {
|
||||
currentPath.let<String, Any> { currentPath ->
|
||||
if (preloadJobs.contains(currentPath)) {
|
||||
preloadJobs.remove(currentPath)
|
||||
preloadJobs.add(currentPath) ///add last
|
||||
} else {
|
||||
preloadJobs.add(currentPath)
|
||||
executorService.submitLogging(object : Runnable {
|
||||
|
||||
override fun run() {
|
||||
|
||||
val preloadPath =
|
||||
synchronized(preloadJobsLock) {
|
||||
assert(!preloadJobs.isEmpty())
|
||||
preloadJobs.last() //pop last job
|
||||
}
|
||||
|
||||
logger.info("folder preload BEGIN for folder = '{}' path = '{}'", folder, preloadPath)
|
||||
getFileInfoByAbsolutePath(preloadPath)
|
||||
if (!PathUtils.isRoot(preloadPath)) {
|
||||
val parent = PathUtils.getParentPath(preloadPath)
|
||||
getFileInfoByAbsolutePath(parent)
|
||||
listFiles(parent)
|
||||
}
|
||||
for (record in listFiles(preloadPath)) {
|
||||
if (record.path == PARENT_FILE_INFO.path && record.isDirectory()) {
|
||||
listFiles(record.path)
|
||||
}
|
||||
}
|
||||
logger.info("folder preload END for folder = '{}' path = '{}'", folder, preloadPath)
|
||||
synchronized(preloadJobsLock) {
|
||||
preloadJobs.remove(preloadPath)
|
||||
if (isCacheReady()) {
|
||||
logger.info("cache ready, notify listeners")
|
||||
mOnPathChangedListener?.invoke()
|
||||
} else {
|
||||
logger.info("still {} job[s] left in cache loader", preloadJobs.size)
|
||||
executorService.submitLogging(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun listFiles(path: String = currentPath): List<FileInfo> {
|
||||
logger.debug("doListFiles for path = '{}' BEGIN", path)
|
||||
val list = ArrayList(indexRepository.findNotDeletedFilesByFolderAndParent(folder, path))
|
||||
logger.debug("doListFiles for path = '{}' : {} records loaded)", path, list.size)
|
||||
if (includeParentInList && (!PathUtils.isRoot(path) || allowParentInRoot)) {
|
||||
list.add(0, PARENT_FILE_INFO)
|
||||
}
|
||||
return list.sortedWith(ordering)
|
||||
}
|
||||
|
||||
fun getFileInfoByAbsolutePath(path: String): FileInfo {
|
||||
return if (PathUtils.isRoot(path)) {
|
||||
ROOT_FILE_INFO
|
||||
} else {
|
||||
logger.debug("doGetFileInfoByAbsolutePath for path = '{}' BEGIN", path)
|
||||
val fileInfo = indexRepository.findNotDeletedFileInfo(folder, path) ?: error("file not found for path = $path")
|
||||
logger.debug("doGetFileInfoByAbsolutePath for path = '{}' END", path)
|
||||
fileInfo
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateTo(fileInfo: FileInfo) {
|
||||
assert(fileInfo.isDirectory())
|
||||
assert(fileInfo.folder == folder)
|
||||
return if (fileInfo.path == PARENT_FILE_INFO.path)
|
||||
navigateToAbsolutePath(PathUtils.getParentPath(currentPath))
|
||||
else
|
||||
navigateToAbsolutePath(fileInfo.path)
|
||||
}
|
||||
|
||||
fun navigateToNearestPath(oldPath: String) {
|
||||
if (!StringUtils.isBlank(oldPath)) {
|
||||
navigateToAbsolutePath(oldPath)
|
||||
}
|
||||
}
|
||||
|
||||
private fun navigateToAbsolutePath(newPath: String) {
|
||||
if (PathUtils.isRoot(newPath)) {
|
||||
currentPath = PathUtils.ROOT_PATH
|
||||
} else {
|
||||
val fileInfo = getFileInfoByAbsolutePath(newPath)
|
||||
assert(fileInfo.isDirectory(), {"cannot navigate to path ${fileInfo.path}: not a directory"})
|
||||
currentPath = fileInfo.path
|
||||
}
|
||||
logger.info("navigate to path = '{}'", currentPath)
|
||||
preloadFileInfoForCurrentPath()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
logger.info("closing")
|
||||
indexHandler.unregisterIndexBrowser(this)
|
||||
executorService.shutdown()
|
||||
executorService.awaitTerminationSafe()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
/*
|
||||
* Copyright (C) 2016 Davide Imbriaco
|
||||
*
|
||||
* This Java file is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package net.syncthing.java.bep
|
||||
|
||||
import net.syncthing.java.core.beans.*
|
||||
import net.syncthing.java.core.beans.FileInfo.Version
|
||||
import net.syncthing.java.core.configuration.Configuration
|
||||
import net.syncthing.java.core.interfaces.IndexRepository
|
||||
import net.syncthing.java.core.interfaces.Sequencer
|
||||
import net.syncthing.java.core.interfaces.TempRepository
|
||||
import net.syncthing.java.core.utils.BlockUtils
|
||||
import net.syncthing.java.core.utils.NetworkUtils
|
||||
import net.syncthing.java.core.utils.awaitTerminationSafe
|
||||
import net.syncthing.java.core.utils.submitLogging
|
||||
import org.apache.commons.lang3.tuple.Pair
|
||||
import org.apache.http.util.TextUtils
|
||||
import org.bouncycastle.util.encoders.Hex
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.io.Closeable
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class IndexHandler(private val configuration: Configuration, val indexRepository: IndexRepository,
|
||||
private val tempRepository: TempRepository) : Closeable {
|
||||
private val logger = LoggerFactory.getLogger(javaClass)
|
||||
private val folderInfoByFolder = mutableMapOf<String, FolderInfo>()
|
||||
private val indexMessageProcessor = IndexMessageProcessor()
|
||||
private var lastIndexActivity: Long = 0
|
||||
private val writeAccessLock = Object()
|
||||
private val indexWaitLock = Object()
|
||||
private val indexBrowsers = mutableSetOf<IndexBrowser>()
|
||||
private val onIndexRecordAcquiredListeners = mutableSetOf<(FolderInfo, List<FileInfo>, IndexInfo) -> Unit>()
|
||||
private val onFullIndexAcquiredListeners = mutableSetOf<(FolderInfo) -> Unit>()
|
||||
|
||||
private fun lastActive(): Long = System.currentTimeMillis() - lastIndexActivity
|
||||
|
||||
fun sequencer(): Sequencer = indexRepository.getSequencer()
|
||||
|
||||
fun folderList(): List<String> = folderInfoByFolder.keys.toList()
|
||||
|
||||
fun folderInfoList(): List<FolderInfo> = folderInfoByFolder.values.toList()
|
||||
|
||||
private fun markActive() {
|
||||
lastIndexActivity = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
fun registerOnIndexRecordAcquiredListener(listener: (FolderInfo, List<FileInfo>, IndexInfo) -> Unit) {
|
||||
onIndexRecordAcquiredListeners.add(listener)
|
||||
}
|
||||
|
||||
fun unregisterOnIndexRecordAcquiredListener(listener: (FolderInfo, List<FileInfo>, IndexInfo) -> Unit) {
|
||||
assert(onIndexRecordAcquiredListeners.contains(listener))
|
||||
onIndexRecordAcquiredListeners.remove(listener)
|
||||
}
|
||||
|
||||
fun registerOnFullIndexAcquiredListenersListener(listener: (FolderInfo) -> Unit) {
|
||||
onFullIndexAcquiredListeners.add(listener)
|
||||
}
|
||||
|
||||
fun unregisterOnFullIndexAcquiredListenersListener(listener: (FolderInfo) -> Unit) {
|
||||
assert(onFullIndexAcquiredListeners.contains(listener))
|
||||
onFullIndexAcquiredListeners.remove(listener)
|
||||
}
|
||||
|
||||
init {
|
||||
loadFolderInfoFromConfig()
|
||||
}
|
||||
|
||||
private fun loadFolderInfoFromConfig() {
|
||||
synchronized(writeAccessLock) {
|
||||
for (folderInfo in configuration.folders) {
|
||||
folderInfoByFolder.put(folderInfo.folderId, folderInfo) //TODO reference 'folder info' repository
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun clearIndex() {
|
||||
synchronized(writeAccessLock) {
|
||||
indexRepository.clearIndex()
|
||||
folderInfoByFolder.clear()
|
||||
loadFolderInfoFromConfig()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun isRemoteIndexAcquired(clusterConfigInfo: ConnectionHandler.ClusterConfigInfo, peerDeviceId: DeviceId): Boolean {
|
||||
var ready = true
|
||||
for (folder in clusterConfigInfo.getSharedFolders()) {
|
||||
val indexSequenceInfo = indexRepository.findIndexInfoByDeviceAndFolder(peerDeviceId, folder)
|
||||
if (indexSequenceInfo == null || indexSequenceInfo.localSequence < indexSequenceInfo.maxSequence) {
|
||||
logger.debug("waiting for index on folder = {} sequenceInfo = {}", folder, indexSequenceInfo)
|
||||
ready = false
|
||||
}
|
||||
}
|
||||
return ready
|
||||
}
|
||||
|
||||
@Throws(InterruptedException::class)
|
||||
fun waitForRemoteIndexAcquired(connectionHandler: ConnectionHandler, timeoutSecs: Long? = null): IndexHandler {
|
||||
val timeoutMillis = (timeoutSecs ?: DEFAULT_INDEX_TIMEOUT) * 1000
|
||||
synchronized(indexWaitLock) {
|
||||
while (!isRemoteIndexAcquired(connectionHandler.clusterConfigInfo!!, connectionHandler.deviceId())) {
|
||||
indexWaitLock.wait(timeoutMillis)
|
||||
NetworkUtils.assertProtocol(connectionHandler.getLastActive() < timeoutMillis || lastActive() < timeoutMillis,
|
||||
{"unable to acquire index from connection $connectionHandler, timeout reached!"})
|
||||
}
|
||||
}
|
||||
logger.debug("acquired all indexes on connection {}", connectionHandler)
|
||||
return this
|
||||
}
|
||||
|
||||
fun handleClusterConfigMessageProcessedEvent(clusterConfig: BlockExchangeProtos.ClusterConfig) {
|
||||
synchronized(writeAccessLock) {
|
||||
for (folderRecord in clusterConfig.foldersList) {
|
||||
val folder = folderRecord.id
|
||||
val folderInfo = updateFolderInfo(folder, folderRecord.label)
|
||||
logger.debug("acquired folder info from cluster config = {}", folderInfo)
|
||||
for (deviceRecord in folderRecord.devicesList) {
|
||||
val deviceId = DeviceId.fromHashData(deviceRecord.id.toByteArray())
|
||||
if (deviceRecord.hasIndexId() && deviceRecord.hasMaxSequence()) {
|
||||
val folderIndexInfo = updateIndexInfo(folder, deviceId, deviceRecord.indexId, deviceRecord.maxSequence, null)
|
||||
logger.debug("acquired folder index info from cluster config = {}", folderIndexInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleIndexMessageReceivedEvent(folderId: String, filesList: List<BlockExchangeProtos.FileInfo>, connectionHandler: ConnectionHandler) {
|
||||
indexMessageProcessor.handleIndexMessageReceivedEvent(folderId, filesList, connectionHandler)
|
||||
}
|
||||
|
||||
fun pushRecord(folder: String, bepFileInfo: BlockExchangeProtos.FileInfo): FileInfo? {
|
||||
var fileBlocks: FileBlocks? = null
|
||||
val builder = FileInfo.Builder()
|
||||
.setFolder(folder)
|
||||
.setPath(bepFileInfo.name)
|
||||
.setLastModified(Date(bepFileInfo.modifiedS * 1000 + bepFileInfo.modifiedNs / 1000000))
|
||||
.setVersionList((if (bepFileInfo.hasVersion()) bepFileInfo.version.countersList else null ?: emptyList()).map { record -> Version(record.id, record.value) })
|
||||
.setDeleted(bepFileInfo.deleted)
|
||||
when (bepFileInfo.type) {
|
||||
BlockExchangeProtos.FileInfoType.FILE -> {
|
||||
fileBlocks = FileBlocks(folder, builder.getPath()!!, ((bepFileInfo.blocksList ?: emptyList())).map { record ->
|
||||
BlockInfo(record.offset, record.size, Hex.toHexString(record.hash.toByteArray()))
|
||||
})
|
||||
builder
|
||||
.setTypeFile()
|
||||
.setHash(fileBlocks.hash)
|
||||
.setSize(bepFileInfo.size)
|
||||
}
|
||||
BlockExchangeProtos.FileInfoType.DIRECTORY -> builder.setTypeDir()
|
||||
else -> {
|
||||
logger.warn("unsupported file type = {}, discarding file info", bepFileInfo.type)
|
||||
return null
|
||||
}
|
||||
}
|
||||
return addRecord(builder.build(), fileBlocks)
|
||||
}
|
||||
|
||||
private fun updateIndexInfo(folder: String, deviceId: DeviceId, indexId: Long?, maxSequence: Long?, localSequence: Long?): IndexInfo {
|
||||
synchronized(writeAccessLock) {
|
||||
var indexSequenceInfo = indexRepository.findIndexInfoByDeviceAndFolder(deviceId, folder)
|
||||
var shouldUpdate = false
|
||||
val builder: IndexInfo.Builder
|
||||
if (indexSequenceInfo == null) {
|
||||
shouldUpdate = true
|
||||
assert(indexId != null, {"index sequence info not found, and supplied null index id (folder = $folder, device = $deviceId)"})
|
||||
builder = IndexInfo.newBuilder()
|
||||
.setFolder(folder)
|
||||
.setDeviceId(deviceId.deviceId)
|
||||
.setIndexId(indexId!!)
|
||||
.setLocalSequence(0)
|
||||
.setMaxSequence(-1)
|
||||
} else {
|
||||
builder = indexSequenceInfo.copyBuilder()
|
||||
}
|
||||
if (indexId != null && indexId != builder.getIndexId()) {
|
||||
shouldUpdate = true
|
||||
builder.setIndexId(indexId)
|
||||
}
|
||||
if (maxSequence != null && maxSequence > builder.getMaxSequence()) {
|
||||
shouldUpdate = true
|
||||
builder.setMaxSequence(maxSequence)
|
||||
}
|
||||
if (localSequence != null && localSequence > builder.getLocalSequence()) {
|
||||
shouldUpdate = true
|
||||
builder.setLocalSequence(localSequence)
|
||||
}
|
||||
if (shouldUpdate) {
|
||||
indexSequenceInfo = builder.build()
|
||||
indexRepository.updateIndexInfo(indexSequenceInfo)
|
||||
}
|
||||
return indexSequenceInfo!!
|
||||
}
|
||||
}
|
||||
|
||||
private fun addRecord(record: FileInfo, fileBlocks: FileBlocks?): FileInfo? {
|
||||
synchronized(writeAccessLock) {
|
||||
val lastModified = indexRepository.findFileInfoLastModified(record.folder, record.path)
|
||||
return if (lastModified != null && !record.lastModified.after(lastModified)) {
|
||||
logger.trace("discarding record = {}, modified before local record", record)
|
||||
null
|
||||
} else {
|
||||
indexRepository.updateFileInfo(record, fileBlocks)
|
||||
logger.trace("loaded new record = {}", record)
|
||||
indexBrowsers.forEach {
|
||||
it.onIndexChangedevent(record.folder, record)
|
||||
}
|
||||
record
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getFileInfoByPath(folder: String, path: String): FileInfo? {
|
||||
return indexRepository.findFileInfo(folder, path)
|
||||
}
|
||||
|
||||
fun getFileInfoAndBlocksByPath(folder: String, path: String): Pair<FileInfo, FileBlocks>? {
|
||||
val fileInfo = getFileInfoByPath(folder, path)
|
||||
return if (fileInfo == null) {
|
||||
null
|
||||
} else {
|
||||
assert(fileInfo.isFile())
|
||||
val fileBlocks = indexRepository.findFileBlocks(folder, path)
|
||||
checkNotNull(fileBlocks, {"file blocks not found for file info = $fileInfo"})
|
||||
|
||||
FileInfo.checkBlocks(fileInfo, fileBlocks!!)
|
||||
|
||||
Pair.of(fileInfo, fileBlocks)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateFolderInfo(folder: String, label: String?): FolderInfo {
|
||||
var folderInfo: FolderInfo? = folderInfoByFolder[folder]
|
||||
if (folderInfo == null || !TextUtils.isEmpty(label)) {
|
||||
folderInfo = FolderInfo(folder, label)
|
||||
folderInfoByFolder.put(folderInfo.folderId, folderInfo)
|
||||
}
|
||||
return folderInfo
|
||||
}
|
||||
|
||||
fun getFolderInfo(folder: String): FolderInfo? {
|
||||
return folderInfoByFolder[folder]
|
||||
}
|
||||
|
||||
fun getIndexInfo(device: DeviceId, folder: String): IndexInfo? {
|
||||
return indexRepository.findIndexInfoByDeviceAndFolder(device, folder)
|
||||
}
|
||||
|
||||
fun newFolderBrowser(): FolderBrowser {
|
||||
return FolderBrowser(this)
|
||||
}
|
||||
|
||||
fun newIndexBrowser(folder: String, includeParentInList: Boolean = false, allowParentInRoot: Boolean = false,
|
||||
ordering: Comparator<FileInfo>? = null): IndexBrowser {
|
||||
val indexBrowser = IndexBrowser(indexRepository, this, folder, includeParentInList, allowParentInRoot, ordering)
|
||||
indexBrowsers.add(indexBrowser)
|
||||
return indexBrowser
|
||||
}
|
||||
|
||||
internal fun unregisterIndexBrowser(indexBrowser: IndexBrowser) {
|
||||
assert(indexBrowsers.contains(indexBrowser))
|
||||
indexBrowsers.remove(indexBrowser)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
assert(indexBrowsers.isEmpty())
|
||||
assert(onIndexRecordAcquiredListeners.isEmpty())
|
||||
assert(onFullIndexAcquiredListeners.isEmpty())
|
||||
indexMessageProcessor.stop()
|
||||
}
|
||||
|
||||
private inner class IndexMessageProcessor {
|
||||
|
||||
private val executorService = Executors.newSingleThreadExecutor()
|
||||
private var queuedMessages = 0
|
||||
private var queuedRecords: Long = 0
|
||||
// private long lastRecordProcessingTime = 0;
|
||||
// , delay = 0;
|
||||
// private boolean addProcessingDelayForInterface = true;
|
||||
// private final int MIN_DELAY = 0, MAX_DELAY = 5000, MAX_RECORD_PER_PROCESS = 16, DELAY_FACTOR = 1;
|
||||
private var startTime: Long? = null
|
||||
|
||||
fun handleIndexMessageReceivedEvent(folderId: String, filesList: List<BlockExchangeProtos.FileInfo>, connectionHandler: ConnectionHandler) {
|
||||
logger.info("received index message event, preparing (queued records = {} event record count = {})", queuedRecords, filesList.size)
|
||||
markActive()
|
||||
val clusterConfigInfo = connectionHandler.clusterConfigInfo
|
||||
val peerDeviceId = connectionHandler.deviceId()
|
||||
// List<BlockExchangeProtos.FileInfo> fileList = event.getFilesList();
|
||||
// for (int index = 0; index < fileList.size(); index += MAX_RECORD_PER_PROCESS) {
|
||||
// BlockExchangeProtos.IndexUpdate data = BlockExchangeProtos.IndexUpdate.newBuilder()
|
||||
// .addAllFiles(Iterables.limit(Iterables.skip(fileList, index), MAX_RECORD_PER_PROCESS))
|
||||
// .setFolder(event.getFolder())
|
||||
// .build();
|
||||
// if (queuedMessages > 0) {
|
||||
// storeAndProcessBg(data, clusterConfigInfo, peerDeviceId);
|
||||
// } else {
|
||||
// processBg(data, clusterConfigInfo, peerDeviceId);
|
||||
// }
|
||||
// }
|
||||
val data = BlockExchangeProtos.IndexUpdate.newBuilder()
|
||||
.addAllFiles(filesList)
|
||||
.setFolder(folderId)
|
||||
.build()
|
||||
if (queuedMessages > 0) {
|
||||
storeAndProcessBg(data, clusterConfigInfo, peerDeviceId)
|
||||
} else {
|
||||
processBg(data, clusterConfigInfo, peerDeviceId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun processBg(data: BlockExchangeProtos.IndexUpdate, clusterConfigInfo: ConnectionHandler.ClusterConfigInfo?, peerDeviceId: DeviceId) {
|
||||
logger.debug("received index message event, queuing for processing")
|
||||
queuedMessages++
|
||||
queuedRecords += data.filesCount.toLong()
|
||||
executorService.submitLogging(object : ProcessingRunnable() {
|
||||
override fun runProcess() {
|
||||
doHandleIndexMessageReceivedEvent(data, clusterConfigInfo, peerDeviceId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun storeAndProcessBg(data: BlockExchangeProtos.IndexUpdate, clusterConfigInfo: ConnectionHandler.ClusterConfigInfo?, peerDeviceId: DeviceId) {
|
||||
val key = tempRepository.pushTempData(data.toByteArray())
|
||||
logger.debug("received index message event, stored to temp record {}, queuing for processing", key)
|
||||
queuedMessages++
|
||||
queuedRecords += data.filesCount.toLong()
|
||||
executorService.submitLogging(object : ProcessingRunnable() {
|
||||
override fun runProcess() {
|
||||
try {
|
||||
doHandleIndexMessageReceivedEvent(key, clusterConfigInfo, peerDeviceId)
|
||||
} catch (ex: IOException) {
|
||||
logger.error("error processing index message", ex)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
private abstract inner class ProcessingRunnable : Runnable {
|
||||
|
||||
override fun run() {
|
||||
startTime = System.currentTimeMillis()
|
||||
runProcess()
|
||||
queuedMessages--
|
||||
// lastRecordProcessingTime = stopwatch.elapsed(TimeUnit.MILLISECONDS) - delay;
|
||||
// logger.info("processed a bunch of records, {}*{} remaining", queuedMessages, MAX_RECORD_PER_PROCESS);
|
||||
// logger.debug("processed index message in {} secs", lastRecordProcessingTime / 1000d);
|
||||
startTime = null
|
||||
}
|
||||
|
||||
protected abstract fun runProcess()
|
||||
|
||||
// private boolean isVersionOlderThanSequence(BlockExchangeProtos.FileInfo fileInfo, long localSequence) {
|
||||
// long fileSequence = fileInfo.getSequence();
|
||||
// //TODO should we check last version instead of sequence? verify
|
||||
// return fileSequence < localSequence;
|
||||
// }
|
||||
@Throws(IOException::class)
|
||||
protected fun doHandleIndexMessageReceivedEvent(key: String, clusterConfigInfo: ConnectionHandler.ClusterConfigInfo?, peerDeviceId: DeviceId) {
|
||||
logger.debug("processing index message event from temp record {}", key)
|
||||
markActive()
|
||||
val data = tempRepository.popTempData(key)
|
||||
val message = BlockExchangeProtos.IndexUpdate.parseFrom(data)
|
||||
doHandleIndexMessageReceivedEvent(message, clusterConfigInfo, peerDeviceId)
|
||||
}
|
||||
|
||||
protected fun doHandleIndexMessageReceivedEvent(message: BlockExchangeProtos.IndexUpdate, clusterConfigInfo: ConnectionHandler.ClusterConfigInfo?, peerDeviceId: DeviceId) {
|
||||
// synchronized (writeAccessLock) {
|
||||
// if (addProcessingDelayForInterface) {
|
||||
// delay = Math.min(MAX_DELAY, Math.max(MIN_DELAY, lastRecordProcessingTime * DELAY_FACTOR));
|
||||
// logger.info("add delay of {} secs before processing index message (to allow UI to process)", delay / 1000d);
|
||||
// try {
|
||||
// Thread.sleep(delay);
|
||||
// } catch (InterruptedException ex) {
|
||||
// logger.warn("interrupted", ex);
|
||||
// }
|
||||
// } else {
|
||||
// delay = 0;
|
||||
// }
|
||||
logger.info("processing index message with {} records (queue size: messages = {} records = {})", message.filesCount, queuedMessages, queuedRecords)
|
||||
// String deviceId = connectionHandler.getDeviceId();
|
||||
val folderId = message.folder
|
||||
var sequence: Long = -1
|
||||
val newRecords = mutableListOf<FileInfo>()
|
||||
// IndexInfo oldIndexInfo = indexRepository.findIndexInfoByDeviceAndFolder(deviceId, folder);
|
||||
// Stopwatch stopwatch = Stopwatch.createStarted();
|
||||
logger.debug("processing {} index records for folder {}", message.filesList.size, folderId)
|
||||
for (fileInfo in message.filesList) {
|
||||
markActive()
|
||||
// if (oldIndexInfo != null && isVersionOlderThanSequence(fileInfo, oldIndexInfo.getLocalSequence())) {
|
||||
// logger.trace("skipping file {}, version older than sequence {}", fileInfo, oldIndexInfo.getLocalSequence());
|
||||
// } else {
|
||||
val newRecord = pushRecord(folderId, fileInfo)
|
||||
if (newRecord != null) {
|
||||
newRecords.add(newRecord)
|
||||
}
|
||||
sequence = Math.max(fileInfo.sequence, sequence)
|
||||
markActive()
|
||||
// }
|
||||
}
|
||||
val newIndexInfo = updateIndexInfo(folderId, peerDeviceId, null, null, sequence)
|
||||
val elap = System.currentTimeMillis() - startTime!!
|
||||
queuedRecords -= message.filesCount.toLong()
|
||||
logger.info("processed {} index records, acquired {} ({} secs, {} record/sec)", message.filesCount, newRecords.size, elap / 1000.0, Math.round(message.filesCount / (elap / 1000.0) * 100) / 100.0)
|
||||
if (logger.isInfoEnabled && newRecords.size <= 10) {
|
||||
for (fileInfo in newRecords) {
|
||||
logger.info("acquired record = {}", fileInfo)
|
||||
}
|
||||
}
|
||||
val folderInfo = folderInfoByFolder[folderId]
|
||||
if (!newRecords.isEmpty()) {
|
||||
onIndexRecordAcquiredListeners.forEach { it(folderInfo!!, newRecords, newIndexInfo) }
|
||||
}
|
||||
logger.debug("index info = {}", newIndexInfo)
|
||||
if (isRemoteIndexAcquired(clusterConfigInfo!!, peerDeviceId)) {
|
||||
logger.debug("index acquired")
|
||||
onFullIndexAcquiredListeners.forEach { it(folderInfo!!)}
|
||||
}
|
||||
// IndexHandler.this.notifyAll();
|
||||
markActive()
|
||||
synchronized(indexWaitLock) {
|
||||
indexWaitLock.notifyAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
logger.info("stopping index record processor")
|
||||
executorService.shutdown()
|
||||
executorService.awaitTerminationSafe()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val DEFAULT_INDEX_TIMEOUT: Long = 30
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (C) 2018 Jonas Lochmann
|
||||
*
|
||||
* This Java file is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package net.syncthing.java.bep
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.util.*
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.collections.HashMap
|
||||
|
||||
class ResponseHandler {
|
||||
companion object {
|
||||
private val logger = LoggerFactory.getLogger(ResponseHandler::class.java)
|
||||
}
|
||||
|
||||
private val responseListeners = Collections.synchronizedMap(HashMap<Int, (BlockExchangeProtos.Response) -> Unit>())
|
||||
private val nextRequestId = AtomicInteger(0)
|
||||
|
||||
fun registerListener(listener: (BlockExchangeProtos.Response) -> Unit): Int {
|
||||
val requestId = nextRequestId.getAndIncrement()
|
||||
|
||||
responseListeners[requestId] = listener
|
||||
|
||||
return requestId
|
||||
}
|
||||
|
||||
fun unregisterListener(requestId: Int) {
|
||||
responseListeners.remove(requestId)
|
||||
}
|
||||
|
||||
fun handleResponse(response: BlockExchangeProtos.Response) {
|
||||
val listener = responseListeners.remove(response.id)
|
||||
|
||||
if (listener != null) {
|
||||
listener(response)
|
||||
} else {
|
||||
logger.warn("received response for {} without associated handler", response.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package net.syncthing.java.bep.utils
|
||||
|
||||
inline fun <T> Iterable<T>.longSumBy(selector: (T) -> Long): Long {
|
||||
var sum = 0L
|
||||
|
||||
this.forEach {
|
||||
sum += selector(it)
|
||||
}
|
||||
|
||||
return sum
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package net.syncthing.java.bep;
|
||||
|
||||
option optimize_for = LITE_RUNTIME;
|
||||
|
||||
import "blockExchangeProtos.proto";
|
||||
|
||||
message Blocks {
|
||||
repeated BlockInfo blocks = 1;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package net.syncthing.java.bep;
|
||||
|
||||
option optimize_for = LITE_RUNTIME;
|
||||
|
||||
message Hello {
|
||||
optional string device_name = 1;
|
||||
optional string client_name = 2;
|
||||
optional string client_version = 3;
|
||||
}
|
||||
|
||||
message Header {
|
||||
optional MessageType type = 1;
|
||||
optional MessageCompression compression = 2;
|
||||
}
|
||||
|
||||
enum MessageType {
|
||||
CLUSTER_CONFIG = 0;
|
||||
INDEX = 1;
|
||||
INDEX_UPDATE = 2;
|
||||
REQUEST = 3;
|
||||
RESPONSE = 4;
|
||||
DOWNLOAD_PROGRESS = 5;
|
||||
PING = 6;
|
||||
CLOSE = 7;
|
||||
}
|
||||
|
||||
enum MessageCompression {
|
||||
NONE = 0;
|
||||
LZ4 = 1;
|
||||
}
|
||||
|
||||
message ClusterConfig {
|
||||
repeated Folder folders = 1;
|
||||
}
|
||||
|
||||
message Folder {
|
||||
optional string id = 1;
|
||||
optional string label = 2;
|
||||
optional bool read_only = 3;
|
||||
optional bool ignore_permissions = 4;
|
||||
optional bool ignore_delete = 5;
|
||||
optional bool disable_temp_indexes = 6;
|
||||
|
||||
repeated Device devices = 16;
|
||||
}
|
||||
|
||||
message Device {
|
||||
optional bytes id = 1;
|
||||
optional string name = 2;
|
||||
repeated string addresses = 3;
|
||||
optional Compression compression = 4;
|
||||
optional string cert_name = 5;
|
||||
optional int64 max_sequence = 6;
|
||||
optional bool introducer = 7;
|
||||
optional uint64 index_id = 8;
|
||||
}
|
||||
|
||||
enum Compression {
|
||||
METADATA = 0;
|
||||
NEVER = 1;
|
||||
ALWAYS = 2;
|
||||
}
|
||||
|
||||
message Index {
|
||||
optional string folder = 1;
|
||||
repeated FileInfo files = 2;
|
||||
}
|
||||
|
||||
message IndexUpdate {
|
||||
optional string folder = 1;
|
||||
repeated FileInfo files = 2;
|
||||
}
|
||||
|
||||
message FileInfo {
|
||||
optional string name = 1;
|
||||
optional FileInfoType type = 2;
|
||||
optional int64 size = 3;
|
||||
optional uint32 permissions = 4;
|
||||
optional int64 modified_s = 5;
|
||||
optional int32 modified_ns = 11;
|
||||
optional uint64 modified_by = 12;
|
||||
optional bool deleted = 6;
|
||||
optional bool invalid = 7;
|
||||
optional bool no_permissions = 8;
|
||||
optional Vector version = 9;
|
||||
optional int64 sequence = 10;
|
||||
|
||||
repeated BlockInfo Blocks = 16;
|
||||
optional string symlink_target = 17;
|
||||
}
|
||||
|
||||
enum FileInfoType {
|
||||
FILE = 0;
|
||||
DIRECTORY = 1;
|
||||
SYMLINK_FILE = 2;
|
||||
SYMLINK_DIRECTORY = 3;
|
||||
SYMLINK = 4;
|
||||
}
|
||||
|
||||
message BlockInfo {
|
||||
optional int64 offset = 1;
|
||||
optional int32 size = 2;
|
||||
optional bytes hash = 3;
|
||||
optional uint32 weak_hash = 4;
|
||||
}
|
||||
|
||||
message Vector {
|
||||
repeated Counter counters = 1;
|
||||
}
|
||||
|
||||
message Counter {
|
||||
optional uint64 id = 1;
|
||||
optional uint64 value = 2;
|
||||
}
|
||||
|
||||
message Request {
|
||||
optional int32 id = 1;
|
||||
optional string folder = 2;
|
||||
optional string name = 3;
|
||||
optional int64 offset = 4;
|
||||
optional int32 size = 5;
|
||||
optional bytes hash = 6;
|
||||
optional bool from_temporary = 7;
|
||||
}
|
||||
|
||||
message Response {
|
||||
optional int32 id = 1;
|
||||
optional bytes data = 2;
|
||||
optional ErrorCode code = 3;
|
||||
}
|
||||
|
||||
enum ErrorCode {
|
||||
NO_ERROR = 0;
|
||||
GENERIC = 1;
|
||||
NO_SUCH_FILE = 2;
|
||||
INVALID_FILE = 3;
|
||||
}
|
||||
|
||||
message DownloadProgress {
|
||||
optional string folder = 1;
|
||||
repeated FileDownloadProgressUpdate updates = 2;
|
||||
}
|
||||
|
||||
message FileDownloadProgressUpdate {
|
||||
optional FileDownloadProgressUpdateType update_type = 1;
|
||||
optional string name = 2;
|
||||
optional Vector version = 3;
|
||||
repeated int32 block_indexes = 4;
|
||||
}
|
||||
|
||||
enum FileDownloadProgressUpdateType {
|
||||
APPEND = 0;
|
||||
FORGET = 1;
|
||||
}
|
||||
|
||||
message Ping {
|
||||
}
|
||||
|
||||
message Close {
|
||||
optional string reason = 1;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user