79 Commits

Author SHA1 Message Date
l-jonas 48f880ee4e Release version 0.3.1 2018-10-28 17:56:02 +01:00
l-jonas 9f5072ed3a Move google() to the top of allprojects.repositories
According to https://forum.f-droid.org/t/syncthing-lite/4394 this is required to make this App build correctly for F-Droid
2018-10-28 17:55:08 +01:00
l-jonas b1743db5af Update wording of the Readme to the one of the dialog in the App 2018-10-27 15:23:02 +02:00
l-jonas f0b465f054 Release version 0.3.0 2018-10-23 07:10:38 +02:00
l-jonas 7bdcef8338 Bugfixes (#61)
* Fix release build problems

- multiple coroutine versions (ProGuard did not like it)
- crashes due to obfuscation of specific coroutine code

* Fix downloading file if the cached version was used

* Show download progress in percent

* Change reconnect issues warning

* Increase timeout per pulled block

* Replace "other syncthing instance running" dialog

The dialog is replaced by a message which is shown at the bottom of the introduction screen and the folder list.

This fixes https://github.com/syncthing/syncthing-lite/issues/62
2018-10-22 21:07:03 -05:00
Felix Ableitner c2823c1aad Remove "unmaintained" note in readme 2018-10-22 20:51:36 -05:00
l-jonas 37b6a5492b Add note about reconnecting issues (#60) 2018-10-17 23:14:58 -05:00
l-jonas 9e632c2f58 File caching (#59)
* Make response handling more modular

* Remove shared state of the BlockPuller tasks

* Implement dispatching errors at the file downloading

* Remove unneeded locking

* Count sums as long

* Fix error caused by using notify without syncrhonized

* Add TODOs

* BlockPuller: Add timeout and concurrency limit

* Cache file blocks on disk during downloading

* Read downloaded files block by block

* Add function to delete temp data in bulk

* Implement deleting temp data at download failure

* Fix error in progress reporting

* Fix canceling

* Fix handling timeouts at block requests

* Add file caching

The files were already cached (saved locally), but it was not tried to use them. This names files by their hash to ensure that no old file version is used. The side effect of this is that equal files are only downloaded and saved once.

This closes https://github.com/syncthing/syncthing-lite/issues/43

What's missing:

- delete local files which are no longer provided by the server
- delete local files which were not used for a long time
- delete all local files when the menu item to clear the cache is used

* Fix file caching failure when using the SyncthingProvider

* Run auto reformat

* Use status object for the block puller download progress

* Improve file downloading by the SyncthingProvider

* Fix NullPointerException when getting totalFileSize

* Add additional check to cancel on race condition

* Check integrity at IndexHandler.getFileInfoAndBlockByPath()

* Use FileInfo.checkBlocks() for the integrity check
2018-10-17 22:03:25 -05:00
l-jonas eaf948d3cd Refactor file downloading (#57)
* Make response handling more modular

* Remove shared state of the BlockPuller tasks

* Implement dispatching errors at the file downloading

* Remove unneeded locking

* Count sums as long

* Fix error caused by using notify without syncrhonized

* Add TODOs

* BlockPuller: Add timeout and concurrency limit

* Cache file blocks on disk during downloading

* Read downloaded files block by block

* Add function to delete temp data in bulk

* Implement deleting temp data at download failure

* Fix error in progress reporting

* Fix canceling

* Fix handling timeouts at block requests

* Run auto reformat

* Use status object for the block puller download progress

* Improve file downloading by the SyncthingProvider

* Fix NullPointerException when getting totalFileSize
2018-10-16 14:16:55 -05:00
l-jonas 3242d3d742 Refactor file downloading dialog (#58)
* Call listeners of download and upload file tasks at the UI Thread

* Remove switching to UI Thread at the listeners at file upload/download dialog

* Refactor the download file dialog

* Revert useless change
2018-10-09 03:12:12 -05:00
l-jonas a90d724c06 Use Gson without reflection (#55)
* Import syncthing-java/master using the import gradle project wizard

* Update buildscripts for the current gradle version

* Add kotlin plugin to the library modules

* Use locale module instead of maven local

* Add Kotlin runtime library to all projects

* Add compiling protocol buffers

* Update module names

* Remove building the old syncthing-java in the CI

* Remove installing the protocol buffer compiler

* Fix building the default repository implementation

* Use GSON without reflection

- more source code
- faster parsing (not really necassary)
- works without any proguard rules

This could work without any dependencies because all used classes
are built into Android (under different package names), but as it should work in other environments too, it uses the GSON versions
2018-10-07 15:33:34 -05:00
l-jonas c257a730b0 Include syncthing java (#54)
* Import syncthing-java/master using the import gradle project wizard

* Update buildscripts for the current gradle version

* Add kotlin plugin to the library modules

* Use locale module instead of maven local

* Add Kotlin runtime library to all projects

* Add compiling protocol buffers

* Update module names

* Remove building the old syncthing-java in the CI

* Remove installing the protocol buffer compiler

* Fix building the default repository implementation
2018-10-04 13:25:41 -05:00
l-jonas fcbd344491 Use sqlite with Room as db abstraction (#52)
* Update for Android Studio 3.2 (which is stable now)

* Enable ProGuard

* Use variable for build tools version (again)

* Always keep the data for the stack traces

* Fix bugs (which were introduced by the library integration refactoring)

* Implement a file based TempRepository

* Implement a room based IndexRepository

* Fix database usage at main Thread

Room throws exceptions for this. This can be disabled,
but the right solution is to move it to a background thread

* Outsource new db layer into a library project

* Remove obsolete comment

* Add option to manually create a RepositoryDatabase instance

* Enable ignoring lint errors

* Revert "Enable ignoring lint errors"

This reverts commit f9c66e3873.

* Enable ignoring lint warnings for the androidrepository
2018-10-01 15:40:06 -05:00
l-jonas 773bb4259b Use a DialogFragment to show the device id (#51) 2018-09-29 13:08:59 -05:00
l-jonas 3f6382e9c6 Remove keep parseFrom proguard rule (#53)
This depends on https://github.com/syncthing/syncthing-java/pull/19
2018-09-29 13:07:50 -05:00
l-jonas 660346a856 Enable proguard (#49)
* Update for Android Studio 3.2 (which is stable now)

* Enable ProGuard

* Use variable for build tools version (again)

* Always keep the data for the stack traces

* Disable ProGuard for debug builds
2018-09-29 02:12:33 -05:00
l-jonas 93f2e0de57 Fix bugs (which were introduced by the library integration refactoring) (#50) 2018-09-28 14:12:11 -05:00
l-jonas 1fbecc449a Update for Android Studio 3.2 (which is stable now) (#48)
* Update for Android Studio 3.2 (which is stable now)

* Use variable for build tools version (again)
2018-09-27 13:50:15 -05:00
l-jonas c7d368dee6 Improve disconnecting logic (#46)
* Add logging about starting and stopping the client

* Refactor LibraryHandler

* Use better icon for the connection notification

* Add shutdown now option to the notification

* Fix bugs introduced by the new library handler

* Use string resources for the notification

* Add notification channel (required for Android >= Oreo)

* Make shutdown delay configurable

* Fix spelling mistake at LibraryInstance

* Add newlines at the ends of the files

* Use string resources for the shutdown delay preference

* Remove the connected notification
2018-09-22 23:07:18 -05:00
l-jonas 5addeb8ea6 Correct the reason why the Apache HTTP Client is an dependency of this App (#47) 2018-09-20 12:21:12 -05:00
l-jonas 57364b4e14 Improve device id dialog (#40)
prevent change of dialog size after generating QR code
2018-09-16 02:05:49 -05:00
l-jonas 4f9c44a4ad Add local discovery to select device id during the setup (#42)
* Add local discovery to select device id during the setup

* Reduce line lengths of the code of the previous commit
2018-09-16 01:18:52 -05:00
Felix Ableitner 126bb507ba Fix Transifex and Travis CI links in Readme 2018-09-15 13:21:18 -05:00
l-jonas f352303f6b Migrate from ListView to RecyclerView (#41)
Side effect (can be changed if wanted): No dividers between list items
2018-09-15 12:50:25 +01:00
l-jonas 53749eac5c Update for current Android Studio (#39) 2018-09-15 10:47:15 +01:00
Jakob Borg 34147063eb readme: And note about unmaintained status 2018-08-28 10:05:52 +02:00
Felix Ableitner edeb5ccb0d Added string 2018-02-28 23:30:45 +09:00
Felix Ableitner 248ccc0606 Removed unused strings 2018-02-28 21:55:26 +09:00
Felix Ableitner 726c9c974e Add Travis CI 2018-02-23 19:36:06 +09:00
Felix Ableitner 087b0f4ec1 Version 0.2.1 2018-02-20 14:58:06 +09:00
Felix Ableitner a6cc38f4a1 Imported translations 2018-02-20 14:58:06 +09:00
Felix Ableitner 96a4ec1738 Added preferences with device name and app version 2018-02-20 14:29:26 +09:00
Felix Ableitner 42b77cab12 Added app intro 2018-02-20 03:27:40 +09:00
Felix Ableitner a28e5d704d Reworked add device dialog 2018-02-15 04:05:16 +09:00
Felix Ableitner b924b50ddc Show warning if another Syncthing instance is running (fixes 18) 2018-02-12 16:47:11 +09:00
Felix Ableitner 96503dd9c1 Added device ID and qr code (fixes #14) 2018-02-09 00:38:51 +09:00
Licaon_Kter c83504c155 Some typos (#27) 2018-02-08 17:51:26 +09:00
Felix Ableitner acea170854 Version 0.2.0 2018-02-08 02:52:43 +09:00
Felix Ableitner c6950493e6 Fixed lint issues 2018-02-08 02:43:14 +09:00
Felix Ableitner b71740d044 Imported translations 2018-02-08 02:28:32 +09:00
Felix Ableitner dafe262c1c Fixed crash when cancelling file upload/download 2018-02-08 02:25:36 +09:00
Felix Ableitner acb1f75c5c Rewrite SyncthingClient API 2018-02-08 02:12:30 +09:00
Poussinou 03cc4f931d Update README.md (#26) 2018-02-07 02:47:27 +09:00
Felix Ableitner 967d65b3f9 Change min sdk to 21 2018-02-05 02:38:52 +09:00
Felix Ableitner ce6e7e2130 Move index updates to library, some bug fixes 2018-02-02 13:14:35 +09:00
Felix Ableitner 809eff7354 Fixed bug that prevented devices from being deleted 2018-02-01 16:33:52 +09:00
Felix Ableitner 944cecce1f Use snackbar to show index updates 2018-02-01 14:20:16 +09:00
Felix Ableitner 31abff58c1 Implement Storage Provider 2018-02-01 11:11:01 +09:00
Felix Ableitner ef401378e2 Version 0.1.5 2018-01-29 23:08:08 +09:00
Felix Ableitner 2c0be54e61 Imported translations 2018-01-29 23:08:08 +09:00
Felix Ableitner cb4b838082 Rewrite configuration 2018-01-29 21:53:54 +09:00
Felix Ableitner 6a6b40a89d Fix file uploads, use system chooser for uploads 2018-01-28 21:30:11 +09:00
Felix Ableitner 1b7dbd91e6 Version 0.1.4 2018-01-27 17:31:27 +09:00
Felix Ableitner cb7a2d362f Imported translations 2018-01-27 17:31:27 +09:00
Felix Ableitner ef2d7fe9d7 Updated release script 2018-01-27 17:30:51 +09:00
Felix Ableitner 0bd96302e0 Adjust for library changes 2018-01-27 17:10:31 +09:00
Felix Ableitner e0a95a0314 Added transifex link to readme 2018-01-27 05:36:48 +09:00
Felix Ableitner c2e7f7cbc2 Add Transifex integration 2018-01-27 00:09:38 +09:00
Felix Ableitner 631a2a4fe3 Fixed some crashes during index update 2018-01-25 17:23:29 +09:00
Felix Ableitner 36e54f5d24 Implement proper string formatting 2018-01-23 16:40:11 +09:00
Felix Ableitner 3506df6b22 Code cleanup 2018-01-22 22:06:47 +09:00
Felix Ableitner 2e1369d9a8 Update dependencies 2018-01-22 01:52:13 +09:00
Felix Ableitner a3495784f7 Version 0.1.3 2018-01-18 01:36:17 +09:00
Felix Ableitner 82c92c9031 Use FileProvider to share downloaded files (fixes #13) 2018-01-18 01:15:59 +09:00
Felix Ableitner bd5d89c158 Adjust to library changes 2018-01-18 00:38:20 +09:00
Felix Ableitner 9cf96d86dd Improve LibraryHandler to prevent various crashes (fixes #8) 2018-01-09 14:51:15 +09:00
Felix Ableitner f4c1e6a0f0 Fixed crash related to loading dialog (ref #11) 2018-01-09 03:32:57 +09:00
Felix Ableitner 036d3846bc Added release scripts 2018-01-04 16:39:52 +09:00
Felix Ableitner 6696f0ff88 Version 0.1.2 2018-01-04 16:28:10 +09:00
Felix Ableitner ffff6a7617 Improve folder browser code 2018-01-04 14:39:52 +09:00
Felix Ableitner 3bd1f6e44a Better error logging 2018-01-04 14:20:27 +09:00
Patrick S a2f46b358d Externalized strings + german translation (#10)
* externalized some strings

* externalized some more strings

* Yay! More strings externalized

* externalized strings

* translated to german

* translation finished

* fixed mistake

* fixed compile error

* externalized strings
translated said strings into german

* finishing touches

* even more finishing touches

* finishing touches

* fixed missing space

* added space

* Revert "fixed compile error"

This reverts commit 0225ce6d79.
2018-01-04 14:19:32 +09:00
ImPat a0e749e879 Various fixes related to capitalization and added some question marks and a space (#9)
* fixed missing space and capitalized two word

* added capitalization

* Added Captialization
2017-12-31 13:28:04 +09:00
Felix Ableitner e566b3c58d Minor fixes 2017-12-30 01:08:45 +09:00
Felix Ableitner acff8c1f5c Simplify code with anko library 2017-12-29 14:07:05 +09:00
Felix Ableitner 4353fc5597 Move library initialization to seperate class 2017-12-29 04:35:28 +09:00
Felix Ableitner 64b2b7424b Fix screen rotation in MainActivity (fixes #7) 2017-12-29 00:24:25 +09:00
Felix Ableitner 2fa0dd2e59 Clarified build instructions 2017-12-28 17:32:43 +09:00
Felix Ableitner 2e1fa76b44 Use own activity for upload file picker 2017-12-27 13:30:23 +09:00
161 changed files with 10583 additions and 1109 deletions
+32
View File
@@ -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
+9
View File
@@ -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
+13 -2
View File
@@ -1,5 +1,6 @@
# Syncthing Lite
[![Build Status](https://travis-ci.org/syncthing/syncthing-lite.svg?branch=master)](https://travis-ci.org/syncthing/syncthing-lite)
[![MPLv2 License](https://img.shields.io/badge/license-MPLv2-blue.svg?style=flat-square)](https://www.mozilla.org/MPL/2.0/)
This project is an Android app, that works as a client for a [Syncthing][1] share (accessing
@@ -13,15 +14,25 @@ example, mobile devices with limited storage available, wishing to access a sync
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 reconnct 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`.
To compile with a development version of the [syncthing-java][3] library, you have to install it to
the local maven repository. To do this, clone the repo and run `gradle install` in the
syncthing-java project folder.
## License
All code is licensed under the [MPLv2 License][5].
+59 -17
View File
@@ -1,17 +1,18 @@
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'
android {
compileSdkVersion 27
buildToolsVersion "27.0.2"
buildToolsVersion "28.0.2"
dataBinding.enabled = true
defaultConfig {
applicationId "net.syncthing.lite"
minSdkVersion 19
targetSdkVersion 25
versionCode 3
versionName "0.1.1"
minSdkVersion 21
targetSdkVersion 26
versionCode 11
versionName "0.3.1"
multiDexEnabled true
}
sourceSets {
@@ -21,26 +22,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')
}
+10
View File
@@ -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>
+89
View File
@@ -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
+20 -9
View File
@@ -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)
}
}
}
}
}
@@ -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)
}
}
@@ -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,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>
+2 -29
View File
@@ -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>
+10 -17
View File
@@ -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>
+32 -20
View File
@@ -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>
+60 -17
View File
@@ -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>
+19 -4
View File
@@ -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"
+19 -1
View File
@@ -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"
+26 -5
View File
@@ -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>
+12 -7
View File
@@ -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"
+24
View File
@@ -0,0 +1,24 @@
<resources>
<string name="app_name">Syncthing Lite</string>
<string name="folder_list_empty_message">Keine 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="device_id_dialog_title">Geräte ID eingeben</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="last_modified_time">Zuletzt modifiziert: %1$s</string>
<string name="remove_device_title">Gerät entfernen: %1$s</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>
</resources>
+24
View File
@@ -0,0 +1,24 @@
<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="device_id_dialog_title">Entrer l\'ID de l\'appareil</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="last_modified_time">Dernière modification : %1$s</string>
<string name="remove_device_title">Supprimer l\'appareil %1$s\?</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>
</resources>
+25
View File
@@ -0,0 +1,25 @@
<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="device_id_dialog_title">Inserisci ID Dispositivo</string>
<string name="dialog_downloading_file">Download 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="last_modified_time">Ultima modifica: %1$s</string>
<string name="remove_device_title">Rimuovere il dispositivo %1$s\?</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>
</resources>
+25
View File
@@ -0,0 +1,25 @@
<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="device_id_dialog_title">デバイス 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="last_modified_time">最終更新: %1$s</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="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>
</resources>
+24
View File
@@ -0,0 +1,24 @@
<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="device_id_dialog_title">Introduceți ID dispozitiv</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="last_modified_time">Modificat ultima dată pe: %1$s</string>
<string name="remove_device_title">Ștergere dispozitiv %1$s\?</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>
</resources>
+3 -6
View File
@@ -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>
+18
View File
@@ -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>
+47 -12
View File
@@ -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 reconnct 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>
+6 -10
View File
@@ -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>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-cache-path name="files" path="/" />
</paths>
+33
View File
@@ -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>
+7 -7
View File
@@ -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,17 @@ 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'
}
}
allprojects {
repositories {
mavenLocal()
google()
jcenter()
maven {
url "https://jitpack.io"
}
google()
}
}
+2 -2
View File
@@ -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
+54
View File
@@ -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.
"
+37
View File
@@ -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
View File
@@ -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'
+42
View File
@@ -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;
}
+16
View File
@@ -0,0 +1,16 @@
apply plugin: 'application'
apply plugin: 'kotlin'
mainClassName = 'net.syncthing.java.client.cli.Main'
dependencies {
compile project(':syncthing-client')
compile project(':syncthing-repository-default')
compile "commons-cli:commons-cli:1.4"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
}
run {
if (project.hasProperty('args')) {
args project.args.split('\\s+')
}
}
@@ -0,0 +1,227 @@
/*
* 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.client.cli
import net.syncthing.java.core.beans.DeviceId
import net.syncthing.java.core.beans.DeviceInfo
import net.syncthing.java.core.beans.FileInfo
import net.syncthing.java.core.configuration.Configuration
import net.syncthing.java.repository.repo.SqlRepository
import net.syncthing.java.client.SyncthingClient
import org.apache.commons.cli.*
import org.apache.commons.io.FileUtils
import org.slf4j.LoggerFactory
import java.io.File
import java.io.FileInputStream
import java.io.IOException
import java.util.concurrent.CountDownLatch
class Main(private val commandLine: CommandLine) {
companion object {
@JvmStatic
fun main(args: Array<String>) {
val options = generateOptions()
val parser = DefaultParser()
val cmd = parser.parse(options, args)
if (cmd.hasOption("h")) {
val formatter = HelpFormatter()
formatter.printHelp("s-client", options)
return
}
val configuration = if (cmd.hasOption("C")) Configuration(File(cmd.getOptionValue("C")))
else Configuration()
val repository = SqlRepository(configuration.databaseFolder)
SyncthingClient(configuration, repository, repository).use { syncthingClient ->
val main = Main(cmd)
cmd.options.forEach { main.handleOption(it, configuration, syncthingClient) }
}
}
private fun generateOptions(): Options {
val options = Options()
options.addOption("C", "set-config", true, "set config file for s-client")
options.addOption("c", "config", false, "dump config")
options.addOption("S", "set-peers", true, "set peer, or comma-separated list of peers")
options.addOption("p", "pull", true, "pull file from network")
options.addOption("P", "push", true, "push file to network")
options.addOption("o", "output", true, "set output file/directory")
options.addOption("i", "input", true, "set input file/directory")
options.addOption("a", "list-peers", false, "list peer addresses")
options.addOption("a", "address", true, "use this peer addresses")
options.addOption("L", "list-remote", false, "list folder (root) content from network")
options.addOption("I", "list-info", false, "dump folder info from network")
options.addOption("l", "list-info", false, "list folder info from local db")
options.addOption("D", "delete", true, "push delete to network")
options.addOption("M", "mkdir", true, "push directory create to network")
options.addOption("h", "help", false, "print help")
return options
}
}
private val logger = LoggerFactory.getLogger(Main::class.java)
private fun handleOption(option: Option, configuration: Configuration, syncthingClient: SyncthingClient) {
when (option.opt) {
"S" -> {
val peers = option.value
.split(",")
.filterNot { it.isEmpty() }
.map { DeviceId(it.trim()) }
.toList()
System.out.println("set peers = $peers")
configuration.peers = peers.map { DeviceInfo(it, null) }.toSet()
configuration.persistNow()
}
"p" -> {
val folderAndPath = option.value
System.out.println("file path = $folderAndPath")
val folder = folderAndPath.split(":".toRegex()).dropLastWhile({ it.isEmpty() }).toTypedArray()[0]
val path = folderAndPath.split(":".toRegex()).dropLastWhile({ it.isEmpty() }).toTypedArray()[1]
val latch = CountDownLatch(1)
val fileInfo = FileInfo(folder = folder, path = path, type = FileInfo.FileType.FILE)
syncthingClient.getBlockPuller(folder, { blockPuller ->
try {
val inputStream = blockPuller.pullFileSync(fileInfo)
val fileName = syncthingClient.indexHandler.getFileInfoByPath(folder, path)!!.fileName
val file =
if (commandLine.hasOption("o")) {
val param = File(commandLine.getOptionValue("o"))
if (param.isDirectory) File(param, fileName) else param
} else {
File(fileName)
}
FileUtils.copyInputStreamToFile(inputStream, file)
System.out.println("saved file to = $file.absolutePath")
} catch (e: InterruptedException) {
logger.warn("", e)
} catch (e: IOException) {
logger.warn("", e)
}
}, { logger.warn("Failed to pull file") })
latch.await()
}
"P" -> {
var path = option.value
val file = File(commandLine.getOptionValue("i"))
assert(!path.startsWith("/")) //TODO check path syntax
System.out.println("file path = $path")
val folder = path.split(":".toRegex()).dropLastWhile({ it.isEmpty() }).toTypedArray()[0]
path = path.split(":".toRegex()).dropLastWhile({ it.isEmpty() }).toTypedArray()[1]
val latch = CountDownLatch(1)
syncthingClient.getBlockPusher(folder, { blockPusher ->
val observer = blockPusher.pushFile(FileInputStream(file), folder, path)
while (!observer.isCompleted()) {
try {
observer.waitForProgressUpdate()
} catch (e: InterruptedException) {
logger.warn("", e)
}
System.out.println("upload progress ${observer.progressPercentage()}%")
}
latch.countDown()
}, { logger.warn("Failed to upload file") })
latch.await()
System.out.println("uploaded file to network")
}
"D" -> {
var path = option.value
val folder = path.split(":".toRegex()).dropLastWhile({ it.isEmpty() }).toTypedArray()[0]
path = path.split(":".toRegex()).dropLastWhile({ it.isEmpty() }).toTypedArray()[1]
System.out.println("delete path = $path")
val latch = CountDownLatch(1)
syncthingClient.getBlockPusher(folder, { blockPusher ->
try {
blockPusher.pushDelete(folder, path).waitForComplete()
} catch (e: InterruptedException) {
logger.warn("", e)
}
latch.countDown()
}, { System.out.println("Failed to delete path") })
latch.await()
System.out.println("deleted path")
}
"M" -> {
var path = option.value
val folder = path.split(":".toRegex()).dropLastWhile({ it.isEmpty() }).toTypedArray()[0]
path = path.split(":".toRegex()).dropLastWhile({ it.isEmpty() }).toTypedArray()[1]
System.out.println("dir path = $path")
val latch = CountDownLatch(1)
syncthingClient.getBlockPusher(folder, { blockPusher ->
try {
blockPusher.pushDir(folder, path).waitForComplete()
} catch (e: InterruptedException) {
logger.warn("", e)
}
latch.countDown()
}, { System.out.println("Failed to push directory") })
latch.await()
System.out.println("uploaded dir to network")
}
"L" -> {
waitForIndexUpdate(syncthingClient, configuration)
for (folder in syncthingClient.indexHandler.folderList()) {
syncthingClient.indexHandler.newIndexBrowser(folder).use { indexBrowser ->
System.out.println("list folder = ${indexBrowser.folder}")
for (fileInfo in indexBrowser.listFiles()) {
System.out.println("${fileInfo.type.name.substring(0, 1)}\t${fileInfo.describeSize()}\t${fileInfo.path}")
}
}
}
}
"I" -> {
waitForIndexUpdate(syncthingClient, configuration)
val folderInfo = StringBuilder()
for (folder in syncthingClient.indexHandler.folderList()) {
folderInfo.append("\nfolder info: ")
.append(syncthingClient.indexHandler.getFolderInfo(folder))
folderInfo.append("\nfolder stats: ")
.append(syncthingClient.indexHandler.newFolderBrowser().getFolderStats(folder).dumpInfo())
.append("\n")
}
System.out.println("folders:\n$folderInfo\n")
}
"l" -> {
var folderInfo = ""
for (folder in syncthingClient.indexHandler.folderList()) {
folderInfo += "\nfolder info: " + syncthingClient.indexHandler.getFolderInfo(folder)
folderInfo += "\nfolder stats: " + syncthingClient.indexHandler.newFolderBrowser().getFolderStats(folder).dumpInfo() + "\n"
}
System.out.println("folders:\n$folderInfo\n")
}
"a" -> {
val deviceAddressSupplier = syncthingClient.discoveryHandler.newDeviceAddressSupplier()
var deviceAddressesStr = ""
for (deviceAddress in deviceAddressSupplier.toList()) {
deviceAddressesStr += "\n" + deviceAddress?.deviceId + " : " + deviceAddress?.address
}
System.out.println("device addresses:\n$deviceAddressesStr\n")
}
}
}
@Throws(InterruptedException::class)
private fun waitForIndexUpdate(client: SyncthingClient, configuration: Configuration) {
val latch = CountDownLatch(configuration.peers.size)
client.indexHandler.registerOnFullIndexAcquiredListenersListener {
latch.countDown()
}
latch.await()
}
}
+9
View File
@@ -0,0 +1,9 @@
apply plugin: 'java-library'
apply plugin: 'kotlin'
dependencies {
compile project(':syncthing-core')
compile project(':syncthing-bep')
compile project(':syncthing-discovery')
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
}
@@ -0,0 +1,231 @@
/*
* 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.client
import net.syncthing.java.bep.BlockPuller
import net.syncthing.java.bep.BlockPusher
import net.syncthing.java.bep.ConnectionHandler
import net.syncthing.java.bep.IndexHandler
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.configuration.Configuration
import net.syncthing.java.core.interfaces.IndexRepository
import net.syncthing.java.core.interfaces.TempRepository
import net.syncthing.java.core.security.KeystoreHandler
import net.syncthing.java.core.utils.awaitTerminationSafe
import net.syncthing.java.discovery.DiscoveryHandler
import org.slf4j.LoggerFactory
import java.io.Closeable
import java.io.IOException
import java.util.Collections
import java.util.TreeSet
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.collections.ArrayList
class SyncthingClient(
private val configuration: Configuration,
private val repository: IndexRepository,
private val tempRepository: TempRepository
) : Closeable {
private val logger = LoggerFactory.getLogger(javaClass)
val discoveryHandler: DiscoveryHandler
val indexHandler: IndexHandler
private val connections = Collections.synchronizedSet(createConnectionsSet())
private val connectByDeviceIdLocks = Collections.synchronizedMap(HashMap<DeviceId, Object>())
private val onConnectionChangedListeners = Collections.synchronizedList(mutableListOf<(DeviceId) -> Unit>())
private var connectDevicesScheduler = Executors.newSingleThreadScheduledExecutor()
private fun createConnectionsSet() = TreeSet<ConnectionHandler>(compareBy { it.address.score })
init {
indexHandler = IndexHandler(configuration, repository, tempRepository)
discoveryHandler = DiscoveryHandler(configuration)
connectDevicesScheduler.scheduleAtFixedRate(this::updateIndexFromPeers, 0, 15, TimeUnit.SECONDS)
}
fun clearCacheAndIndex() {
indexHandler.clearIndex()
configuration.folders = emptySet()
configuration.persistLater()
updateIndexFromPeers()
}
fun addOnConnectionChangedListener(listener: (DeviceId) -> Unit) {
onConnectionChangedListeners.add(listener)
}
fun removeOnConnectionChangedListener(listener: (DeviceId) -> Unit) {
assert(onConnectionChangedListeners.contains(listener))
onConnectionChangedListeners.remove(listener)
}
@Throws(IOException::class, KeystoreHandler.CryptoException::class)
private fun openConnection(deviceAddress: DeviceAddress): ConnectionHandler {
logger.debug("Connecting to ${deviceAddress.deviceId}, active connections: ${connections.map { it.deviceId().deviceId }}")
val connectionHandler = ConnectionHandler(
configuration, deviceAddress, indexHandler, tempRepository, { connectionHandler, _ ->
connectionHandler.close()
openConnection(deviceAddress)
},
{connection ->
if (!connection.isConnected) {
connections.remove(connection)
}
onConnectionChangedListeners.forEach { it(connection.deviceId()) }
})
try {
connectionHandler.connect()
} catch (ex: Exception) {
connectionHandler.closeBg()
throw ex
}
connections.add(connectionHandler)
return connectionHandler
}
/**
* Takes discovered addresses from [[DiscoveryHandler]] and connects to devices.
*
* We need to make sure that we are only connecting once to each device.
*/
private fun getPeerConnections(listener: (connection: ConnectionHandler) -> Unit, completeListener: () -> Unit) {
// create an copy to prevent dispatching an action two times
val connectionsWhichWereDispatched = createConnectionsSet()
synchronized (connections) {
connectionsWhichWereDispatched.addAll(connections)
}
connectionsWhichWereDispatched.forEach { listener(it) }
discoveryHandler.newDeviceAddressSupplier()
.takeWhile { it != null }
.filterNotNull()
.groupBy { it.deviceId() }
.filterNot { it.value.isEmpty() }
.forEach { (deviceId, addresses) ->
// create an lock per device id to prevent multiple connections to one device
synchronized (connectByDeviceIdLocks) {
if (connectByDeviceIdLocks[deviceId] == null) {
connectByDeviceIdLocks[deviceId] = Object()
}
}
synchronized (connectByDeviceIdLocks[deviceId]!!) {
val existingConnection = connections.find { it.deviceId() == deviceId && it.isConnected }
if (existingConnection != null) {
connectionsWhichWereDispatched.add(existingConnection)
listener(existingConnection)
return@synchronized
}
// try to use all addresses
for (address in addresses.distinctBy { it.address }) {
try {
val newConnection = openConnection(address)
connectionsWhichWereDispatched.add(newConnection)
listener(newConnection)
break // it worked, no need to try more
} catch (e: IOException) {
logger.warn("error connecting to device = $address", e)
} catch (e: KeystoreHandler.CryptoException) {
logger.warn("error connecting to device = $address", e)
}
}
}
}
// use all connections which were added in the time between and were not added by this function call
val newConnectionsBackup = createConnectionsSet()
synchronized (connections) {
newConnectionsBackup.addAll(connections)
}
connectionsWhichWereDispatched.forEach { newConnectionsBackup.remove(it) }
newConnectionsBackup.forEach { listener(it) }
completeListener()
}
private fun updateIndexFromPeers() {
getPeerConnections({ connection ->
try {
indexHandler.waitForRemoteIndexAcquired(connection)
} catch (ex: InterruptedException) {
logger.warn("exception while waiting for index", ex)
}
}, {})
}
private fun getConnectionForFolder(folder: String, listener: (connection: ConnectionHandler) -> Unit,
errorListener: () -> Unit) {
val isConnected = AtomicBoolean(false)
getPeerConnections({ connection ->
if (connection.hasFolder(folder) && !isConnected.get()) {
listener(connection)
isConnected.set(true)
}
}, {
if (!isConnected.get()) {
errorListener()
}
})
}
fun getBlockPuller(folderId: String, listener: (BlockPuller) -> Unit, errorListener: () -> Unit) {
getConnectionForFolder(folderId, { connection ->
listener(connection.getBlockPuller())
}, errorListener)
}
fun getBlockPusher(folderId: String, listener: (BlockPusher) -> Unit, errorListener: () -> Unit) {
getConnectionForFolder(folderId, { connection ->
listener(connection.getBlockPusher())
}, errorListener)
}
fun getPeerStatus(): List<DeviceInfo> {
return configuration.peers.map { device ->
val isConnected = connections.find { it.deviceId() == device.deviceId }?.isConnected ?: false
device.copy(isConnected = isConnected)
}
}
override fun close() {
connectDevicesScheduler.awaitTerminationSafe()
discoveryHandler.close()
// Create copy of list, because it will be modified by handleConnectionClosedEvent(), causing ConcurrentModificationException.
ArrayList(connections).forEach{it.close()}
indexHandler.close()
repository.close()
tempRepository.close()
assert(onConnectionChangedListeners.isEmpty())
}
}
+15
View File
@@ -0,0 +1,15 @@
apply plugin: 'java-library'
apply plugin: 'kotlin'
dependencies {
compile "org.apache.commons:commons-lang3:3.7"
compile 'commons-codec:commons-codec:1.11'
// Can't upgrade to 2.6 because it crashes on Android
compile "commons-io:commons-io:2.5"
compile "org.slf4j:slf4j-api:1.7.25"
compile "ch.qos.logback:logback-classic:1.2.3"
compile "com.google.code.gson:gson:2.8.2"
compile "org.apache.httpcomponents:httpclient:4.5.4"
compile "org.bouncycastle:bcmail-jdk15on:1.59"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
}
@@ -0,0 +1,16 @@
/*
* 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.core.beans
data class BlockInfo(val offset: Long, val size: Int, val hash: String)
@@ -0,0 +1,205 @@
/*
* 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.core.beans
import org.slf4j.LoggerFactory
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.UnknownHostException
import java.util.*
/**
*
* TODO: this class cant use [[DeviceId]] because [[GlobalDiscoveryHandler.pickAnnounceServers]] uses that field for discovery server URLs.
*/
class DeviceAddress private constructor(val deviceId: String, private val instanceId: Long?, val address: String, producer: AddressProducer?, score: Int?, lastModified: Date?) {
private val producer = producer ?: AddressProducer.UNKNOWN
val score = score ?: Integer.MAX_VALUE
private val lastModified = lastModified ?: Date()
fun deviceId() = DeviceId(deviceId)
@Throws(UnknownHostException::class)
private fun getInetAddress(): InetAddress = InetAddress.getByName(address.replaceFirst("^[^:]+://".toRegex(), "").replaceFirst("(:[0-9]+)?(/.*)?$".toRegex(), ""))
private fun getPort(): Int = if (address.matches("^[a-z]+://[^:]+:([0-9]+).*".toRegex())) {
Integer.parseInt(address.replaceFirst("^[a-z]+://[^:]+:([0-9]+).*".toRegex(), "$1"))
} else {
DEFAULT_PORT_BY_PROTOCOL[getType()]!!
}
fun getType(): AddressType = when {
address.isEmpty() -> AddressType.NULL
address.startsWith("tcp://") -> AddressType.TCP
address.startsWith("relay://") -> AddressType.RELAY
address.startsWith("relay-http://") -> AddressType.HTTP_RELAY
address.startsWith("relay-https://") -> AddressType.HTTPS_RELAY
else -> AddressType.OTHER
}
@Throws(UnknownHostException::class)
fun getSocketAddress(): InetSocketAddress = InetSocketAddress(getInetAddress(), getPort())
fun isWorking(): Boolean = score < Integer.MAX_VALUE
constructor(deviceId: String, address: String) : this(deviceId, null, address, null, null, null)
fun containsUriParamValue(key: String): Boolean {
return !getUriParam(key).isNullOrEmpty()
}
/**
* Returns value for the specified URL parameter key.
*
* We need to parse the URL manually, as it is not URL encoded and may contain invalid key/values
* like "key=a b" (with an unencoded space).
*/
fun getUriParam(key: String): String? {
assert(!key.isEmpty())
return address
.split("?", limit = 2).first()
.splitToSequence("&")
.map { it.split("=", limit = 2) }
.map { it[0] to (it.getOrNull(1) ?: "") }
.find { it.first == key }
?.second
}
enum class AddressType {
TCP, RELAY, OTHER, NULL, HTTP_RELAY, HTTPS_RELAY
}
enum class AddressProducer {
LOCAL_DISCOVERY, GLOBAL_DISCOVERY, UNKNOWN
}
override fun toString(): String {
return "DeviceAddress(deviceId=$deviceId, instanceId=$instanceId, address=$address, producer=$producer, score=$score, lastModified=$lastModified)"
}
override fun hashCode(): Int {
var hash = 3
hash = 29 * hash + Objects.hashCode(this.deviceId)
hash = 29 * hash + Objects.hashCode(this.address)
return hash
}
override fun equals(obj: Any?): Boolean {
if (this === obj) {
return true
}
if (obj == null) {
return false
}
if (javaClass != obj.javaClass) {
return false
}
val other = obj as DeviceAddress?
if (this.deviceId != other!!.deviceId) {
return false
}
return this.address == other.address
}
fun copyBuilder(): Builder {
return Builder(deviceId, instanceId, address, producer, score, lastModified)
}
class Builder {
private var deviceId: String? = null
private var instanceId: Long? = null
private var address: String? = null
private var producer: AddressProducer? = null
private var score: Int? = null
private var lastModified: Date? = null
constructor()
internal constructor(deviceId: String, instanceId: Long?, address: String, producer: AddressProducer, score: Int?, lastModified: Date) {
this.deviceId = deviceId
this.instanceId = instanceId
this.address = address
this.producer = producer
this.score = score
this.lastModified = lastModified
}
fun getLastModified(): Date? {
return lastModified
}
fun setLastModified(lastModified: Date): Builder {
this.lastModified = lastModified
return this
}
fun getDeviceId(): String? {
return deviceId
}
fun setDeviceId(deviceId: String): Builder {
this.deviceId = deviceId
return this
}
fun getInstanceId(): Long? {
return instanceId
}
fun setInstanceId(instanceId: Long?): Builder {
this.instanceId = instanceId
return this
}
fun getAddress(): String? {
return address
}
fun setAddress(address: String): Builder {
this.address = address
return this
}
fun getProducer(): AddressProducer? {
return producer
}
fun setProducer(producer: AddressProducer): Builder {
this.producer = producer
return this
}
fun getScore(): Int? {
return score
}
fun setScore(score: Int?): Builder {
this.score = score
return this
}
fun build(): DeviceAddress {
return DeviceAddress(deviceId!!, instanceId, address!!, producer, score, lastModified)
}
}
companion object {
private val DEFAULT_PORT_BY_PROTOCOL = mapOf(
AddressType.TCP to 22000,
AddressType.RELAY to 22067,
AddressType.HTTP_RELAY to 80,
AddressType.HTTPS_RELAY to 443)
}
}
@@ -0,0 +1,83 @@
package net.syncthing.java.core.beans
import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonWriter
import net.syncthing.java.core.utils.NetworkUtils
import org.apache.commons.codec.binary.Base32
import org.slf4j.LoggerFactory
import java.io.IOException
data class DeviceId @Throws(IOException::class) constructor(val deviceId: String) {
init {
val withoutDashes = this.deviceId.replace("-", "")
NetworkUtils.assertProtocol(DeviceId.fromHashDataToString(toHashData()) == withoutDashes)
}
val shortId
get() = deviceId.substring(0, 7)
fun toHashData(): ByteArray {
NetworkUtils.assertProtocol(deviceId.matches("^[A-Z0-9]{7}-[A-Z0-9]{7}-[A-Z0-9]{7}-[A-Z0-9]{7}-[A-Z0-9]{7}-[A-Z0-9]{7}-[A-Z0-9]{7}-[A-Z0-9]{7}$".toRegex()), {"device id syntax error for deviceId = $deviceId"})
val base32data = deviceId.replaceFirst("(.{7})-(.{6}).-(.{7})-(.{6}).-(.{7})-(.{6}).-(.{7})-(.{6}).".toRegex(), "$1$2$3$4$5$6$7$8") + "==="
val binaryData = Base32().decode(base32data)
NetworkUtils.assertProtocol(binaryData.size == SHA256_BYTES)
return binaryData
}
companion object {
private const val DEVICE_ID = "deviceId"
private const val SHA256_BYTES = 256 / 8
private fun fromHashDataToString(hashData: ByteArray): String {
NetworkUtils.assertProtocol(hashData.size == SHA256_BYTES)
val string = Base32().encodeAsString(hashData).replace("=", "")
return string.chunked(13).joinToString("") { part -> part + generateLuhn32Checksum(part) }
}
fun fromHashData(hashData: ByteArray): DeviceId {
return DeviceId(fromHashDataToString(hashData).chunked(7).joinToString("-"))
}
private fun generateLuhn32Checksum(string: String): Char {
val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
var factor = 1
var sum = 0
val n = alphabet.length
for (character in string.toCharArray()) {
val index = alphabet.indexOf(character)
NetworkUtils.assertProtocol(index >= 0)
var add = factor * index
factor = if (factor == 2) 1 else 2
add = add / n + add % n
sum += add
}
val remainder = sum % n
val check = (n - remainder) % n
return alphabet[check]
}
fun parse(reader: JsonReader): DeviceId {
var deviceId: String? = null
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
DEVICE_ID -> deviceId = reader.nextString()
else -> reader.skipValue()
}
}
reader.endObject()
return DeviceId(deviceId!!)
}
}
fun serialize(writer: JsonWriter) {
writer.beginObject()
writer.name(DEVICE_ID).value(deviceId)
writer.endObject()
}
}
@@ -0,0 +1,60 @@
/*
* Copyright 2016 Davide Imbriaco <davide.imbriaco@gmail.com>
* Copyright 2018 Jonas Lochmann
*
* This Source Code Form 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.core.beans
import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonWriter
data class DeviceInfo(val deviceId: DeviceId, val name: String, val isConnected: Boolean? = null) {
companion object {
private const val DEVICE_ID = "deviceId"
private const val NAME = "name"
fun parse(reader: JsonReader): DeviceInfo {
var deviceId: DeviceId? = null
var name: String? = null
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
DEVICE_ID -> deviceId = DeviceId.parse(reader)
NAME -> name = reader.nextString()
else -> reader.skipValue()
}
}
reader.endObject()
return DeviceInfo(
deviceId = deviceId!!,
name = name!!
)
}
}
constructor(deviceId: DeviceId, name: String?) :
this(deviceId, if (name != null && !name.isBlank()) name else deviceId.shortId, null)
fun serialize(writer: JsonWriter) {
writer.beginObject()
writer.name(DEVICE_ID)
deviceId.serialize(writer)
writer.name(NAME).value(name)
writer.endObject()
}
}

Some files were not shown because too many files have changed in this diff Show More