Merge pull request #1430 from kiwix/feature/macgills/#1386-use-fetch-downloads

Feature/macgills/#1386 use fetch downloads
This commit is contained in:
Seán Mac Gillicuddy 2019-09-11 09:57:17 +01:00 committed by GitHub
commit f0b5344e4d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 2761 additions and 2703 deletions

View File

@ -10,14 +10,6 @@
</value>
</option>
<option name="LINE_SEPARATOR" value="&#10;" />
<AndroidXmlCodeStyleSettings>
<option name="LAYOUT_SETTINGS">
<value>
<option name="INSERT_BLANK_LINE_BEFORE_TAG" value="false" />
<option name="INSERT_LINE_BREAK_AFTER_LAST_ATTRIBUTE" value="true" />
</value>
</option>
</AndroidXmlCodeStyleSettings>
<GroovyCodeStyleSettings>
<option name="ALIGN_MULTILINE_LIST_OR_MAP" value="false" />
<option name="ALIGN_NAMED_ARGS_IN_MAP" value="false" />
@ -207,7 +199,7 @@
<codeStyleSettings language="XML">
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="4" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
<arrangement>
@ -215,28 +207,22 @@
<section>
<rule>
<match>
<NAME>class</NAME>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<NAME>layout</NAME>
</match>
</rule>
</section>
<section>
<rule>
<match>
<NAME>xmlns:android</NAME>
</match>
</rule>
</section>
<section>
<rule>
<match>
<NAME>xmlns:.*</NAME>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
@ -246,6 +232,7 @@
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
@ -256,6 +243,7 @@
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
@ -265,8 +253,9 @@
<rule>
<match>
<AND>
<NAME>.*:layout_width</NAME>
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
@ -275,8 +264,9 @@
<rule>
<match>
<AND>
<NAME>.*:layout_height</NAME>
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
@ -285,8 +275,9 @@
<rule>
<match>
<AND>
<NAME>.*:layout_.*</NAME>
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
@ -297,38 +288,25 @@
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>app:layout_.*</NAME>
<XML_NAMESPACE>http://schemas.android.com/apk/res-auto</XML_NAMESPACE>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<NAME>.*(?&lt;!style)$</NAME>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<NAME>style</NAME>
</match>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>

View File

@ -132,6 +132,9 @@ dependencies {
implementation "io.objectbox:objectbox-rxjava:$objectboxVersion"
implementation 'com.google.android.gms:play-services-location:17.0.0'
implementation "androidx.tonyodev.fetch2:xfetch2:$fetchVersion"
implementation "androidx.tonyodev.fetch2okhttp:xfetch2okhttp:$fetchVersion"
testImplementation "org.junit.jupiter:junit-jupiter:5.4.2"
testImplementation "io.mockk:mockk:1.9"
testImplementation "org.assertj:assertj-core:3.11.1"

File diff suppressed because it is too large Load Diff

View File

@ -3,74 +3,6 @@
"_note2": "ObjectBox manages crucial IDs for your object model. See docs for details.",
"_note3": "If you have VCS merge conflicts, you must resolve them according to ObjectBox docs.",
"entities": [
{
"id": "1:7257718270326155947",
"lastPropertyId": "17:8085320504542486236",
"name": "DownloadEntity",
"properties": [
{
"id": "1:2266566996008201697",
"name": "id"
},
{
"id": "2:1953917250527765737",
"name": "downloadId"
},
{
"id": "5:6575412958851693470",
"name": "bookId"
},
{
"id": "6:1075612111256674117",
"name": "title"
},
{
"id": "7:2831524841121029990",
"name": "description"
},
{
"id": "8:2334902404590133038",
"name": "language"
},
{
"id": "9:5087250349738158996",
"name": "creator"
},
{
"id": "10:6128960350043895299",
"name": "publisher"
},
{
"id": "11:3850323036475883785",
"name": "date"
},
{
"id": "12:5288623325038033644",
"name": "url"
},
{
"id": "13:2501711400901908648",
"name": "articleCount"
},
{
"id": "14:3550975911715416030",
"name": "mediaCount"
},
{
"id": "15:8949996430663588693",
"name": "size"
},
{
"id": "16:7554483297276446029",
"name": "name"
},
{
"id": "17:8085320504542486236",
"name": "favIcon"
}
],
"relations": []
},
{
"id": "3:5536749840871435068",
"lastPropertyId": "16:6142333908132117423",
@ -262,16 +194,113 @@
}
],
"relations": []
},
{
"id": "8:8093454424037540087",
"lastPropertyId": "23:5485468735259326535",
"name": "FetchDownloadEntity",
"properties": [
{
"id": "1:7366957113003324901",
"name": "id"
},
{
"id": "3:3174500111130052488",
"name": "bookId"
},
{
"id": "4:3949362784963767166",
"name": "title"
},
{
"id": "5:812546090900770347",
"name": "description"
},
{
"id": "6:3129463483413863468",
"name": "language"
},
{
"id": "7:3402286918039853548",
"name": "creator"
},
{
"id": "8:4732753967507809221",
"name": "publisher"
},
{
"id": "9:3239042532048399134",
"name": "date"
},
{
"id": "10:1136584919149973914",
"name": "url"
},
{
"id": "11:4252749008345744598",
"name": "articleCount"
},
{
"id": "12:8625493380854102341",
"name": "mediaCount"
},
{
"id": "13:2787210837560254021",
"name": "size"
},
{
"id": "14:2052022387195277817",
"name": "name"
},
{
"id": "15:1976493094677983679",
"name": "favIcon"
},
{
"id": "16:217454020763036675",
"name": "etaInMilliSeconds"
},
{
"id": "17:1136630637198901642",
"name": "bytesDownloaded"
},
{
"id": "18:8939019296899137627",
"name": "totalSizeOfDownload"
},
{
"id": "19:3378789699620971394",
"name": "status"
},
{
"id": "20:6867355950440828062",
"name": "error"
},
{
"id": "21:5555873126720275555",
"name": "file"
},
{
"id": "22:2724607601244650879",
"name": "downloadId"
},
{
"id": "23:5485468735259326535",
"name": "progress"
}
],
"relations": []
}
],
"lastEntityId": "7:7635075139296819361",
"lastEntityId": "8:8093454424037540087",
"lastIndexId": "4:4868787482832538530",
"lastRelationId": "0:0",
"lastSequenceId": "0:0",
"modelVersion": 4,
"modelVersionParserMinimum": 4,
"retiredEntityUids": [
349148274283701276
349148274283701276,
7257718270326155947
],
"retiredIndexUids": [
1293695782925933448,
@ -297,7 +326,23 @@
5620508895870653354,
7273406943564025911,
428251106490095982,
5162677841083528491
5162677841083528491,
7886541039889727771,
2266566996008201697,
1953917250527765737,
6575412958851693470,
1075612111256674117,
2831524841121029990,
2334902404590133038,
5087250349738158996,
6128960350043895299,
3850323036475883785,
5288623325038033644,
2501711400901908648,
3550975911715416030,
8949996430663588693,
7554483297276446029,
8085320504542486236
],
"retiredRelationUids": [],
"version": 1

View File

@ -3,74 +3,6 @@
"_note2": "ObjectBox manages crucial IDs for your object model. See docs for details.",
"_note3": "If you have VCS merge conflicts, you must resolve them according to ObjectBox docs.",
"entities": [
{
"id": "1:7257718270326155947",
"lastPropertyId": "17:8085320504542486236",
"name": "DownloadEntity",
"properties": [
{
"id": "1:2266566996008201697",
"name": "id"
},
{
"id": "2:1953917250527765737",
"name": "downloadId"
},
{
"id": "5:6575412958851693470",
"name": "bookId"
},
{
"id": "6:1075612111256674117",
"name": "title"
},
{
"id": "7:2831524841121029990",
"name": "description"
},
{
"id": "8:2334902404590133038",
"name": "language"
},
{
"id": "9:5087250349738158996",
"name": "creator"
},
{
"id": "10:6128960350043895299",
"name": "publisher"
},
{
"id": "11:3850323036475883785",
"name": "date"
},
{
"id": "12:5288623325038033644",
"name": "url"
},
{
"id": "13:2501711400901908648",
"name": "articleCount"
},
{
"id": "14:3550975911715416030",
"name": "mediaCount"
},
{
"id": "15:8949996430663588693",
"name": "size"
},
{
"id": "16:7554483297276446029",
"name": "name"
},
{
"id": "17:8085320504542486236",
"name": "favIcon"
}
],
"relations": []
},
{
"id": "3:5536749840871435068",
"lastPropertyId": "16:6142333908132117423",
@ -262,16 +194,113 @@
}
],
"relations": []
},
{
"id": "8:8093454424037540087",
"lastPropertyId": "23:5485468735259326535",
"name": "FetchDownloadEntity",
"properties": [
{
"id": "1:7366957113003324901",
"name": "id"
},
{
"id": "3:3174500111130052488",
"name": "bookId"
},
{
"id": "4:3949362784963767166",
"name": "title"
},
{
"id": "5:812546090900770347",
"name": "description"
},
{
"id": "6:3129463483413863468",
"name": "language"
},
{
"id": "7:3402286918039853548",
"name": "creator"
},
{
"id": "8:4732753967507809221",
"name": "publisher"
},
{
"id": "9:3239042532048399134",
"name": "date"
},
{
"id": "10:1136584919149973914",
"name": "url"
},
{
"id": "11:4252749008345744598",
"name": "articleCount"
},
{
"id": "12:8625493380854102341",
"name": "mediaCount"
},
{
"id": "13:2787210837560254021",
"name": "size"
},
{
"id": "14:2052022387195277817",
"name": "name"
},
{
"id": "15:1976493094677983679",
"name": "favIcon"
},
{
"id": "16:217454020763036675",
"name": "etaInMilliSeconds"
},
{
"id": "17:1136630637198901642",
"name": "bytesDownloaded"
},
{
"id": "18:8939019296899137627",
"name": "totalSizeOfDownload"
},
{
"id": "19:3378789699620971394",
"name": "status"
},
{
"id": "20:6867355950440828062",
"name": "error"
},
{
"id": "21:5555873126720275555",
"name": "file"
},
{
"id": "22:2724607601244650879",
"name": "downloadId"
},
{
"id": "23:5485468735259326535",
"name": "progress"
}
],
"relations": []
}
],
"lastEntityId": "7:7635075139296819361",
"lastEntityId": "8:8093454424037540087",
"lastIndexId": "4:4868787482832538530",
"lastRelationId": "0:0",
"lastSequenceId": "0:0",
"modelVersion": 4,
"modelVersionParserMinimum": 4,
"retiredEntityUids": [
349148274283701276
349148274283701276,
7257718270326155947
],
"retiredIndexUids": [
1293695782925933448,
@ -297,7 +326,23 @@
5620508895870653354,
7273406943564025911,
428251106490095982,
5162677841083528491
5162677841083528491,
7886541039889727771,
2266566996008201697,
1953917250527765737,
6575412958851693470,
1075612111256674117,
2831524841121029990,
2334902404590133038,
5087250349738158996,
6128960350043895299,
3850323036475883785,
5288623325038033644,
2501711400901908648,
3550975911715416030,
8949996430663588693,
7554483297276446029,
8085320504542486236
],
"retiredRelationUids": [],
"version": 1

View File

@ -27,7 +27,7 @@ import java.util.Stack
*/
class KiwixMockServer {
val queuedResponses: Stack<MockResponse> = Stack()
var forcedResponse: MockResponse? = null
private val mockWebServer = MockWebServer().apply {
start(TEST_PORT)
@ -44,8 +44,8 @@ class KiwixMockServer {
mockWebServer.setDispatcher(object : Dispatcher() {
override fun dispatch(request: RecordedRequest) =
mapOfPathsToResponses[request.path]?.let(::successfulResponse)
?: queuedResponses.popOrNull()?.let { return@let it }
?: throw RuntimeException("No response mapped for ${request.path}")
?: forcedResponse?.let { return@let it }
?: throw RuntimeException("No response mapped for ${request.path}\nmapped $mapOfPathsToResponses\nqueued $forcedResponse")
})
}
@ -54,8 +54,8 @@ class KiwixMockServer {
setBody(bodyObject.asXmlString())
}
fun queueResponse(mockResponse: MockResponse) {
queuedResponses.push(mockResponse)
fun forceResponse(mockResponse: MockResponse) {
forcedResponse = mockResponse
}
private fun <E> Stack<E>.popOrNull() =

View File

@ -21,10 +21,11 @@ package org.kiwix.kiwixmobile.main;
import android.Manifest;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
import androidx.test.rule.ActivityTestRule;
import androidx.test.rule.GrantPermissionRule;
import com.schibsted.spain.barista.interaction.BaristaMenuClickInteractions;
import com.schibsted.spain.barista.interaction.BaristaSleepInteractions;
import com.schibsted.spain.barista.rule.BaristaRule;
import org.junit.Ignore;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
@ -38,9 +39,9 @@ import static org.kiwix.kiwixmobile.utils.StandardActions.enterSettings;
@LargeTest
@RunWith(AndroidJUnit4.class)
public class MainActivityTest {
@Rule
public BaristaRule<MainActivity> activityTestRule = BaristaRule.create(MainActivity.class);
public ActivityTestRule<MainActivity> activityTestRule =
new ActivityTestRule<>(MainActivity.class);
@Rule
public GrantPermissionRule readPermissionRule =
GrantPermissionRule.grant(Manifest.permission.READ_EXTERNAL_STORAGE);
@ -48,35 +49,28 @@ public class MainActivityTest {
public GrantPermissionRule writePermissionRule =
GrantPermissionRule.grant(Manifest.permission.WRITE_EXTERNAL_STORAGE);
@Test
public void MainActivitySimple() {
}
@Test
public void navigateHelp() {
activityTestRule.launchActivity();
BaristaSleepInteractions.sleep(TEST_PAUSE_MS);
BaristaMenuClickInteractions.clickMenu(getResourceString(R.string.menu_help));
}
@Test
@Ignore("This is hanging on travis")
//TODO fix as part of https://github.com/kiwix/kiwix-android/issues/1428
public void navigateSettings() {
activityTestRule.launchActivity();
BaristaSleepInteractions.sleep(TEST_PAUSE_MS);
enterSettings();
}
@Test
public void navigateBookmarks() {
activityTestRule.launchActivity();
BaristaSleepInteractions.sleep(TEST_PAUSE_MS);
BaristaMenuClickInteractions.clickMenu(getResourceString(R.string.menu_bookmarks));
}
@Test
public void navigateDeviceContent() {
activityTestRule.launchActivity();
BaristaSleepInteractions.sleep(TEST_PAUSE_MS);
BaristaMenuClickInteractions.clickMenu(getResourceString(R.string.menu_zim_manager));
BaristaSleepInteractions.sleep(TEST_PAUSE_MS);
@ -85,7 +79,6 @@ public class MainActivityTest {
@Test
public void navigateOnlineContent() {
activityTestRule.launchActivity();
BaristaSleepInteractions.sleep(TEST_PAUSE_MS);
BaristaMenuClickInteractions.clickMenu(getResourceString(R.string.menu_zim_manager));
BaristaSleepInteractions.sleep(TEST_PAUSE_MS);
@ -94,7 +87,6 @@ public class MainActivityTest {
@Test
public void navigateDownloadingContent() {
activityTestRule.launchActivity();
BaristaSleepInteractions.sleep(TEST_PAUSE_MS);
BaristaMenuClickInteractions.clickMenu(getResourceString(R.string.menu_zim_manager));
BaristaSleepInteractions.sleep(TEST_PAUSE_MS);

View File

@ -20,14 +20,15 @@ package org.kiwix.kiwixmobile.splash;
import android.Manifest;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.preference.PreferenceManager;
import androidx.test.espresso.intent.Intents;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
import androidx.test.rule.ActivityTestRule;
import androidx.test.rule.GrantPermissionRule;
import com.schibsted.spain.barista.interaction.BaristaSleepInteractions;
import com.schibsted.spain.barista.rule.BaristaRule;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
@ -47,8 +48,8 @@ import static org.kiwix.kiwixmobile.utils.SharedPreferenceUtil.PREF_SHOW_INTRO;
@RunWith(AndroidJUnit4.class)
public class SplashActivityTest {
@Rule
public BaristaRule<SplashActivity> activityTestRule = BaristaRule.create(SplashActivity.class);
private ActivityTestRule<SplashActivity> activityTestRule =
new ActivityTestRule<>(SplashActivity.class, true, false);
@Rule
public GrantPermissionRule readPermissionRule =
GrantPermissionRule.grant(Manifest.permission.READ_EXTERNAL_STORAGE);
@ -65,7 +66,8 @@ public class SplashActivityTest {
@Test
public void testFirstRun() {
activityTestRule.launchActivity();
shouldShowIntro(true);
activityTestRule.launchActivity(new Intent());
BaristaSleepInteractions.sleep(TEST_PAUSE_MS);
// Verify that the SplashActivity is followed by IntroActivity
@ -78,11 +80,9 @@ public class SplashActivityTest {
@Test
public void testNormalRun() {
SharedPreferences.Editor preferencesEditor =
PreferenceManager.getDefaultSharedPreferences(context).edit();
preferencesEditor.putBoolean(PREF_SHOW_INTRO, false).apply();
shouldShowIntro(false);
activityTestRule.launchActivity();
activityTestRule.launchActivity(new Intent());
BaristaSleepInteractions.sleep(TEST_PAUSE_MS);
// Verify that the SplashActivity is followed by MainActivity
@ -93,4 +93,10 @@ public class SplashActivityTest {
public void endTest() {
Intents.release();
}
private void shouldShowIntro(boolean value) {
SharedPreferences.Editor preferencesEditor =
PreferenceManager.getDefaultSharedPreferences(context).edit();
preferencesEditor.putBoolean(PREF_SHOW_INTRO, value).apply();
}
}

View File

@ -41,7 +41,7 @@ class ZimManageActivityTest : BaseActivityTest<ZimManageActivity>() {
searchFor(book)
pressBack()
pressBack()
queueMockResponseWith("0123456789")
forceResponse("0123456789")
clickOn(book)
}
clickOnDownloading {
@ -51,7 +51,7 @@ class ZimManageActivityTest : BaseActivityTest<ZimManageActivity>() {
clickPositiveDialogButton()
}
clickOnOnline {
queueMockResponseWith("01234")
forceResponse("01234")
clickOn(book)
}
clickOnDownloading {
@ -72,8 +72,8 @@ class ZimManageActivityTest : BaseActivityTest<ZimManageActivity>() {
} clickOnLanguageIcon { }
}
private fun queueMockResponseWith(body: String) {
mockServer.queueResponse(
private fun forceResponse(body: String) {
mockServer.forceResponse(
MockResponse()
.setBody(body)
.throttleBody(

View File

@ -1,15 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
android:installLocation="auto"
package="org.kiwix.kiwixmobile">
package="org.kiwix.kiwixmobile"
android:installLocation="auto">
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Devices with version >= Oreo need location permission to start/stop the hotspot -->
@ -147,7 +147,6 @@
android:name=".zim_manager.ZimManageActivity"
android:label="@string/choose_file"
android:launchMode="singleTop">
<!-- TODO -->
<intent-filter>
<action android:name="android.intent.action.GET_CONTENT" />
@ -228,12 +227,5 @@
<data android:host="*" />
</intent-filter>
</activity>
<receiver android:name=".zim_manager.DownloadNotificationClickedReceiver">
<intent-filter>
<action android:name="android.intent.action.DOWNLOAD_NOTIFICATION_CLICKED" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

@ -51,6 +51,7 @@ internal object ExternalPaths {
"/mnt/extsd",
"/extsd",
"/mnt/sdcard",
"/misc/android"
"/misc/android",
"/mnt"
)
}

View File

@ -19,67 +19,12 @@
package eu.mhutti1.utils.storage
import android.util.Log
import java.io.BufferedReader
import java.io.File
import java.io.FileReader
import java.io.FileWriter
import java.io.IOException
const val LOCATION_EXTENSION = "storageLocationMarker"
data class StorageDevice(val file: File, val isInternal: Boolean) {
constructor(path: String, internal: Boolean) : this(File(path), internal)
init {
if (file.exists()) {
createLocationCode()
}
}
var isDuplicate = false
private set
val name: String
get() = file.path
// Create unique file to identify duplicate devices.
private fun createLocationCode() {
if (!getLocationCodeFromFolder(file)) {
File(file, ".$LOCATION_EXTENSION").let { locationCode ->
try {
locationCode.createNewFile()
FileWriter(locationCode).use { it.write(file.path) }
} catch (ioException: IOException) {
Log.d("StorageDevice", "could not write file $file", ioException)
}
}
}
}
// Check if there is already a device code in our path
private fun getLocationCodeFromFolder(folder: File): Boolean {
val locationCode = File(folder, ".$LOCATION_EXTENSION")
if (locationCode.exists()) {
try {
BufferedReader(FileReader(locationCode)).use { br ->
if (br.readLine() == file.path) {
isDuplicate = false
} else {
isDuplicate = true
return@getLocationCodeFromFolder true
}
}
} catch (e: Exception) {
return true
}
}
val parent = folder.parentFile
if (parent == null) {
isDuplicate = false
return false
}
return getLocationCodeFromFolder(parent)
}
}

View File

@ -28,15 +28,17 @@ import java.io.RandomAccessFile
import java.util.ArrayList
object StorageDeviceUtils {
@JvmStatic
fun getWritableStorage(context: Context) = validate(externalFilesDirsDevices(context, true), true)
@JvmStatic
fun getStorageDevices(context: Context, writable: Boolean): List<StorageDevice> {
fun getReadableStorage(context: Context): List<StorageDevice> {
val storageDevices = ArrayList<StorageDevice>().apply {
add(environmentDevices(writable))
add(environmentDevices())
addAll(externalMountPointDevices())
addAll(externalFilesDirsDevices(context, writable))
addAll(externalFilesDirsDevices(context, false))
}
return validate(storageDevices, writable)
return validate(storageDevices, false)
}
private fun externalFilesDirsDevices(
@ -44,7 +46,7 @@ object StorageDeviceUtils {
writable: Boolean
) = ContextCompat.getExternalFilesDirs(context, "")
.filterNotNull()
.map { dir -> StorageDevice(generalisePath(dir.path, writable), false) }
.mapIndexed { index, dir -> StorageDevice(generalisePath(dir.path, writable), index == 0) }
private fun externalMountPointDevices(): Collection<StorageDevice> =
ExternalPaths.possiblePaths.fold(mutableListOf(), { acc, path ->
@ -62,11 +64,9 @@ object StorageDeviceUtils {
?.map { dir -> StorageDevice(dir, false) }
.orEmpty()
private fun environmentDevices(
writable: Boolean
) =
private fun environmentDevices() =
StorageDevice(
generalisePath(Environment.getExternalStorageDirectory().path, writable),
generalisePath(Environment.getExternalStorageDirectory().path, false),
Environment.isExternalStorageEmulated()
)
@ -94,26 +94,12 @@ object StorageDeviceUtils {
}
private fun validate(
storageDevices: ArrayList<StorageDevice>,
storageDevices: List<StorageDevice>,
writable: Boolean
) = storageDevices.asSequence().distinct()
) = storageDevices.asSequence()
.filter { it.file.exists() }
.filter { it.file.isDirectory }
.filter { canWrite(it.file) || !writable }
.filterNot(StorageDevice::isDuplicate)
.distinctBy { it.file.canonicalPath }
.filter { !writable || canWrite(it.file) }
.toList()
.also(StorageDeviceUtils::deleteStorageMarkers)
private fun deleteStorageMarkers(validatedDevices: List<StorageDevice>) {
validatedDevices.forEach { recursiveDeleteStorageMarkers(it.file) }
}
private fun recursiveDeleteStorageMarkers(file: File) {
file.listFiles().forEach {
when {
it.isDirectory -> recursiveDeleteStorageMarkers(it)
it.extension == LOCATION_EXTENSION -> it.delete()
}
}
}
}

View File

@ -59,7 +59,7 @@ class StorageSelectDialog : DialogFragment() {
title.text = aTitle
adapter = StorageSelectArrayAdapter(
activity!!,
StorageDeviceUtils.getStorageDevices(activity!!, true),
StorageDeviceUtils.getWritableStorage(activity!!),
storageCalculator
)
device_list.adapter = adapter

View File

@ -35,6 +35,7 @@ import javax.inject.Inject;
import org.kiwix.kiwixmobile.data.local.KiwixDatabase;
import org.kiwix.kiwixmobile.di.components.ApplicationComponent;
import org.kiwix.kiwixmobile.di.components.DaggerApplicationComponent;
import org.kiwix.kiwixmobile.downloader.DownloadMonitor;
public class KiwixApplication extends MultiDexApplication implements HasActivityInjector {
@ -48,6 +49,8 @@ public class KiwixApplication extends MultiDexApplication implements HasActivity
@Inject
DispatchingAndroidInjector<Activity> activityInjector;
@Inject
DownloadMonitor downloadMonitor;
@Inject
KiwixDatabase kiwixDatabase;
public static KiwixApplication getInstance() {
@ -78,6 +81,7 @@ public class KiwixApplication extends MultiDexApplication implements HasActivity
writeLogFile();
applicationComponent.inject(this);
kiwixDatabase.forceMigration();
downloadMonitor.init();
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(buildThreadPolicy(new StrictMode.ThreadPolicy.Builder()));
StrictMode.setVmPolicy(buildVmPolicy(new StrictMode.VmPolicy.Builder()));

View File

@ -0,0 +1,80 @@
/*
* Kiwix Android
* Copyright (C) 2018 Kiwix <android.kiwix.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.kiwix.kiwixmobile.database.newdb.dao
import com.tonyodev.fetch2.Download
import com.tonyodev.fetch2.Status.COMPLETED
import io.objectbox.Box
import io.objectbox.kotlin.equal
import io.objectbox.kotlin.query
import io.reactivex.Flowable
import org.kiwix.kiwixmobile.database.newdb.entities.FetchDownloadEntity
import org.kiwix.kiwixmobile.database.newdb.entities.FetchDownloadEntity_
import org.kiwix.kiwixmobile.downloader.model.DownloadModel
import org.kiwix.kiwixmobile.library.entity.LibraryNetworkEntity.Book
import org.kiwix.kiwixmobile.zim_manager.fileselect_view.adapter.BooksOnDiskListItem.BookOnDisk
import javax.inject.Inject
class FetchDownloadDao @Inject constructor(
private val box: Box<FetchDownloadEntity>,
private val newBookDao: NewBookDao
) {
fun downloads(): Flowable<List<DownloadModel>> =
box.asFlowable()
.distinctUntilChanged()
.doOnNext(::moveCompletedToBooksOnDiskDao)
.map { it.map(::DownloadModel) }
private fun moveCompletedToBooksOnDiskDao(downloadEntities: List<FetchDownloadEntity>) {
downloadEntities.filter { it.status == COMPLETED }.takeIf { it.isNotEmpty() }?.let {
box.remove(it)
newBookDao.insert(it.map(::BookOnDisk))
}
}
fun update(download: Download) {
box.store.callInTx {
getEntityFor(download)?.let { dbEntity ->
dbEntity.updateWith(download)
.takeIf { updatedEntity -> updatedEntity != dbEntity }
?.let(box::put)
}
}
}
private fun getEntityFor(download: Download) =
box.query {
equal(FetchDownloadEntity_.downloadId, download.id)
}.find().getOrNull(0)
fun doesNotAlreadyExist(book: Book) =
box.query {
equal(FetchDownloadEntity_.bookId, book.id)
}.count() == 0L
fun insert(downloadId: Long, book: Book) {
box.put(FetchDownloadEntity(downloadId, book))
}
fun delete(download: Download) {
box.query {
equal(FetchDownloadEntity_.downloadId, download.id)
}.remove()
}
}

View File

@ -1,42 +0,0 @@
package org.kiwix.kiwixmobile.database.newdb.dao
import io.objectbox.Box
import io.objectbox.kotlin.inValues
import io.objectbox.kotlin.query
import org.kiwix.kiwixmobile.database.newdb.entities.DownloadEntity
import org.kiwix.kiwixmobile.database.newdb.entities.DownloadEntity_
import org.kiwix.kiwixmobile.downloader.model.DownloadModel
import org.kiwix.kiwixmobile.library.entity.LibraryNetworkEntity.Book
import javax.inject.Inject
class NewDownloadDao @Inject constructor(private val box: Box<DownloadEntity>) {
fun downloads() = box.asFlowable()
.map { it.map(DownloadEntity::toDownloadModel) }
fun delete(vararg downloadIds: Long) {
box
.query {
inValues(DownloadEntity_.downloadId, downloadIds)
}
.remove()
}
fun containsAny(vararg downloadIds: Long) =
box
.query {
inValues(DownloadEntity_.downloadId, downloadIds)
}
.count() > 0
fun doesNotAlreadyExist(book: Book) =
box
.query {
equal(DownloadEntity_.bookId, book.id)
}
.count() == 0L
fun insert(downloadModel: DownloadModel) {
box.put(DownloadEntity(downloadModel))
}
}

View File

@ -1,78 +0,0 @@
/*
* Kiwix Android
* Copyright (C) 2018 Kiwix <android.kiwix.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.kiwix.kiwixmobile.database.newdb.entities
import io.objectbox.annotation.Entity
import io.objectbox.annotation.Id
import org.kiwix.kiwixmobile.downloader.model.DownloadModel
import org.kiwix.kiwixmobile.library.entity.LibraryNetworkEntity.Book
@Entity
data class DownloadEntity(
@Id var id: Long = 0,
val downloadId: Long,
val bookId: String,
val title: String,
val description: String,
val language: String,
val creator: String,
val publisher: String,
val date: String,
val url: String?,
val articleCount: String?,
val mediaCount: String?,
val size: String,
val name: String?,
val favIcon: String
) {
constructor(downloadModel: DownloadModel) : this(
0,
downloadModel.downloadId,
downloadModel.book.getId(),
downloadModel.book.getTitle(),
downloadModel.book.getDescription(),
downloadModel.book.getLanguage(),
downloadModel.book.getCreator(),
downloadModel.book.getPublisher(),
downloadModel.book.getDate(),
downloadModel.book.getUrl(),
downloadModel.book.getArticleCount(),
downloadModel.book.getMediaCount(),
downloadModel.book.getSize(),
downloadModel.book.name,
downloadModel.book.getFavicon()
)
fun toDownloadModel() = DownloadModel(id, downloadId, toBook())
private fun toBook() = Book().apply {
id = bookId
title = this@DownloadEntity.title
description = this@DownloadEntity.description
language = this@DownloadEntity.language
creator = this@DownloadEntity.creator
publisher = this@DownloadEntity.publisher
date = this@DownloadEntity.date
url = this@DownloadEntity.url
articleCount = this@DownloadEntity.articleCount
mediaCount = this@DownloadEntity.mediaCount
size = this@DownloadEntity.size
bookName = name
favicon = favIcon
}
}

View File

@ -0,0 +1,110 @@
/*
* Kiwix Android
* Copyright (C) 2018 Kiwix <android.kiwix.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.kiwix.kiwixmobile.database.newdb.entities
import com.tonyodev.fetch2.Download
import com.tonyodev.fetch2.Error
import com.tonyodev.fetch2.Status
import io.objectbox.annotation.Convert
import io.objectbox.annotation.Entity
import io.objectbox.annotation.Id
import io.objectbox.converter.PropertyConverter
import org.kiwix.kiwixmobile.library.entity.LibraryNetworkEntity.Book
@Entity
data class FetchDownloadEntity(
@Id var id: Long = 0,
var downloadId: Long,
val file: String? = null,
val etaInMilliSeconds: Long = -1L,
val bytesDownloaded: Long = -1L,
val totalSizeOfDownload: Long = -1L,
@Convert(converter = StatusConverter::class, dbType = Int::class)
val status: Status = Status.NONE,
@Convert(converter = ErrorConverter::class, dbType = Int::class)
val error: Error = Error.NONE,
val progress: Int = -1,
val bookId: String,
val title: String,
val description: String,
val language: String,
val creator: String,
val publisher: String,
val date: String,
val url: String?,
val articleCount: String?,
val mediaCount: String?,
val size: String,
val name: String?,
val favIcon: String
) {
constructor(downloadId: Long, book: Book) : this(
downloadId = downloadId,
bookId = book.getId(),
title = book.getTitle(),
description = book.getDescription(),
language = book.getLanguage(),
creator = book.getCreator(),
publisher = book.getPublisher(),
date = book.getDate(),
url = book.getUrl(),
articleCount = book.getArticleCount(),
mediaCount = book.getMediaCount(),
size = book.getSize(),
name = book.name,
favIcon = book.getFavicon()
)
fun toBook() = Book().apply {
id = bookId
title = this@FetchDownloadEntity.title
description = this@FetchDownloadEntity.description
language = this@FetchDownloadEntity.language
creator = this@FetchDownloadEntity.creator
publisher = this@FetchDownloadEntity.publisher
date = this@FetchDownloadEntity.date
url = this@FetchDownloadEntity.url
articleCount = this@FetchDownloadEntity.articleCount
mediaCount = this@FetchDownloadEntity.mediaCount
size = this@FetchDownloadEntity.size
bookName = name
favicon = favIcon
}
fun updateWith(download: Download) = copy(
file = download.file,
etaInMilliSeconds = download.etaInMilliSeconds,
bytesDownloaded = download.downloaded,
totalSizeOfDownload = download.total,
status = download.status,
error = download.error,
progress = download.progress
)
}
class StatusConverter : EnumConverter<Status>() {
override fun convertToEntityProperty(databaseValue: Int) = Status.valueOf(databaseValue)
}
class ErrorConverter : EnumConverter<Error>() {
override fun convertToEntityProperty(databaseValue: Int) = Error.valueOf(databaseValue)
}
abstract class EnumConverter<E : Enum<E>> : PropertyConverter<E, Int> {
override fun convertToDatabaseValue(entityProperty: E): Int = entityProperty.ordinal
}

View File

@ -29,12 +29,10 @@ import org.kiwix.kiwixmobile.data.ZimContentProvider;
import org.kiwix.kiwixmobile.di.modules.ApplicationModule;
import org.kiwix.kiwixmobile.di.modules.JNIModule;
import org.kiwix.kiwixmobile.di.modules.NetworkModule;
import org.kiwix.kiwixmobile.downloader.DownloadService;
import org.kiwix.kiwixmobile.language.LanguageActivity;
import org.kiwix.kiwixmobile.main.KiwixWebView;
import org.kiwix.kiwixmobile.search.AutoCompleteAdapter;
import org.kiwix.kiwixmobile.settings.PrefsFragment;
import org.kiwix.kiwixmobile.zim_manager.DownloadNotificationClickedReceiver;
import org.kiwix.kiwixmobile.zim_manager.ZimManageActivity;
@Singleton
@ -60,8 +58,6 @@ public interface ApplicationComponent {
void inject(KiwixApplication application);
void inject(DownloadService service);
void inject(ZimContentProvider zimContentProvider);
void inject(KiwixWebView kiwixWebView);
@ -70,8 +66,6 @@ public interface ApplicationComponent {
void inject(AutoCompleteAdapter autoCompleteAdapter);
void inject(DownloadNotificationClickedReceiver downloadNotificationClickedReceiver);
void inject(@NotNull ZimManageActivity zimManageActivity);
void inject(@NotNull LanguageActivity languageActivity);

View File

@ -33,7 +33,8 @@ import javax.inject.Singleton;
import org.kiwix.kiwixmobile.di.qualifiers.Computation;
import org.kiwix.kiwixmobile.di.qualifiers.IO;
import org.kiwix.kiwixmobile.di.qualifiers.MainThread;
import org.kiwix.kiwixmobile.downloader.model.UriToFileConverter;
import org.kiwix.kiwixmobile.downloader.DownloadMonitor;
import org.kiwix.kiwixmobile.downloader.fetch.FetchDownloadMonitor;
import org.kiwix.kiwixmobile.utils.BookUtils;
@Module(includes = {
@ -84,13 +85,13 @@ public class ApplicationModule {
}
@Provides @Singleton
UriToFileConverter provideUriToFIleCOnverter() {
return new UriToFileConverter.Impl();
LocationManager provideLocationManager(Context context) {
return (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);
}
@Provides @Singleton
LocationManager provideLocationManager(Context context) {
return (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);
DownloadMonitor provideDownloadMonitor(FetchDownloadMonitor fetchDownloadMonitor) {
return fetchDownloadMonitor;
}
@Provides @Singleton

View File

@ -22,31 +22,29 @@ import dagger.Module
import dagger.Provides
import io.objectbox.BoxStore
import io.objectbox.kotlin.boxFor
import org.kiwix.kiwixmobile.database.newdb.dao.FetchDownloadDao
import org.kiwix.kiwixmobile.database.newdb.dao.HistoryDao
import org.kiwix.kiwixmobile.database.newdb.dao.NewBookDao
import org.kiwix.kiwixmobile.database.newdb.dao.NewBookmarksDao
import org.kiwix.kiwixmobile.database.newdb.dao.NewDownloadDao
import org.kiwix.kiwixmobile.database.newdb.dao.NewLanguagesDao
import org.kiwix.kiwixmobile.database.newdb.dao.NewRecentSearchDao
import org.kiwix.kiwixmobile.database.newdb.entities.MyObjectBox
import javax.inject.Singleton
@Module
class DatabaseModule {
open class DatabaseModule {
companion object {
var boxStore: BoxStore? = null
}
// NOT RECOMMENDED TODO use custom runner to load TestApplication
@Provides @Singleton fun providesBoxStore(context: Context): BoxStore {
if (boxStore == null) {
boxStore = MyObjectBox.builder().androidContext(context.applicationContext).build()
boxStore = MyObjectBox.builder().androidContext(context).build()
}
return boxStore!!
}
@Provides @Singleton fun providesNewDownloadDao(boxStore: BoxStore): NewDownloadDao =
NewDownloadDao(boxStore.boxFor())
@Provides @Singleton fun providesNewBookDao(boxStore: BoxStore): NewBookDao =
NewBookDao(boxStore.boxFor())
@ -61,4 +59,10 @@ class DatabaseModule {
@Provides @Singleton fun providesNewRecentSearchDao(boxStore: BoxStore): NewRecentSearchDao =
NewRecentSearchDao(boxStore.boxFor())
@Provides @Singleton fun providesFetchDownloadDao(
boxStore: BoxStore,
newBookDao: NewBookDao
): FetchDownloadDao =
FetchDownloadDao(boxStore.boxFor(), newBookDao)
}

View File

@ -17,18 +17,75 @@
*/
package org.kiwix.kiwixmobile.di.modules
import dagger.Binds
import android.content.Context
import com.tonyodev.fetch2.Fetch
import com.tonyodev.fetch2.Fetch.Impl
import com.tonyodev.fetch2.FetchConfiguration
import com.tonyodev.fetch2.FetchNotificationManager
import com.tonyodev.fetch2okhttp.OkHttpDownloader
import dagger.Module
import org.kiwix.kiwixmobile.downloader.DownloadManagerRequester
import dagger.Provides
import okhttp3.OkHttpClient
import org.kiwix.kiwixmobile.BuildConfig
import org.kiwix.kiwixmobile.data.remote.KiwixService
import org.kiwix.kiwixmobile.database.newdb.dao.FetchDownloadDao
import org.kiwix.kiwixmobile.downloader.DownloadRequester
import org.kiwix.kiwixmobile.downloader.Downloader
import org.kiwix.kiwixmobile.downloader.DownloaderImpl
import org.kiwix.kiwixmobile.downloader.fetch.FetchDownloadNotificationManager
import org.kiwix.kiwixmobile.downloader.fetch.FetchDownloadRequester
import org.kiwix.kiwixmobile.utils.SharedPreferenceUtil
import javax.inject.Singleton
@Module
abstract class DownloaderModule {
@Binds
abstract fun bindDownloader(downloaderImpl: DownloaderImpl): Downloader
object DownloaderModule {
@JvmStatic
@Provides
@Singleton
fun providesDownloader(
downloadRequester: DownloadRequester,
downloadDao: FetchDownloadDao,
kiwixService: KiwixService
): Downloader = DownloaderImpl(downloadRequester, downloadDao, kiwixService)
@Binds
abstract fun bindDownloaderRequester(downloaderImpl: DownloadManagerRequester): DownloadRequester
@JvmStatic
@Provides
@Singleton
fun providesDownloadRequester(
fetch: Fetch,
sharedPreferenceUtil: SharedPreferenceUtil
): DownloadRequester = FetchDownloadRequester(fetch, sharedPreferenceUtil)
@JvmStatic
@Provides
@Singleton
fun provideFetch(fetchConfiguration: FetchConfiguration): Fetch =
Fetch.getInstance(fetchConfiguration)
@JvmStatic
@Provides
@Singleton
fun provideFetchConfiguration(
context: Context,
okHttpDownloader: OkHttpDownloader,
fetchNotificationManager: FetchNotificationManager
): FetchConfiguration =
FetchConfiguration.Builder(context).apply {
setDownloadConcurrentLimit(5)
enableLogging(BuildConfig.DEBUG)
enableRetryOnNetworkGain(true)
setHttpDownloader(okHttpDownloader)
setNotificationManager(fetchNotificationManager)
}.build().also(Impl::setDefaultInstanceConfiguration)
@JvmStatic
@Provides
@Singleton
fun provideOkHttpDownloader() = OkHttpDownloader(OkHttpClient.Builder().build())
@JvmStatic
@Provides
@Singleton
fun provideFetchDownloadNotificationManager(context: Context): FetchNotificationManager =
FetchDownloadNotificationManager(context)
}

View File

@ -1,90 +0,0 @@
/*
* Kiwix Android
* Copyright (C) 2018 Kiwix <android.kiwix.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.kiwix.kiwixmobile.downloader
import android.app.DownloadManager
import android.app.DownloadManager.Request
import android.net.Uri
import android.os.Build
import org.kiwix.kiwixmobile.downloader.model.DownloadItem
import org.kiwix.kiwixmobile.downloader.model.DownloadModel
import org.kiwix.kiwixmobile.downloader.model.DownloadRequest
import org.kiwix.kiwixmobile.downloader.model.DownloadStatus
import org.kiwix.kiwixmobile.extensions.forEachRow
import org.kiwix.kiwixmobile.utils.SharedPreferenceUtil
import org.kiwix.kiwixmobile.utils.StorageUtils
import java.io.File
import javax.inject.Inject
class DownloadManagerRequester @Inject constructor(
private val downloadManager: DownloadManager,
private val sharedPreferenceUtil: SharedPreferenceUtil
) : DownloadRequester {
override fun enqueue(downloadRequest: DownloadRequest) =
downloadManager.enqueue(downloadRequest.toDownloadManagerRequest(sharedPreferenceUtil))
override fun query(downloadModels: List<DownloadModel>): List<DownloadStatus> {
val downloadStatuses = mutableListOf<DownloadStatus>()
if (downloadModels.isNotEmpty()) {
downloadModels.forEach { model ->
downloadManager.query(model.toQuery())
.forEachRow {
downloadStatuses.add(DownloadStatus(it, model))
}
}
}
return downloadStatuses
}
override fun cancel(downloadItem: DownloadItem) {
downloadManager.remove(downloadItem.downloadId)
}
private fun DownloadRequest.toDownloadManagerRequest(sharedPreferenceUtil: SharedPreferenceUtil) =
Request(uri).apply {
setAllowedNetworkTypes(
if (sharedPreferenceUtil.prefWifiOnly) {
Request.NETWORK_WIFI
} else {
Request.NETWORK_MOBILE or Request.NETWORK_WIFI
}
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
setAllowedOverMetered(true)
}
setAllowedOverRoaming(true)
setTitle(title)
setDescription(description)
setDestinationUri(toDestinationUri(sharedPreferenceUtil))
setNotificationVisibility(Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
setVisibleInDownloadsUi(true)
}
private fun DownloadRequest.toDestinationUri(sharedPreferenceUtil: SharedPreferenceUtil) =
Uri.fromFile(
File(
"${sharedPreferenceUtil.prefStorage}/Kiwix/${
StorageUtils.getFileNameFromUrl(urlString)
}"
)
)
private fun DownloadModel.toQuery() =
DownloadManager.Query().setFilterById(downloadId)
}

View File

@ -0,0 +1,22 @@
/*
* Kiwix Android
* Copyright (C) 2018 Kiwix <android.kiwix.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.kiwix.kiwixmobile.downloader
interface DownloadMonitor {
fun init()
}

View File

@ -18,12 +18,9 @@
package org.kiwix.kiwixmobile.downloader
import org.kiwix.kiwixmobile.downloader.model.DownloadItem
import org.kiwix.kiwixmobile.downloader.model.DownloadModel
import org.kiwix.kiwixmobile.downloader.model.DownloadRequest
import org.kiwix.kiwixmobile.downloader.model.DownloadStatus
interface DownloadRequester {
fun enqueue(downloadRequest: DownloadRequest): Long
fun query(downloadModels: List<DownloadModel>): List<DownloadStatus>
fun cancel(downloadItem: DownloadItem)
}

View File

@ -1,719 +0,0 @@
/*
* Kiwix Android
* Copyright (C) 2018 Kiwix <android.kiwix.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.kiwix.kiwixmobile.downloader;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Intent;
import android.graphics.Color;
import android.os.Binder;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.util.Log;
import android.util.Pair;
import android.util.SparseArray;
import android.util.SparseIntArray;
import android.widget.Toast;
import androidx.core.app.NotificationCompat;
import io.reactivex.Observable;
import io.reactivex.Observer;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.Disposable;
import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
import javax.inject.Inject;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okio.BufferedSource;
import org.kiwix.kiwixmobile.KiwixApplication;
import org.kiwix.kiwixmobile.R;
import org.kiwix.kiwixmobile.data.DataSource;
import org.kiwix.kiwixmobile.data.remote.KiwixService;
import org.kiwix.kiwixmobile.database.newdb.dao.NewBookDao;
import org.kiwix.kiwixmobile.library.entity.LibraryNetworkEntity;
import org.kiwix.kiwixmobile.main.MainActivity;
import org.kiwix.kiwixmobile.utils.Constants;
import org.kiwix.kiwixmobile.utils.NetworkUtils;
import org.kiwix.kiwixmobile.utils.SharedPreferenceUtil;
import org.kiwix.kiwixmobile.utils.StorageUtils;
import org.kiwix.kiwixmobile.utils.TestingUtils;
import org.kiwix.kiwixmobile.utils.files.FileUtils;
import org.kiwix.kiwixmobile.zim_manager.ZimManageActivity;
import static org.kiwix.kiwixmobile.downloader.ChunkUtils.ALPHABET;
import static org.kiwix.kiwixmobile.downloader.ChunkUtils.PART;
import static org.kiwix.kiwixmobile.downloader.ChunkUtils.ZIM_EXTENSION;
import static org.kiwix.kiwixmobile.utils.Constants.EXTRA_BOOK;
import static org.kiwix.kiwixmobile.utils.Constants.EXTRA_LIBRARY;
import static org.kiwix.kiwixmobile.utils.Constants.EXTRA_NOTIFICATION_ID;
import static org.kiwix.kiwixmobile.utils.Constants.EXTRA_ZIM_FILE;
import static org.kiwix.kiwixmobile.utils.Constants.ONGOING_DOWNLOAD_CHANNEL_ID;
@Deprecated
public class DownloadService extends Service {
public static final int PLAY = 1;
public static final int PAUSE = 2;
public static final int FINISH = 3;
public static final int CANCEL = 4;
public static final String ACTION_PAUSE = "PAUSE";
public static final String ACTION_STOP = "STOP";
public static final String ACTION_NO_WIFI = "NO_WIFI";
public static final String NOTIFICATION_ID = "NOTIFICATION_ID";
public static final String NOTIFICATION_TITLE_KEY = "NOTIFICATION_TITLE_KEY";
public static final Object pauseLock = new Object();
// 1024 / 100
private static final double BOOK_SIZE_OFFSET = 10.24;
private static final String KIWIX_TAG = "kiwixdownloadservice";
public static String KIWIX_ROOT;
public static ArrayList<String> notifications = new ArrayList<>();
private static String SD_CARD;
private static DownloadFragment downloadFragment;
private final IBinder mBinder = new LocalBinder();
public String notificationTitle;
public SparseIntArray downloadStatus = new SparseIntArray();
public SparseIntArray downloadProgress = new SparseIntArray();
public SparseIntArray timeRemaining = new SparseIntArray();
@Inject
KiwixService kiwixService;
@Inject
OkHttpClient httpClient;
@Inject
NotificationManager notificationManager;
Handler handler = new Handler(Looper.getMainLooper());
@Inject
SharedPreferenceUtil sharedPreferenceUtil;
@Inject
NewBookDao bookDao;
@Inject
DataSource dataSource;
private SparseArray<NotificationCompat.Builder> notification = new SparseArray<>();
public static void setDownloadFragment(DownloadFragment dFragment) {
downloadFragment = dFragment;
}
@Override
public void onCreate() {
KiwixApplication.getApplicationComponent().inject(this);
SD_CARD = sharedPreferenceUtil.getPrefStorage();
KIWIX_ROOT = SD_CARD + "/Kiwix/";
KIWIX_ROOT = checkWritable(KIWIX_ROOT);
createOngoingDownloadChannel();
super.onCreate();
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent == null) {
return START_NOT_STICKY;
}
String log = intent.getAction() + " : ";
if (intent.hasExtra(NOTIFICATION_ID)) {
log += intent.getIntExtra(NOTIFICATION_ID, -3);
}
Log.d(KIWIX_TAG, log);
if (intent.hasExtra(NOTIFICATION_ID) && intent.getAction().equals(ACTION_STOP)) {
stopDownload(intent.getIntExtra(NOTIFICATION_ID, 0));
return START_NOT_STICKY;
}
if (intent.hasExtra(NOTIFICATION_ID) && (intent.getAction().equals(ACTION_PAUSE))) {
if (MainActivity.wifiOnly && !NetworkUtils.isWiFi(getApplicationContext())) {
Log.i(KIWIX_TAG, "Not connected to WiFi, and wifiOnly is enabled");
startActivity(new Intent(this, ZimManageActivity.class).setAction(ACTION_NO_WIFI)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
this.sendBroadcast(new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS));
} else {
toggleDownload(intent.getIntExtra(NOTIFICATION_ID, 0));
}
return START_NOT_STICKY;
}
SD_CARD = sharedPreferenceUtil.getPrefStorage();
KIWIX_ROOT = SD_CARD + "/Kiwix/";
KIWIX_ROOT = checkWritable(KIWIX_ROOT);
Log.d(KIWIX_TAG, "Using KIWIX_ROOT: " + KIWIX_ROOT);
notificationTitle = intent.getExtras().getString(DownloadIntent.DOWNLOAD_ZIM_TITLE);
LibraryNetworkEntity.Book book =
(LibraryNetworkEntity.Book) intent.getSerializableExtra(EXTRA_BOOK);
int notificationID = book.getId().hashCode();
if (downloadStatus.get(notificationID, -1) == PAUSE
|| downloadStatus.get(notificationID, -1) == PLAY) {
return START_NOT_STICKY;
}
notifications.add(notificationTitle);
final Intent target = new Intent(this, MainActivity.class);
target.putExtra(EXTRA_LIBRARY, true);
PendingIntent pendingIntent = PendingIntent.getActivity
(getBaseContext(), notificationID,
target, PendingIntent.FLAG_CANCEL_CURRENT);
Intent pauseIntent = new Intent(this, this.getClass()).setAction(ACTION_PAUSE)
.putExtra(NOTIFICATION_ID, notificationID);
Intent stopIntent = new Intent(this, this.getClass()).setAction(ACTION_STOP)
.putExtra(NOTIFICATION_ID, notificationID);
PendingIntent pausePending =
PendingIntent.getService(getBaseContext(), notificationID, pauseIntent,
PendingIntent.FLAG_CANCEL_CURRENT);
PendingIntent stopPending =
PendingIntent.getService(getBaseContext(), notificationID, stopIntent,
PendingIntent.FLAG_CANCEL_CURRENT);
NotificationCompat.Action pause = new NotificationCompat.Action(R.drawable.ic_pause_black_24dp,
getString(R.string.download_pause), pausePending);
NotificationCompat.Action stop = new NotificationCompat.Action(R.drawable.ic_stop_black_24dp,
getString(R.string.download_stop), stopPending);
if (flags == START_FLAG_REDELIVERY && book.file == null) {
return START_NOT_STICKY;
} else {
notification.put(notificationID,
new NotificationCompat.Builder(this, ONGOING_DOWNLOAD_CHANNEL_ID)
.setContentTitle(
getResources().getString(R.string.zim_file_downloading) + " " + notificationTitle)
.setProgress(100, 0, false)
.setSmallIcon(R.drawable.kiwix_notification)
.setColor(Color.BLACK)
.setContentIntent(pendingIntent)
.addAction(pause)
.addAction(stop)
.setOngoing(true));
Bundle bundle = new Bundle();
bundle.putString(NOTIFICATION_TITLE_KEY, notificationTitle);
notification.get(notificationID).addExtras(bundle);
notificationManager.notify(notificationID, notification.get(notificationID).build());
downloadStatus.put(notificationID, PLAY);
//LibraryFragment.downloadingBooks.remove(book);
String url = intent.getExtras().getString(DownloadIntent.DOWNLOAD_URL_PARAMETER);
downloadBook(url, notificationID, book);
}
return START_REDELIVER_INTENT;
}
public void stopDownload(int notificationID) {
Log.i(KIWIX_TAG, "Stopping ZIM Download for notificationID: " + notificationID);
downloadStatus.put(notificationID, CANCEL);
synchronized (pauseLock) {
pauseLock.notify();
}
//if (!DownloadFragment.downloads.isEmpty()) {
// DownloadFragment.downloads.remove(notificationID);
// DownloadFragment.downloadFiles.remove(notificationID);
// DownloadFragment.downloadAdapter.notifyDataSetChanged();
//}
updateForeground();
notificationManager.cancel(notificationID);
}
public void cancelNotification(int notificationID) {
if (notificationManager != null) {
notificationManager.cancel(notificationID);
}
}
public String checkWritable(String path) {
try {
File f = new File(path);
f.mkdir();
if (f.canWrite()) {
return path;
}
Toast.makeText(this, getResources().getString(R.string.path_not_writable), Toast.LENGTH_LONG)
.show();
return Environment.getExternalStorageDirectory().getPath();
} catch (Exception e) {
Toast.makeText(this, getResources().getString(R.string.path_not_writable), Toast.LENGTH_LONG)
.show();
return Environment.getExternalStorageDirectory().getPath();
}
}
public void toggleDownload(int notificationID) {
if (downloadStatus.get(notificationID) == PAUSE) {
playDownload(notificationID);
} else {
pauseDownload(notificationID);
}
}
public void pauseDownload(int notificationID) {
Log.i(KIWIX_TAG, "Pausing ZIM Download for notificationID: " + notificationID);
downloadStatus.put(notificationID, PAUSE);
//notification.get(notificationID).mActions.get(0).title = getString(R.string.download_resume);
//notification.get(notificationID).mActions.get(0).icon = R.drawable.ic_play_arrow_black_24dp;
notification.get(notificationID).setContentText(getString(R.string.download_paused));
notificationManager.notify(notificationID, notification.get(notificationID).build());
// if (DownloadFragment.downloadAdapter != null) {
// DownloadFragment.downloadAdapter.notifyDataSetChanged();
// downloadFragment.listView.invalidateViews();
// }
}
public boolean playDownload(int notificationID) {
Log.i(KIWIX_TAG, "Starting ZIM Download for notificationID: " + notificationID);
downloadStatus.put(notificationID, PLAY);
synchronized (pauseLock) {
pauseLock.notify();
}
// notification.get(notificationID).mActions.get(0).title = getString(R.string.download_pause);
// notification.get(notificationID).mActions.get(0).icon = R.drawable.ic_pause_black_24dp;
notification.get(notificationID).setContentText("");
notificationManager.notify(notificationID, notification.get(notificationID).build());
// if (DownloadFragment.downloadAdapter != null) {
// DownloadFragment.downloadAdapter.notifyDataSetChanged();
// downloadFragment.listView.invalidateViews();
// }
return true;
}
private void downloadBook(String url, int notificationID, LibraryNetworkEntity.Book book) {
//if (downloadFragment != null) {
// downloadFragment.addDownload(notificationID, book,
// KIWIX_ROOT + StorageUtils.getFileNameFromUrl(book.getUrl()));
//}
TestingUtils.bindResource(DownloadService.class);
if (book.file != null && (book.file.exists() || new File(
book.file.getPath() + ".part").exists())) {
// Calculate initial download progress
int initial =
(int) (FileUtils.getCurrentSize(book) / (Long.valueOf(book.getSize()) * BOOK_SIZE_OFFSET));
notification.get(notificationID).setProgress(100, initial, false);
updateDownloadFragmentProgress(initial, notificationID);
notificationManager.notify(notificationID, notification.get(notificationID).build());
}
kiwixService.getMetaLinks(url)
.retryWhen(errors -> errors.flatMap(error -> Observable.timer(5, TimeUnit.SECONDS)))
.subscribeOn(AndroidSchedulers.mainThread())
.flatMap(metaLink -> getMetaLinkContentLength(metaLink.getRelevantUrl().getValue()))
.flatMap(pair -> Observable.fromIterable(
ChunkUtils.getChunks(pair.first, pair.second, notificationID)))
.concatMap(this::downloadChunk)
.distinctUntilChanged().doOnComplete(() -> updateDownloadFragmentComplete(notificationID))
.subscribe(new Observer<Integer>() {
@Override
public void onSubscribe(Disposable d) {
}
@Override
public void onNext(Integer progress) {
if (progress == 100) {
notification.get(notificationID).setOngoing(false);
Bundle b = notification.get(notificationID).getExtras();
notification.get(notificationID)
.setContentTitle(
b.getString(NOTIFICATION_TITLE_KEY) + " " + getResources().getString(
R.string.zim_file_downloaded));
notification.get(notificationID).getExtras();
notification.get(notificationID)
.setContentText(getString(R.string.zim_file_downloaded));
final Intent target = new Intent(DownloadService.this, MainActivity.class);
target.putExtra(EXTRA_ZIM_FILE,
KIWIX_ROOT + StorageUtils.getFileNameFromUrl(book.getUrl()));
//Remove the extra ".part" from files
String filename = book.file.getPath();
if (filename.endsWith(ZIM_EXTENSION)) {
filename = filename + PART;
File partFile = new File(filename);
if (partFile.exists()) {
partFile.renameTo(new File(partFile.getPath().replaceAll(".part", "")));
}
} else {
for (int i = 0; true; i++) {
char first = ALPHABET.charAt(i / 26);
char second = ALPHABET.charAt(i % 26);
String chunkExtension = String.valueOf(first) + second;
filename = book.file.getPath();
filename = filename.replaceAll(".zim([a-z][a-z]){0,1}$", ".zim");
filename = filename + chunkExtension + ".part";
File partFile = new File(filename);
if (partFile.exists()) {
partFile.renameTo(new File(partFile.getPath().replaceAll(".part$", "")));
} else {
File lastChunkFile = new File(filename + ".part");
if (lastChunkFile.exists()) {
lastChunkFile.renameTo(new File(partFile.getPath().replaceAll(".part", "")));
} else {
break;
}
}
}
}
target.putExtra(EXTRA_NOTIFICATION_ID, notificationID);
target.setAction(Long.toString(System.currentTimeMillis()));
PendingIntent pendingIntent = PendingIntent.getActivity
(getBaseContext(), 0,
target, PendingIntent.FLAG_ONE_SHOT);
//book.downloaded = true;
//dataSource.deleteBook(book)
// .subscribe(new CompletableObserver() {
// @Override
// public void onSubscribe(Disposable d) {
//
// }
//
// @Override
// public void onComplete() {
//
// }
//
// @Override
// public void onError(Throwable e) {
// Log.e("DownloadService", "Unable to delete book", e);
// }
// });
notification.get(notificationID).setContentIntent(pendingIntent);
//notification.get(notificationID).mActions.clear();
TestingUtils.unbindResource(DownloadService.class);
}
notification.get(notificationID).setProgress(100, progress, false);
if (progress != 100 && timeRemaining.get(notificationID) != -1) {
//notification.get(notificationID)
// .setContentText(
// DownloadFragment.toHumanReadableTime(timeRemaining.get(notificationID)));
}
notificationManager.notify(notificationID, notification.get(notificationID).build());
if (progress == 0 || progress == 100) {
// Tells android to not kill the service
updateForeground();
}
updateDownloadFragmentProgress(progress, notificationID);
if (progress == 100) {
stopSelf();
}
}
@Override
public void onError(Throwable e) {
}
@Override
public void onComplete() {
}
});
}
private void updateDownloadFragmentProgress(int progress, int notificationID) {
//if (DownloadFragment.downloads != null
// && DownloadFragment.downloads.get(notificationID) != null) {
// handler.post(() -> {
// if (DownloadFragment.downloads.get(notificationID) != null) {
// DownloadFragment.downloadAdapter.updateProgress(progress, notificationID);
// }
// });
//}
}
private void updateDownloadFragmentComplete(int notificationID) {
//if (DownloadFragment.downloads != null
// && DownloadFragment.downloads.get(notificationID) != null) {
// handler.post(() -> {
// if (DownloadFragment.downloads.get(notificationID) != null) {
// DownloadFragment.downloadAdapter.complete(notificationID);
// }
// });
//}
}
private void updateForeground() {
// Allow notification to be dismissible while ensuring integrity of service if active downloads
stopForeground(true);
for (int i = 0; i < downloadStatus.size(); i++) {
if (downloadStatus.get(i) == PLAY && downloadStatus.get(i) == PAUSE) {
startForeground(downloadStatus.keyAt(i), notification.get(downloadStatus.keyAt(i)).build());
}
}
}
private Observable<Pair<String, Long>> getMetaLinkContentLength(String url) {
Log.d("KiwixDownloadSSL", "url=" + url);
final String urlToUse = UseHttpOnAndroidVersion4(url);
return Observable.create(subscriber -> {
try {
Request request = new Request.Builder().url(urlToUse).head().build();
Response response = httpClient.newCall(request).execute();
String LengthHeader = response.headers().get("Content-Length");
long contentLength = LengthHeader == null ? 0 : Long.parseLong(LengthHeader);
subscriber.onNext(new Pair<>(urlToUse, contentLength));
subscriber.onComplete();
if (!response.isSuccessful()) subscriber.onError(new Exception(response.message()));
} catch (IOException e) {
subscriber.onError(e);
}
});
}
private String UseHttpOnAndroidVersion4(String sourceUrl) {
// Simply return the current URL on newer builds of Android
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
return sourceUrl;
}
// Otherwise replace https with http to bypass Android 4.x devices having older certificates
// See https://github.com/kiwix/kiwix-android/issues/510 for details
try {
URL tempURL = new URL(sourceUrl);
String androidV4URL = "http" + sourceUrl.substring(tempURL.getProtocol().length());
Log.d("KiwixDownloadSSL", "replacement_url=" + androidV4URL);
return androidV4URL;
} catch (MalformedURLException e) {
return sourceUrl;
}
}
private Observable<Integer> downloadChunk(Chunk chunk) {
return Observable.create(subscriber -> {
try {
// Stop if download is completed or download canceled
if (chunk.isDownloaded || downloadStatus.get(chunk.getNotificationID()) == CANCEL) {
subscriber.onComplete();
return;
}
// Create chunk file
File file = new File(KIWIX_ROOT, chunk.getFileName());
file.getParentFile().mkdirs();
File fullFile =
new File(file.getPath().substring(0, file.getPath().length() - PART.length()));
long downloaded = Long.parseLong(chunk.getRangeHeader().split("-")[0]);
if (fullFile.exists() && fullFile.length() == chunk.getSize()) {
// Mark chunk status as downloaded
chunk.isDownloaded = true;
subscriber.onComplete();
return;
} else if (!file.exists()) {
file.createNewFile();
}
RandomAccessFile output = new RandomAccessFile(file, "rw");
output.seek(output.length());
downloaded += output.length();
if (chunk.getStartByte() == 0) {
//if (!DownloadFragment.downloads.isEmpty()) {
// LibraryNetworkEntity.Book book = DownloadFragment.downloads
// .get(chunk.getNotificationID());
// book.remoteUrl = book.getUrl();
// book.file = fullFile;
// dataSource.saveBook(book)
// .subscribe(new CompletableObserver() {
// @Override
// public void onSubscribe(Disposable d) {
//
// }
//
// @Override
// public void onComplete() {
//
// }
//
// @Override
// public void onError(Throwable e) {
// Log.e("DownloadService", "Unable to save book", e);
// }
// });
//}
downloadStatus.put(chunk.getNotificationID(), PLAY);
downloadProgress.put(chunk.getNotificationID(), 0);
}
byte[] buffer = new byte[2048];
int read;
int timeout = 100;
int attempts = 0;
BufferedSource input = null;
// Keep attempting to download chunk despite network errors
while (attempts < timeout) {
try {
String rangeHeader = String.format(Locale.US, "%d-%d", downloaded, chunk.getEndByte());
// Build request with up to date range
Response response = httpClient.newCall(
new Request.Builder()
.url(chunk.getUrl())
.header("Range", "bytes=" + rangeHeader)
.build()
).execute();
// Check that the server is sending us the right file
if (Math.abs(chunk.getEndByte() - downloaded - response.body().contentLength()) > 10) {
throw new Exception("Server broadcasting wrong size");
}
input = response.body().source();
Log.d("kiwixdownloadservice", "Got valid chunk");
long lastTime = System.currentTimeMillis();
long lastSize = 0;
// Start streaming data
while ((read = input.read(buffer)) != -1) {
if (downloadStatus.get(chunk.getNotificationID()) == CANCEL) {
attempts = timeout;
break;
}
if (MainActivity.wifiOnly && !NetworkUtils.isWiFi(getApplicationContext()) ||
!NetworkUtils.isNetworkAvailable(getApplicationContext())) {
pauseDownload(chunk.getNotificationID());
}
if (downloadStatus.get(chunk.getNotificationID()) == PAUSE) {
synchronized (pauseLock) {
try {
timeRemaining.put(chunk.getNotificationID(), -1);
// Calling wait() will block this thread until another thread
// calls notify() on the object.
pauseLock.wait();
lastTime = System.currentTimeMillis();
lastSize = downloaded;
} catch (InterruptedException e) {
// Happens if someone interrupts your thread.
}
}
}
downloaded += read;
long timeDiff = System.currentTimeMillis() - lastTime;
if (timeDiff >= 1000) {
lastTime = System.currentTimeMillis();
double speed = (downloaded - lastSize) / (timeDiff / 1000.0);
lastSize = downloaded;
int secondsLeft = (int) ((chunk.getContentLength() - downloaded) / speed);
timeRemaining.put(chunk.getNotificationID(), secondsLeft);
}
output.write(buffer, 0, read);
int progress = (int) ((100 * downloaded) / chunk.getContentLength());
downloadProgress.put(chunk.getNotificationID(), progress);
if (progress == 100) {
downloadStatus.put(chunk.getNotificationID(), FINISH);
}
subscriber.onNext(progress);
}
attempts = timeout;
} catch (Exception e) {
// Retry on network error
attempts++;
Log.d(KIWIX_TAG, "Download Attempt Failed [" + attempts + "] times", e);
try {
Thread.sleep(1000 * attempts); // The more unsuccessful attempts the longer the wait
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
}
if (input != null) {
input.close();
}
// If download is canceled clean up else remove .part from file name
if (downloadStatus.get(chunk.getNotificationID()) == CANCEL) {
String path = file.getPath();
Log.i(KIWIX_TAG, "Download Cancelled, deleting file: " + path);
if (path.substring(path.length() - (ZIM_EXTENSION + PART).length())
.equals(ZIM_EXTENSION + PART)) {
path = path.substring(0, path.length() - PART.length() + 1);
FileUtils.deleteZimFile(path);
} else {
path = path.substring(0, path.length() - (ZIM_EXTENSION + PART).length() + 2) + "aa";
FileUtils.deleteZimFile(path);
}
} else {
Log.i(KIWIX_TAG,
"Download completed, renaming file ([" + file.getPath() + "] -> .zim.part)");
file.renameTo(new File(file.getPath().replaceAll(".part$", "")));
}
// Mark chunk status as downloaded
chunk.isDownloaded = true;
subscriber.onComplete();
} catch (IOException e) {
// Catch unforeseen file system errors
subscriber.onError(e);
}
});
}
/**
* Creates and registers notification channel with system for notifications of
* type: download in progress.
*/
private void createOngoingDownloadChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
CharSequence name = getString(R.string.ongoing_download_channel_name);
String description = getString(R.string.ongoing_download_channel_desc);
int importance = NotificationManager.IMPORTANCE_DEFAULT;
NotificationChannel ongoingDownloadsChannel = new NotificationChannel(
Constants.ONGOING_DOWNLOAD_CHANNEL_ID, name, importance);
ongoingDownloadsChannel.setDescription(description);
ongoingDownloadsChannel.setSound(null, null);
NotificationManager notificationManager = (NotificationManager) getSystemService(
NOTIFICATION_SERVICE);
notificationManager.createNotificationChannel(ongoingDownloadsChannel);
}
}
@Override
public IBinder onBind(Intent intent) {
return mBinder;
}
/**
* Class used for the client Binder. Because we know this service always
* runs in the same process as its clients, we don't need to deal with IPC.
*/
public class LocalBinder extends Binder {
public DownloadService getService() {
// Return this instance of LocalService so clients can call public methods
return DownloadService.this;
}
}
}

View File

@ -25,6 +25,7 @@ import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.download_item.description
import kotlinx.android.synthetic.main.download_item.downloadProgress
import kotlinx.android.synthetic.main.download_item.downloadState
import kotlinx.android.synthetic.main.download_item.eta
import kotlinx.android.synthetic.main.download_item.favicon
import kotlinx.android.synthetic.main.download_item.stop
import kotlinx.android.synthetic.main.download_item.title
@ -35,7 +36,6 @@ import org.kiwix.kiwixmobile.downloader.model.DownloadState.Paused
import org.kiwix.kiwixmobile.downloader.model.DownloadState.Pending
import org.kiwix.kiwixmobile.downloader.model.DownloadState.Running
import org.kiwix.kiwixmobile.downloader.model.DownloadState.Successful
import org.kiwix.kiwixmobile.downloader.model.FailureReason.Rfc2616HttpCode
import org.kiwix.kiwixmobile.extensions.setBitmap
class DownloadViewHolder(override val containerView: View) : RecyclerView.ViewHolder(containerView),
@ -51,36 +51,21 @@ class DownloadViewHolder(override val containerView: View) : RecyclerView.ViewHo
stop.setOnClickListener {
itemClickListener.invoke(downloadItem)
}
downloadState.text = toReadableState(
downloadItem.downloadState, containerView.context
)
downloadState.text = toReadableState(downloadItem.downloadState, containerView.context)
eta.text = downloadItem.eta.takeIf { it.seconds > 0L }?.toHumanReadableTime() ?: ""
}
private fun toReadableState(
downloadState: DownloadState,
context: Context
) = when (downloadState) {
is Paused -> context.getString(
downloadState.stringId,
context.getString(downloadState.reason.stringId)
)
is Failed -> context.getString(
downloadState.stringId,
getTemplateString(downloadState, context)
downloadState.reason.name
)
Pending,
Running,
Paused,
Successful -> context.getString(downloadState.stringId)
}
private fun getTemplateString(
downloadState: Failed,
context: Context
) = when (downloadState.reason) {
is Rfc2616HttpCode -> context.getString(
downloadState.reason.stringId,
downloadState.reason.code
)
else -> context.getString(downloadState.reason.stringId)
}
}

View File

@ -18,12 +18,9 @@
package org.kiwix.kiwixmobile.downloader
import org.kiwix.kiwixmobile.downloader.model.DownloadItem
import org.kiwix.kiwixmobile.downloader.model.DownloadModel
import org.kiwix.kiwixmobile.downloader.model.DownloadStatus
import org.kiwix.kiwixmobile.library.entity.LibraryNetworkEntity
interface Downloader {
fun download(book: LibraryNetworkEntity.Book)
fun queryStatus(downloadModels: List<DownloadModel>): List<DownloadStatus>
fun cancelDownload(downloadItem: DownloadItem)
}

View File

@ -19,17 +19,15 @@
package org.kiwix.kiwixmobile.downloader
import org.kiwix.kiwixmobile.data.remote.KiwixService
import org.kiwix.kiwixmobile.database.newdb.dao.NewDownloadDao
import org.kiwix.kiwixmobile.database.newdb.dao.FetchDownloadDao
import org.kiwix.kiwixmobile.downloader.model.DownloadItem
import org.kiwix.kiwixmobile.downloader.model.DownloadModel
import org.kiwix.kiwixmobile.downloader.model.DownloadRequest
import org.kiwix.kiwixmobile.downloader.model.DownloadStatus
import org.kiwix.kiwixmobile.library.entity.LibraryNetworkEntity
import javax.inject.Inject
class DownloaderImpl @Inject constructor(
private val downloadRequester: DownloadRequester,
private val downloadDao: NewDownloadDao,
private val downloadDao: FetchDownloadDao,
private val kiwixService: KiwixService
) : Downloader {
@ -42,21 +40,14 @@ class DownloaderImpl @Inject constructor(
val downloadId = downloadRequester.enqueue(
DownloadRequest(it, book)
)
downloadDao.insert(
DownloadModel(downloadId = downloadId, book = book)
)
downloadDao.insert(downloadId, book = book)
}
},
Throwable::printStackTrace
)
}
override fun queryStatus(downloadModels: List<DownloadModel>) =
downloadRequester.query(downloadModels)
.sortedBy(DownloadStatus::downloadId)
override fun cancelDownload(downloadItem: DownloadItem) {
downloadRequester.cancel(downloadItem)
downloadDao.delete(downloadItem.downloadId)
}
}

View File

@ -0,0 +1,117 @@
/*
* Kiwix Android
* Copyright (C) 2018 Kiwix <android.kiwix.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.kiwix.kiwixmobile.downloader.fetch
import com.tonyodev.fetch2.Download
import com.tonyodev.fetch2.Error
import com.tonyodev.fetch2.Fetch
import com.tonyodev.fetch2.FetchListener
import com.tonyodev.fetch2core.DownloadBlock
import io.reactivex.schedulers.Schedulers
import io.reactivex.subjects.PublishSubject
import org.kiwix.kiwixmobile.database.newdb.dao.FetchDownloadDao
import org.kiwix.kiwixmobile.downloader.DownloadMonitor
import javax.inject.Inject
class FetchDownloadMonitor @Inject constructor(fetch: Fetch, fetchDownloadDao: FetchDownloadDao) :
DownloadMonitor {
private val updater = PublishSubject.create<() -> Unit>()
private val fetchListener = object : FetchListener {
override fun onAdded(download: Download) {}
override fun onCancelled(download: Download) {
delete(download)
}
override fun onCompleted(download: Download) {
update(download)
}
override fun onDeleted(download: Download) {
delete(download)
}
override fun onDownloadBlockUpdated(
download: Download,
downloadBlock: DownloadBlock,
totalBlocks: Int
) {
update(download)
}
override fun onError(download: Download, error: Error, throwable: Throwable?) {
update(download)
}
override fun onPaused(download: Download) {
update(download)
}
override fun onProgress(
download: Download,
etaInMilliSeconds: Long,
downloadedBytesPerSecond: Long
) {
update(download)
}
override fun onQueued(download: Download, waitingOnNetwork: Boolean) {
update(download)
}
override fun onRemoved(download: Download) {
delete(download)
}
override fun onResumed(download: Download) {
update(download)
}
override fun onStarted(
download: Download,
downloadBlocks: List<DownloadBlock>,
totalBlocks: Int
) {
update(download)
}
override fun onWaitingNetwork(download: Download) {
update(download)
}
private fun update(download: Download) {
updater.onNext { fetchDownloadDao.update(download) }
}
private fun delete(download: Download) {
updater.onNext { fetchDownloadDao.delete(download) }
}
}
init {
fetch.addListener(fetchListener, true)
updater.subscribeOn(Schedulers.io()).observeOn(Schedulers.io()).subscribe(
{ it.invoke() },
Throwable::printStackTrace
)
}
override fun init() {
// empty method to so class does not get reported unused
}
}

View File

@ -0,0 +1,101 @@
/*
* Kiwix Android
* Copyright (C) 2018 Kiwix <android.kiwix.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.kiwix.kiwixmobile.downloader.fetch
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.media.AudioManager
import android.os.Build
import android.os.Build.VERSION_CODES
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import com.tonyodev.fetch2.DefaultFetchNotificationManager
import com.tonyodev.fetch2.DownloadNotification
import com.tonyodev.fetch2.Fetch
import com.tonyodev.fetch2.util.DEFAULT_NOTIFICATION_TIMEOUT_AFTER_RESET
import org.kiwix.kiwixmobile.R
import org.kiwix.kiwixmobile.R.string
class FetchDownloadNotificationManager(context: Context) :
DefaultFetchNotificationManager(context) {
override fun getFetchInstanceForNamespace(namespace: String) = Fetch.getDefaultInstance()
override fun createNotificationChannels(
context: Context,
notificationManager: NotificationManager
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channelId = context.getString(R.string.fetch_notification_default_channel_id)
if (notificationManager.getNotificationChannel(channelId) == null) {
notificationManager.createNotificationChannel(createChannel(channelId, context))
}
}
}
override fun updateNotification(
notificationBuilder: NotificationCompat.Builder,
downloadNotification: DownloadNotification,
context: Context
) {
val smallIcon = if (downloadNotification.isDownloading) {
android.R.drawable.stat_sys_download
} else {
android.R.drawable.stat_sys_download_done
}
notificationBuilder.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setSmallIcon(smallIcon)
.setContentTitle(downloadNotification.title)
.setContentText(getSubtitleText(context, downloadNotification))
.setOngoing(downloadNotification.isOnGoingNotification)
.setGroup(downloadNotification.groupId.toString())
.setSound(null)
.setSound(null, AudioManager.STREAM_NOTIFICATION)
.setVibrate(null)
.setGroupSummary(false)
if (downloadNotification.isFailed || downloadNotification.isCompleted) {
notificationBuilder.setProgress(0, 0, false)
} else {
val progressIndeterminate = downloadNotification.progressIndeterminate
val maxProgress = if (downloadNotification.progressIndeterminate) 0 else 100
val progress = if (downloadNotification.progress < 0) 0 else downloadNotification.progress
notificationBuilder.setProgress(maxProgress, progress, progressIndeterminate)
}
when {
downloadNotification.isDownloading ||
downloadNotification.isPaused ||
downloadNotification.isQueued -> {
notificationBuilder.setTimeoutAfter(getNotificationTimeOutMillis())
}
else -> {
notificationBuilder.setTimeoutAfter(DEFAULT_NOTIFICATION_TIMEOUT_AFTER_RESET)
}
}
}
@RequiresApi(VERSION_CODES.O)
private fun createChannel(channelId: String, context: Context) =
NotificationChannel(
channelId,
context.getString(string.fetch_notification_default_channel_name),
NotificationManager.IMPORTANCE_DEFAULT
).apply {
setSound(null, null)
enableVibration(false)
}
}

View File

@ -0,0 +1,50 @@
/*
* Kiwix Android
* Copyright (C) 2018 Kiwix <android.kiwix.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.kiwix.kiwixmobile.downloader.fetch
import com.tonyodev.fetch2.Fetch
import com.tonyodev.fetch2.NetworkType.ALL
import com.tonyodev.fetch2.NetworkType.WIFI_ONLY
import com.tonyodev.fetch2.Request
import org.kiwix.kiwixmobile.downloader.DownloadRequester
import org.kiwix.kiwixmobile.downloader.model.DownloadItem
import org.kiwix.kiwixmobile.downloader.model.DownloadRequest
import org.kiwix.kiwixmobile.utils.SharedPreferenceUtil
import javax.inject.Inject
class FetchDownloadRequester @Inject constructor(
private val fetch: Fetch,
private val sharedPreferenceUtil: SharedPreferenceUtil
) : DownloadRequester {
override fun enqueue(downloadRequest: DownloadRequest): Long {
val request = downloadRequest.toFetchRequest(sharedPreferenceUtil)
fetch.enqueue(request)
return request.id.toLong()
}
override fun cancel(downloadItem: DownloadItem) {
fetch.delete(downloadItem.downloadId.toInt())
}
}
private fun DownloadRequest.toFetchRequest(sharedPreferenceUtil: SharedPreferenceUtil) =
Request("$uri", getDestination(sharedPreferenceUtil)).apply {
networkType = if (sharedPreferenceUtil.prefWifiOnly) WIFI_ONLY else ALL
autoRetryMaxAttempts = 10
}

View File

@ -17,6 +17,20 @@
*/
package org.kiwix.kiwixmobile.downloader.model
import com.tonyodev.fetch2.Error
import com.tonyodev.fetch2.Status
import com.tonyodev.fetch2.Status.ADDED
import com.tonyodev.fetch2.Status.CANCELLED
import com.tonyodev.fetch2.Status.COMPLETED
import com.tonyodev.fetch2.Status.DELETED
import com.tonyodev.fetch2.Status.DOWNLOADING
import com.tonyodev.fetch2.Status.FAILED
import com.tonyodev.fetch2.Status.NONE
import com.tonyodev.fetch2.Status.PAUSED
import com.tonyodev.fetch2.Status.QUEUED
import com.tonyodev.fetch2.Status.REMOVED
import org.kiwix.kiwixmobile.R
data class DownloadItem(
val downloadId: Long,
val favIcon: Base64String,
@ -24,17 +38,47 @@ data class DownloadItem(
val description: String,
val bytesDownloaded: Long,
val totalSizeBytes: Long,
val progress: Int,
val eta: Seconds,
val downloadState: DownloadState
) {
val progress get() = ((bytesDownloaded.toFloat() / totalSizeBytes) * 100).toInt()
constructor(downloadStatus: DownloadStatus) : this(
downloadStatus.downloadId,
Base64String(downloadStatus.book.favicon),
downloadStatus.title,
downloadStatus.description,
downloadStatus.bytesDownloadedSoFar,
downloadStatus.totalSizeBytes,
downloadStatus.state
constructor(downloadModel: DownloadModel) : this(
downloadModel.downloadId,
Base64String(downloadModel.book.favicon),
downloadModel.book.title,
downloadModel.book.description,
downloadModel.bytesDownloaded,
downloadModel.totalSizeOfDownload,
downloadModel.progress,
Seconds(downloadModel.etaInMilliSeconds / 1000L),
DownloadState.from(downloadModel.state, downloadModel.error)
)
}
sealed class DownloadState(val stringId: Int) {
companion object {
fun from(state: Status, error: Error): DownloadState =
when (state) {
NONE,
ADDED,
QUEUED -> Pending
DOWNLOADING -> Running
PAUSED -> Paused
COMPLETED -> Successful
CANCELLED,
FAILED,
REMOVED,
DELETED -> Failed(error)
}
}
object Pending : DownloadState(R.string.pending_state)
object Running : DownloadState(R.string.running_state)
object Successful : DownloadState(R.string.successful_state)
object Paused : DownloadState(R.string.paused_state)
data class Failed(val reason: Error) : DownloadState(R.string.failed_state)
override fun toString(): String = javaClass.simpleName
}

View File

@ -17,13 +17,36 @@
*/
package org.kiwix.kiwixmobile.downloader.model
import com.tonyodev.fetch2.Error
import com.tonyodev.fetch2.Status
import org.kiwix.kiwixmobile.database.newdb.entities.FetchDownloadEntity
import org.kiwix.kiwixmobile.library.entity.LibraryNetworkEntity.Book
import org.kiwix.kiwixmobile.utils.StorageUtils
data class DownloadModel(
val databaseId: Long? = null,
val databaseId: Long,
val downloadId: Long,
val file: String?,
val etaInMilliSeconds: Long,
val bytesDownloaded: Long,
val totalSizeOfDownload: Long,
val state: Status,
val error: Error,
val progress: Int,
val book: Book
) {
val fileNameFromUrl: String get() = StorageUtils.getFileNameFromUrl(book.url)
val fileNameFromUrl: String by lazy { StorageUtils.getFileNameFromUrl(book.url) }
constructor(downloadEntity: FetchDownloadEntity) : this(
downloadEntity.id,
downloadEntity.downloadId,
downloadEntity.file,
downloadEntity.etaInMilliSeconds,
downloadEntity.bytesDownloaded,
downloadEntity.totalSizeOfDownload,
downloadEntity.status,
downloadEntity.error,
downloadEntity.progress,
downloadEntity.toBook()
)
}

View File

@ -20,6 +20,8 @@ package org.kiwix.kiwixmobile.downloader.model
import android.net.Uri
import org.kiwix.kiwixmobile.library.entity.LibraryNetworkEntity
import org.kiwix.kiwixmobile.library.entity.MetaLinkNetworkEntity
import org.kiwix.kiwixmobile.utils.SharedPreferenceUtil
import org.kiwix.kiwixmobile.utils.StorageUtils
data class DownloadRequest(
val urlString: String,
@ -37,4 +39,9 @@ data class DownloadRequest(
book.title,
book.description
)
fun getDestination(sharedPreferenceUtil: SharedPreferenceUtil): String =
"${sharedPreferenceUtil.prefStorage}/Kiwix/${
StorageUtils.getFileNameFromUrl(urlString)
}"
}

View File

@ -1,168 +0,0 @@
/*
* Kiwix Android
* Copyright (C) 2018 Kiwix <android.kiwix.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.kiwix.kiwixmobile.downloader.model
import android.app.DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR
import android.app.DownloadManager.COLUMN_DESCRIPTION
import android.app.DownloadManager.COLUMN_ID
import android.app.DownloadManager.COLUMN_LAST_MODIFIED_TIMESTAMP
import android.app.DownloadManager.COLUMN_LOCAL_URI
import android.app.DownloadManager.COLUMN_MEDIAPROVIDER_URI
import android.app.DownloadManager.COLUMN_MEDIA_TYPE
import android.app.DownloadManager.COLUMN_REASON
import android.app.DownloadManager.COLUMN_STATUS
import android.app.DownloadManager.COLUMN_TITLE
import android.app.DownloadManager.COLUMN_TOTAL_SIZE_BYTES
import android.app.DownloadManager.COLUMN_URI
import android.app.DownloadManager.ERROR_CANNOT_RESUME
import android.app.DownloadManager.ERROR_DEVICE_NOT_FOUND
import android.app.DownloadManager.ERROR_FILE_ALREADY_EXISTS
import android.app.DownloadManager.ERROR_FILE_ERROR
import android.app.DownloadManager.ERROR_HTTP_DATA_ERROR
import android.app.DownloadManager.ERROR_INSUFFICIENT_SPACE
import android.app.DownloadManager.ERROR_TOO_MANY_REDIRECTS
import android.app.DownloadManager.ERROR_UNHANDLED_HTTP_CODE
import android.app.DownloadManager.ERROR_UNKNOWN
import android.app.DownloadManager.PAUSED_QUEUED_FOR_WIFI
import android.app.DownloadManager.PAUSED_UNKNOWN
import android.app.DownloadManager.PAUSED_WAITING_FOR_NETWORK
import android.app.DownloadManager.PAUSED_WAITING_TO_RETRY
import android.app.DownloadManager.STATUS_FAILED
import android.app.DownloadManager.STATUS_PAUSED
import android.app.DownloadManager.STATUS_PENDING
import android.app.DownloadManager.STATUS_RUNNING
import android.app.DownloadManager.STATUS_SUCCESSFUL
import android.database.Cursor
import android.net.Uri
import org.kiwix.kiwixmobile.R
import org.kiwix.kiwixmobile.extensions.get
import org.kiwix.kiwixmobile.library.entity.LibraryNetworkEntity.Book
import org.kiwix.kiwixmobile.zim_manager.fileselect_view.adapter.BooksOnDiskListItem.BookOnDisk
import java.io.File
class DownloadStatus(
val downloadId: Long,
val title: String,
val description: String,
val state: DownloadState,
val bytesDownloadedSoFar: Long,
val totalSizeBytes: Long,
val lastModified: String,
private val localUri: String?,
val mediaProviderUri: String?,
val mediaType: String?,
val uri: String?,
val book: Book
) {
fun toBookOnDisk(uriToFileConverter: UriToFileConverter) =
BookOnDisk(book = book, file = uriToFileConverter.convert(localUri))
constructor(
cursor: Cursor,
downloadModel: DownloadModel
) : this(
cursor[COLUMN_ID],
cursor[COLUMN_TITLE],
cursor[COLUMN_DESCRIPTION],
DownloadState.from(cursor[COLUMN_STATUS], cursor[COLUMN_REASON]),
cursor[COLUMN_BYTES_DOWNLOADED_SO_FAR],
cursor[COLUMN_TOTAL_SIZE_BYTES],
cursor[COLUMN_LAST_MODIFIED_TIMESTAMP],
cursor[COLUMN_LOCAL_URI],
cursor[COLUMN_MEDIAPROVIDER_URI],
cursor[COLUMN_MEDIA_TYPE],
cursor[COLUMN_URI],
downloadModel.book
)
}
interface UriToFileConverter {
fun convert(uriString: String?) = File(Uri.parse(uriString).path)
class Impl : UriToFileConverter
}
sealed class DownloadState(val stringId: Int) {
companion object {
fun from(
status: Int,
reason: Int
) = when (status) {
STATUS_PAUSED -> Paused(PausedReason.from(reason))
STATUS_FAILED -> Failed(FailureReason.from(reason))
STATUS_PENDING -> Pending
STATUS_RUNNING -> Running
STATUS_SUCCESSFUL -> Successful
else -> throw RuntimeException("invalid status $status")
}
}
data class Paused(val reason: PausedReason) : DownloadState(R.string.paused_state)
data class Failed(val reason: FailureReason) : DownloadState(R.string.failed_state)
object Pending : DownloadState(R.string.pending_state)
object Running : DownloadState(R.string.running_state)
object Successful : DownloadState(R.string.successful_state)
override fun toString(): String = javaClass.simpleName
}
sealed class FailureReason(val stringId: Int) {
companion object {
fun from(reason: Int) = when (reason) {
in 100..505 -> Rfc2616HttpCode(reason)
ERROR_CANNOT_RESUME -> CannotResume
ERROR_DEVICE_NOT_FOUND -> StorageNotFound
ERROR_FILE_ALREADY_EXISTS -> FileAlreadyExists
ERROR_FILE_ERROR -> UnknownFileError
ERROR_HTTP_DATA_ERROR -> HttpError
ERROR_INSUFFICIENT_SPACE -> InsufficientSpace
ERROR_TOO_MANY_REDIRECTS -> TooManyRedirects
ERROR_UNHANDLED_HTTP_CODE -> UnhandledHttpCode
ERROR_UNKNOWN -> Unknown
else -> Unknown
}
}
object CannotResume : FailureReason(R.string.failed_cannot_resume)
object StorageNotFound : FailureReason(R.string.failed_storage_not_found)
object FileAlreadyExists : FailureReason(R.string.failed_file_already_exists)
object UnknownFileError : FailureReason(R.string.failed_unknown_file_error)
object HttpError : FailureReason(R.string.failed_http_error)
object InsufficientSpace : FailureReason(R.string.failed_insufficient_space)
object TooManyRedirects : FailureReason(R.string.failed_too_many_redirects)
object UnhandledHttpCode : FailureReason(R.string.failed_unhandled_http_code)
object Unknown : FailureReason(R.string.failed_unknown)
data class Rfc2616HttpCode(val code: Int) : FailureReason(R.string.failed_http_code)
}
sealed class PausedReason(val stringId: Int) {
companion object {
fun from(reason: Int) = when (reason) {
PAUSED_QUEUED_FOR_WIFI -> WaitingForWifi
PAUSED_WAITING_FOR_NETWORK -> WaitingForConnectivity
PAUSED_WAITING_TO_RETRY -> WaitingForRetry
PAUSED_UNKNOWN -> Unknown
else -> Unknown
}
}
object WaitingForWifi : PausedReason(R.string.paused_wifi)
object WaitingForConnectivity : PausedReason(R.string.paused_connectivity)
object WaitingForRetry : PausedReason(R.string.paused_retry)
object Unknown : PausedReason(R.string.paused_unknown)
}

View File

@ -4,8 +4,8 @@ import org.kiwix.kiwixmobile.KiwixApplication
import java.util.Locale
import kotlin.math.roundToLong
inline class Seconds(private val seconds: Int) {
@Suppress("unused") fun toHumanReadableTime(): String {
inline class Seconds(val seconds: Long) {
fun toHumanReadableTime(): String {
val minutes = 60.0
val hours = 60 * minutes
val days = 24 * hours

View File

@ -45,7 +45,7 @@ class AlertDialogShower @Inject constructor(
}
private fun bodyArguments(dialog: KiwixDialog) =
if (dialog is HasBodyFormatArgs) dialog.args
if (dialog is HasBodyFormatArgs) dialog.args.toTypedArray()
else emptyArray()
private fun dialogStyle() =

View File

@ -11,23 +11,10 @@ sealed class KiwixDialog(
val negativeMessage: Int?
) {
data class DeleteZim(override val args: Array<out Any>) : KiwixDialog(
data class DeleteZim(override val args: List<Any>) : KiwixDialog(
null, R.string.delete_zim_body, R.string.delete, R.string.no
), HasBodyFormatArgs {
constructor(bookOnDisk: BookOnDisk) : this(arrayOf(bookOnDisk.book.title))
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as DeleteZim
if (!args.contentEquals(other.args)) return false
return true
}
override fun hashCode() = args.contentHashCode()
constructor(bookOnDisk: BookOnDisk) : this(listOf(bookOnDisk.book.title))
}
object LocationPermissionRationale : KiwixDialog(
@ -59,17 +46,14 @@ sealed class KiwixDialog(
null
)
data class ShowHotspotDetails(override val args: Array<out Any>) : KiwixDialog(
data class ShowHotspotDetails(override val args: List<Any>) : KiwixDialog(
R.string.hotspot_turned_on,
R.string.hotspot_details_message,
android.R.string.ok,
null
), HasBodyFormatArgs {
constructor(wifiConfiguration: WifiConfiguration) : this(
arrayOf(
wifiConfiguration.SSID,
wifiConfiguration.preSharedKey
)
listOf(wifiConfiguration.SSID, wifiConfiguration.preSharedKey)
)
}
@ -82,10 +66,10 @@ sealed class KiwixDialog(
null
)
data class FileTransferConfirmation(override val args: Array<out Any>) : KiwixDialog(
data class FileTransferConfirmation(override val args: List<Any>) : KiwixDialog(
null, R.string.transfer_to, R.string.yes, android.R.string.cancel
), HasBodyFormatArgs {
constructor(selectedPeerDeviceName: String) : this(arrayOf(selectedPeerDeviceName))
constructor(selectedPeerDeviceName: String) : this(listOf(selectedPeerDeviceName))
}
open class YesNoDialog(
@ -103,5 +87,5 @@ sealed class KiwixDialog(
}
interface HasBodyFormatArgs {
val args: Array<out Any>
val args: List<Any>
}

View File

@ -2,13 +2,14 @@ package org.kiwix.kiwixmobile.utils;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Environment;
import android.preference.PreferenceManager;
import androidx.core.content.ContextCompat;
import io.reactivex.Flowable;
import io.reactivex.processors.PublishProcessor;
import java.util.Calendar;
import javax.inject.Inject;
import javax.inject.Singleton;
import org.kiwix.kiwixmobile.KiwixApplication;
/**
* Manager for the Default Shared Preferences of the application.
@ -85,8 +86,13 @@ public class SharedPreferenceUtil {
}
public String getPrefStorage() {
return sharedPreferences.getString(PREF_STORAGE,
Environment.getExternalStorageDirectory().getPath());
String storage = sharedPreferences.getString(PREF_STORAGE, null);
if (storage == null) {
storage =
ContextCompat.getExternalFilesDirs(KiwixApplication.getInstance(), null)[0].getPath();
putPrefStorage(storage);
}
return storage;
}
private boolean getPrefNightMode() {

View File

@ -20,8 +20,6 @@ package org.kiwix.kiwixmobile.utils;
public class StorageUtils {
public static String getFileNameFromUrl(String url) {
String filename = NetworkUtils.getFileNameFromUrl(url);
filename = filename.replace(".meta4", "");
return filename;
return NetworkUtils.getFileNameFromUrl(url).replace(".meta4", "");
}
}

View File

@ -20,9 +20,9 @@
package org.kiwix.kiwixmobile.utils.files
import android.content.Context
import android.os.Environment
import android.provider.MediaStore.Files
import android.provider.MediaStore.MediaColumns
import eu.mhutti1.utils.storage.StorageDevice
import eu.mhutti1.utils.storage.StorageDeviceUtils
import io.reactivex.Flowable
import io.reactivex.functions.BiFunction
@ -36,9 +36,9 @@ class FileSearch @Inject constructor(private val context: Context) {
private val zimFileExtensions = arrayOf("zim", "zimaa")
fun scan(defaultPath: String): Flowable<List<File>> =
fun scan(): Flowable<List<File>> =
Flowable.combineLatest(
Flowable.fromCallable { scanFileSystem(defaultPath) }.subscribeOn(Schedulers.io()),
Flowable.fromCallable(::scanFileSystem).subscribeOn(Schedulers.io()),
Flowable.fromCallable(::scanMediaStore).subscribeOn(Schedulers.io()),
BiFunction<List<File>, List<File>, List<File>> { filesSystemFiles, mediaStoreFiles ->
filesSystemFiles + mediaStoreFiles
@ -62,18 +62,15 @@ class FileSearch @Inject constructor(private val context: Context) {
null
)
private fun scanFileSystem(defaultPath: String) =
directoryRoots(defaultPath)
.minus(Environment.getExternalStorageDirectory().absolutePath)
private fun scanFileSystem() =
directoryRoots()
.fold(mutableListOf<File>(), { acc, root ->
acc.apply { addAll(scanDirectory(root)) }
})
.distinctBy { it.canonicalPath }
private fun directoryRoots(defaultPath: String) = listOf(
"/mnt",
defaultPath,
*StorageDeviceUtils.getStorageDevices(context, false).map { it.name }.toTypedArray()
)
private fun directoryRoots() =
StorageDeviceUtils.getReadableStorage(context).map(StorageDevice::name)
private fun scanDirectory(directory: String): List<File> = File(directory).listFiles()
?.fold(

View File

@ -35,7 +35,7 @@ import java.util.ArrayList
object FileUtils {
val saveFilePath =
private val saveFilePath =
"${Environment.getExternalStorageDirectory()}${File.separator}Android" +
"${File.separator}obb${File.separator}${BuildConfig.APPLICATION_ID}"

View File

@ -1,51 +0,0 @@
/*
* Kiwix Android
* Copyright (C) 2018 Kiwix <android.kiwix.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.kiwix.kiwixmobile.zim_manager
import android.app.DownloadManager
import android.content.Context
import android.content.Intent
import android.os.Bundle
import org.kiwix.kiwixmobile.KiwixApplication
import org.kiwix.kiwixmobile.database.newdb.dao.NewDownloadDao
import javax.inject.Inject
class DownloadNotificationClickedReceiver : BaseBroadcastReceiver() {
override val action: String = DownloadManager.ACTION_NOTIFICATION_CLICKED
@Inject lateinit var downloadDao: NewDownloadDao
override fun onIntentWithActionReceived(
context: Context,
intent: Intent
) {
KiwixApplication.getApplicationComponent()
.inject(this)
if (downloadDao.containsAny(*longArrayFrom(intent.extras))) {
context.startActivity(
Intent(context, ZimManageActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
putExtra(ZimManageActivity.TAB_EXTRA, 2)
}
)
}
}
private fun longArrayFrom(extras: Bundle?) =
extras?.getLongArray(DownloadManager.EXTRA_NOTIFICATION_CLICK_DOWNLOAD_IDS) ?: longArrayOf()
}

View File

@ -17,59 +17,46 @@
*/
package org.kiwix.kiwixmobile.zim_manager
import android.Manifest.permission
import android.content.pm.PackageManager
import android.os.FileObserver
import android.util.Log
import androidx.core.content.ContextCompat
import io.reactivex.Flowable
import io.reactivex.functions.Function3
import io.reactivex.functions.BiFunction
import io.reactivex.processors.BehaviorProcessor
import io.reactivex.schedulers.Schedulers
import org.kiwix.kiwixmobile.KiwixApplication
import org.kiwix.kiwixmobile.utils.SharedPreferenceUtil
import org.kiwix.kiwixmobile.zim_manager.Fat32Checker.FileSystemState.CanWrite4GbFile
import org.kiwix.kiwixmobile.zim_manager.Fat32Checker.FileSystemState.CannotWrite4GbFile
import org.kiwix.kiwixmobile.zim_manager.Fat32Checker.FileSystemState.NotEnoughSpaceFor4GbFile
import org.kiwix.kiwixmobile.zim_manager.Fat32Checker.FileSystemState.Unknown
import java.io.File
import java.io.RandomAccessFile
import java.util.concurrent.TimeUnit.SECONDS
import javax.inject.Inject
class Fat32Checker @Inject constructor(sharedPreferenceUtil: SharedPreferenceUtil) {
private val _fileSystemStates: BehaviorProcessor<FileSystemState> = BehaviorProcessor.create()
val fileSystemStates: Flowable<FileSystemState> = _fileSystemStates.distinctUntilChanged()
val fileSystemStates: BehaviorProcessor<FileSystemState> = BehaviorProcessor.create()
private var fileObserver: FileObserver? = null
private val requestCheckSystemFileType = BehaviorProcessor.createDefault(Unit)
init {
Flowable.combineLatest(
sharedPreferenceUtil.prefStorages.distinctUntilChanged(),
sharedPreferenceUtil.prefStorages
.distinctUntilChanged()
.doOnNext { fileSystemStates.offer(Unknown) },
requestCheckSystemFileType,
pollForExternalStoragePermissionGranted(),
Function3 { storage: String, _: Unit, _: Boolean -> storage }
BiFunction { storage: String, _: Unit -> storage }
)
.observeOn(Schedulers.io())
.subscribeOn(Schedulers.io())
.subscribe(
{
val systemState = toFileSystemState(it)
_fileSystemStates.onNext(systemState)
fileSystemStates.offer(systemState)
fileObserver = if (systemState == NotEnoughSpaceFor4GbFile) fileObserver(it) else null
},
Throwable::printStackTrace
)
}
private fun pollForExternalStoragePermissionGranted() =
Flowable.interval(1, SECONDS)
.map {
ContextCompat.checkSelfPermission(
KiwixApplication.getInstance(), permission.WRITE_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED
}
.filter { it }
.take(1)
private fun fileObserver(it: String?): FileObserver {
return object : FileObserver(it, MOVED_FROM or DELETE) {
override fun onEvent(
@ -91,7 +78,7 @@ class Fat32Checker @Inject constructor(sharedPreferenceUtil: SharedPreferenceUti
private fun canCreate4GbFile(storage: String): Boolean {
val path = "$storage/large_file_test.txt"
File(path).delete()
File(path).deleteIfExists()
try {
RandomAccessFile(path, "rw").use {
it.setLength(FOUR_GIGABYTES_IN_BYTES)
@ -102,7 +89,7 @@ class Fat32Checker @Inject constructor(sharedPreferenceUtil: SharedPreferenceUti
Log.d("Fat32Checker", e.message)
return false
} finally {
File(path).delete()
File(path).deleteIfExists()
}
}
@ -115,5 +102,10 @@ class Fat32Checker @Inject constructor(sharedPreferenceUtil: SharedPreferenceUti
object NotEnoughSpaceFor4GbFile : FileSystemState()
object CanWrite4GbFile : FileSystemState()
object CannotWrite4GbFile : FileSystemState()
object Unknown : FileSystemState()
}
}
private fun File.deleteIfExists() {
if (exists()) delete()
}

View File

@ -33,15 +33,11 @@ import io.reactivex.schedulers.Schedulers
import org.kiwix.kiwixmobile.R
import org.kiwix.kiwixmobile.data.DataSource
import org.kiwix.kiwixmobile.data.remote.KiwixService
import org.kiwix.kiwixmobile.database.newdb.dao.FetchDownloadDao
import org.kiwix.kiwixmobile.database.newdb.dao.NewBookDao
import org.kiwix.kiwixmobile.database.newdb.dao.NewDownloadDao
import org.kiwix.kiwixmobile.database.newdb.dao.NewLanguagesDao
import org.kiwix.kiwixmobile.downloader.Downloader
import org.kiwix.kiwixmobile.downloader.model.DownloadItem
import org.kiwix.kiwixmobile.downloader.model.DownloadModel
import org.kiwix.kiwixmobile.downloader.model.DownloadState.Successful
import org.kiwix.kiwixmobile.downloader.model.DownloadStatus
import org.kiwix.kiwixmobile.downloader.model.UriToFileConverter
import org.kiwix.kiwixmobile.extensions.calculateSearchMatches
import org.kiwix.kiwixmobile.extensions.registerReceiver
import org.kiwix.kiwixmobile.library.entity.LibraryNetworkEntity
@ -51,6 +47,7 @@ import org.kiwix.kiwixmobile.zim_manager.Fat32Checker.FileSystemState
import org.kiwix.kiwixmobile.zim_manager.Fat32Checker.FileSystemState.CanWrite4GbFile
import org.kiwix.kiwixmobile.zim_manager.Fat32Checker.FileSystemState.CannotWrite4GbFile
import org.kiwix.kiwixmobile.zim_manager.Fat32Checker.FileSystemState.NotEnoughSpaceFor4GbFile
import org.kiwix.kiwixmobile.zim_manager.Fat32Checker.FileSystemState.Unknown
import org.kiwix.kiwixmobile.zim_manager.NetworkState.CONNECTED
import org.kiwix.kiwixmobile.zim_manager.ZimManageViewModel.FileSelectActions.MultiModeFinished
import org.kiwix.kiwixmobile.zim_manager.ZimManageViewModel.FileSelectActions.RequestDeleteMultiSelection
@ -80,17 +77,15 @@ import java.util.concurrent.TimeUnit.SECONDS
import javax.inject.Inject
class ZimManageViewModel @Inject constructor(
private val downloadDao: NewDownloadDao,
private val downloadDao: FetchDownloadDao,
private val bookDao: NewBookDao,
private val languageDao: NewLanguagesDao,
private val downloader: Downloader,
private val storageObserver: StorageObserver,
private val kiwixService: KiwixService,
private val context: Application,
private val connectivityBroadcastReceiver: ConnectivityBroadcastReceiver,
private val bookUtils: BookUtils,
private val fat32Checker: Fat32Checker,
private val uriToFileConverter: UriToFileConverter,
private val defaultLanguageProvider: DefaultLanguageProvider,
private val dataSource: DataSource
) : ViewModel() {
@ -136,14 +131,11 @@ class ZimManageViewModel @Inject constructor(
private fun disposables(): Array<Disposable> {
val downloads = downloadDao.downloads()
val downloadStatuses = downloadStatuses(downloads)
val booksFromDao = books()
val networkLibrary = PublishProcessor.create<LibraryNetworkEntity>()
val languages = languageDao.languages()
return arrayOf(
updateDownloadItems(downloadStatuses),
removeCompletedDownloadsFromDb(downloadStatuses),
removeNonExistingDownloadsFromDb(downloadStatuses, downloads),
updateDownloadItems(downloads),
updateBookItems(),
checkFileSystemForBooksOnRequest(booksFromDao),
updateLibraryItems(booksFromDao, downloads, networkLibrary, languages),
@ -246,46 +238,6 @@ class ZimManageViewModel @Inject constructor(
Throwable::printStackTrace
)
private fun removeNonExistingDownloadsFromDb(
downloadStatuses: Flowable<List<DownloadStatus>>,
downloads: Flowable<List<DownloadModel>>
) = downloadStatuses
.withLatestFrom(
downloads,
BiFunction(::combineToDownloadsWithoutStatuses)
)
.buffer(3, SECONDS)
.map(::downloadIdsWithNoStatusesOverBufferPeriod)
.filter { it.isNotEmpty() }
.subscribe(
{
downloadDao.delete(*it.toLongArray())
},
Throwable::printStackTrace
)
private fun downloadIdsWithNoStatusesOverBufferPeriod(noStatusIds: List<MutableList<Long>>) =
noStatusIds.flatten()
.fold(mutableMapOf<Long, Int>(), { acc, id -> acc.increment(id) })
.filter { (_, count) -> count == noStatusIds.size }
.map { (id, _) -> id }
private fun combineToDownloadsWithoutStatuses(
statuses: List<DownloadStatus>,
downloads: List<DownloadModel>
): MutableList<Long> {
val downloadIdsWithStatuses = statuses.map(DownloadStatus::downloadId)
return downloads.fold(
mutableListOf(),
{ acc, downloadModel ->
if (!downloadIdsWithStatuses.contains(downloadModel.downloadId)) {
acc.add(downloadModel.downloadId)
}
acc
}
)
}
private fun updateNetworkStates() =
connectivityBroadcastReceiver.networkStates.subscribe(
networkStates::postValue, Throwable::printStackTrace
@ -301,10 +253,13 @@ class ZimManageViewModel @Inject constructor(
downloads,
languages.filter { it.isNotEmpty() },
library,
requestFiltering
.doOnNext { libraryListIsRefreshing.postValue(true) }
.debounce(500, MILLISECONDS)
.observeOn(Schedulers.io()),
Flowable.merge(
Flowable.just(""),
requestFiltering
.doOnNext { libraryListIsRefreshing.postValue(true) }
.debounce(500, MILLISECONDS)
.observeOn(Schedulers.io())
),
fat32Checker.fileSystemStates,
Function6(::combineLibrarySources)
)
@ -400,6 +355,7 @@ class ZimManageViewModel @Inject constructor(
libraryNetworkEntity.books
.filter {
when (fileSystemState) {
Unknown,
CannotWrite4GbFile -> isLessThan4GB(it)
NotEnoughSpaceFor4GbFile,
CanWrite4GbFile -> true
@ -516,38 +472,11 @@ class ZimManageViewModel @Inject constructor(
})
}
private fun removeCompletedDownloadsFromDb(downloadStatuses: Flowable<List<DownloadStatus>>) =
downloadStatuses
.observeOn(Schedulers.io())
.subscribeOn(Schedulers.io())
.map { it.filter { status -> status.state == Successful } }
.filter { it.isNotEmpty() }
.subscribe(
{
bookDao.insert(
it.map { downloadStatus -> downloadStatus.toBookOnDisk(uriToFileConverter) })
downloadDao.delete(
*it.map(DownloadStatus::downloadId).toLongArray()
)
},
Throwable::printStackTrace
)
private fun updateDownloadItems(downloadStatuses: Flowable<List<DownloadStatus>>) =
downloadStatuses
.map { statuses -> statuses.map(::DownloadItem) }
private fun updateDownloadItems(downloadModels: Flowable<List<DownloadModel>>) =
downloadModels
.map { it.map(::DownloadItem) }
.subscribe(
downloadItems::postValue,
Throwable::printStackTrace
)
private fun downloadStatuses(downloads: Flowable<List<DownloadModel>>) =
Flowable.combineLatest(
downloads,
Flowable.interval(1, SECONDS),
BiFunction { downloadModels: List<DownloadModel>, _: Long -> downloadModels }
)
.subscribeOn(Schedulers.io())
.map(downloader::queryStatus)
.distinctUntilChanged()
}

View File

@ -3,18 +3,16 @@ package org.kiwix.kiwixmobile.zim_manager.fileselect_view
import io.reactivex.functions.BiFunction
import io.reactivex.schedulers.Schedulers
import org.kiwix.kiwixmobile.data.ZimContentProvider
import org.kiwix.kiwixmobile.database.newdb.dao.NewDownloadDao
import org.kiwix.kiwixmobile.database.newdb.dao.FetchDownloadDao
import org.kiwix.kiwixmobile.downloader.model.DownloadModel
import org.kiwix.kiwixmobile.library.entity.LibraryNetworkEntity.Book
import org.kiwix.kiwixmobile.utils.SharedPreferenceUtil
import org.kiwix.kiwixmobile.utils.files.FileSearch
import org.kiwix.kiwixmobile.zim_manager.fileselect_view.adapter.BooksOnDiskListItem.BookOnDisk
import java.io.File
import javax.inject.Inject
class StorageObserver @Inject constructor(
private val sharedPreferenceUtil: SharedPreferenceUtil,
private val downloadDao: NewDownloadDao,
private val downloadDao: FetchDownloadDao,
private val fileSearch: FileSearch
) {
@ -40,7 +38,7 @@ class StorageObserver @Inject constructor(
file.absolutePath.endsWith(it.fileNameFromUrl)
} == null
private fun scanFiles() = fileSearch.scan(sharedPreferenceUtil.prefStorage)
private fun scanFiles() = fileSearch.scan()
.subscribeOn(Schedulers.io())
private fun convertToBookOnDisk(file: File): BookOnDisk? {

View File

@ -1,6 +1,7 @@
package org.kiwix.kiwixmobile.zim_manager.fileselect_view.adapter
import org.kiwix.kiwixmobile.database.newdb.entities.BookOnDiskEntity
import org.kiwix.kiwixmobile.database.newdb.entities.FetchDownloadEntity
import org.kiwix.kiwixmobile.library.entity.LibraryNetworkEntity.Book
import java.io.File
import java.util.Locale
@ -35,5 +36,11 @@ sealed class BooksOnDiskListItem {
bookOnDiskEntity.toBook(),
bookOnDiskEntity.file
)
constructor(fetchDownloadEntity: FetchDownloadEntity) : this(
0L,
fetchDownloadEntity.toBook(),
File(fetchDownloadEntity.file)
)
}
}

View File

@ -7,9 +7,8 @@
android:minHeight="?android:attr/listPreferredItemHeight"
android:orientation="horizontal"
android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
android:paddingTop="@dimen/activity_vertical_margin"
>
android:paddingRight="@dimen/activity_horizontal_margin">
<ImageView
android:id="@+id/favicon"
@ -20,16 +19,14 @@
android:adjustViewBounds="true"
android:minHeight="?android:attr/listPreferredItemHeight"
android:scaleType="fitCenter"
android:src="@mipmap/kiwix_icon"
/>
android:src="@mipmap/kiwix_icon" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical"
>
android:orientation="vertical">
<TextView
android:id="@+id/title"
@ -37,8 +34,7 @@
android:layout_height="wrap_content"
android:layout_gravity="start"
android:textAppearance="?android:attr/textAppearanceListItem"
tools:text="Title"
/>
tools:text="Title" />
<TextView
android:id="@+id/description"
@ -46,33 +42,42 @@
android:layout_height="wrap_content"
android:layout_gravity="start"
android:textAppearance="?android:attr/textAppearanceSmall"
tools:text="Description"
/>
tools:text="Description" />
<ProgressBar
android:id="@+id/downloadProgress"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:indeterminate="false"
android:padding="@dimen/download_progress_padding"
style="?android:attr/progressBarStyleHorizontal"
/>
android:padding="@dimen/download_progress_padding" />
<TextView
android:id="@+id/downloadState"
android:layout_width="wrap_content"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:textAppearance="?android:attr/textAppearanceSmall"
tools:text="In Progress"
/>
android:orientation="horizontal">
<TextView
android:id="@+id/downloadState"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textAppearance="?android:attr/textAppearanceSmall"
tools:text="In Progress" />
<TextView
android:id="@+id/eta"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceSmall"
tools:text="1min 10secs" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="horizontal"
>
android:orientation="horizontal">
<ImageView
android:id="@+id/stop"
@ -82,10 +87,9 @@
android:layout_marginLeft="@dimen/stop_horizontal_margin"
android:layout_marginRight="@dimen/stop_horizontal_margin"
android:layout_weight="0.5"
android:minHeight="@dimen/stop_min_height"
android:minWidth="@dimen/stop_min_width"
app:srcCompat="@drawable/ic_stop_black_24dp"
android:minHeight="@dimen/stop_min_height"
android:text="@string/download_stop"
/>
app:srcCompat="@drawable/ic_stop_black_24dp" />
</LinearLayout>
</LinearLayout>

View File

@ -243,7 +243,7 @@
<string name="pending_state">Pending</string>
<string name="running_state">In Progress</string>
<string name="successful_state">Complete</string>
<string name="paused_state">Paused: %s</string>
<string name="paused_state">Paused</string>
<string name="failed_state">Failed: %s</string>
<string name="paused_wifi">Waiting for Wifi</string>
<string name="paused_connectivity">Waiting to connect to a network</string>

View File

@ -61,7 +61,7 @@ class FileSearchTest {
every { Environment.getExternalStorageDirectory() } returns externalStorageDirectory
every { externalStorageDirectory.absolutePath } returns "/externalStorageDirectory"
every { context.contentResolver } returns contentResolver
every { StorageDeviceUtils.getStorageDevices(context, false) } returns arrayListOf(
every { StorageDeviceUtils.getReadableStorage(context) } returns arrayListOf(
storageDevice
)
every { storageDevice.name } returns "/deviceDir"
@ -80,7 +80,7 @@ class FileSearchTest {
@Test
fun `scan of directory that doesn't exist returns nothing`() {
every { contentResolver.query(any(), any(), any(), any(), any()) } returns null
fileSearch.scan("doesNotExist")
fileSearch.scan()
.test()
.assertValue(listOf())
}
@ -91,7 +91,8 @@ class FileSearchTest {
val zimaaFile = File.createTempFile("fileToFind2", ".zimaa")
File.createTempFile("willNotFind", ".txt")
every { contentResolver.query(any(), any(), any(), any(), any()) } returns null
val fileList = fileSearch.scan(zimFile.parent)
every { storageDevice.name } returns zimFile.parent
val fileList = fileSearch.scan()
.test()
.values()[0]
assertThat(fileList).containsExactlyInAnyOrder(zimFile, zimaaFile)
@ -106,7 +107,8 @@ class FileSearchTest {
".zim",
File("$tempRoot${File.separator}dir").apply { mkdirs() })
every { contentResolver.query(any(), any(), any(), any(), any()) } returns null
val fileList = fileSearch.scan(zimFile.parentFile.parent)
every { storageDevice.name } returns zimFile.parentFile.parent
val fileList = fileSearch.scan()
.test()
.values()[0]
assertThat(fileList).containsExactlyInAnyOrder(zimFile)
@ -120,7 +122,7 @@ class FileSearchTest {
fun `scan media store, if files are readable they are returned`() {
val fileToFind = File.createTempFile("fileToFind", ".zim")
expectFromMediaStore(fileToFind)
fileSearch.scan("")
fileSearch.scan()
.test()
.assertValue(listOf(fileToFind))
}
@ -130,7 +132,7 @@ class FileSearchTest {
val unreadableFile = File.createTempFile("fileToFind", ".zim")
expectFromMediaStore(unreadableFile)
unreadableFile.delete()
fileSearch.scan("")
fileSearch.scan()
.test()
.assertValue(listOf())
}

View File

@ -25,6 +25,7 @@ import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import io.reactivex.Single
import io.reactivex.processors.BehaviorProcessor
import io.reactivex.processors.PublishProcessor
import io.reactivex.schedulers.TestScheduler
import org.junit.jupiter.api.AfterAll
@ -38,17 +39,12 @@ import org.kiwix.kiwixmobile.book
import org.kiwix.kiwixmobile.bookOnDisk
import org.kiwix.kiwixmobile.data.DataSource
import org.kiwix.kiwixmobile.data.remote.KiwixService
import org.kiwix.kiwixmobile.database.newdb.dao.FetchDownloadDao
import org.kiwix.kiwixmobile.database.newdb.dao.NewBookDao
import org.kiwix.kiwixmobile.database.newdb.dao.NewDownloadDao
import org.kiwix.kiwixmobile.database.newdb.dao.NewLanguagesDao
import org.kiwix.kiwixmobile.downloadItem
import org.kiwix.kiwixmobile.downloadModel
import org.kiwix.kiwixmobile.downloadStatus
import org.kiwix.kiwixmobile.downloader.Downloader
import org.kiwix.kiwixmobile.downloader.model.DownloadItem
import org.kiwix.kiwixmobile.downloader.model.DownloadModel
import org.kiwix.kiwixmobile.downloader.model.DownloadState
import org.kiwix.kiwixmobile.downloader.model.DownloadStatus
import org.kiwix.kiwixmobile.downloader.model.UriToFileConverter
import org.kiwix.kiwixmobile.language
import org.kiwix.kiwixmobile.library.entity.LibraryNetworkEntity.Book
import org.kiwix.kiwixmobile.libraryNetworkEntity
@ -65,7 +61,6 @@ import org.kiwix.kiwixmobile.zim_manager.fileselect_view.StorageObserver
import org.kiwix.kiwixmobile.zim_manager.fileselect_view.adapter.BooksOnDiskListItem
import org.kiwix.kiwixmobile.zim_manager.fileselect_view.adapter.BooksOnDiskListItem.BookOnDisk
import org.kiwix.kiwixmobile.zim_manager.library_view.adapter.LibraryListItem
import java.io.File
import java.util.Locale
import java.util.concurrent.TimeUnit.MILLISECONDS
import java.util.concurrent.TimeUnit.SECONDS
@ -73,17 +68,15 @@ import java.util.concurrent.TimeUnit.SECONDS
@ExtendWith(InstantExecutorExtension::class)
class ZimManageViewModelTest {
private val newDownloadDao: NewDownloadDao = mockk()
private val downloadDao: FetchDownloadDao = mockk()
private val newBookDao: NewBookDao = mockk()
private val newLanguagesDao: NewLanguagesDao = mockk()
private val downloader: Downloader = mockk()
private val storageObserver: StorageObserver = mockk()
private val kiwixService: KiwixService = mockk()
private val application: Application = mockk()
private val connectivityBroadcastReceiver: ConnectivityBroadcastReceiver = mockk()
private val bookUtils: BookUtils = mockk()
private val fat32Checker: Fat32Checker = mockk()
private val uriToFileConverter: UriToFileConverter = mockk()
private val defaultLanguageProvider: DefaultLanguageProvider = mockk()
private val dataSource: DataSource = mockk()
lateinit var viewModel: ZimManageViewModel
@ -92,7 +85,7 @@ class ZimManageViewModelTest {
private val booksOnFileSystem: PublishProcessor<List<BookOnDisk>> = PublishProcessor.create()
private val books: PublishProcessor<List<BookOnDisk>> = PublishProcessor.create()
private val languages: PublishProcessor<List<Language>> = PublishProcessor.create()
private val fileSystemStates: PublishProcessor<FileSystemState> = PublishProcessor.create()
private val fileSystemStates: BehaviorProcessor<FileSystemState> = BehaviorProcessor.create()
private val networkStates: PublishProcessor<NetworkState> = PublishProcessor.create()
private val booksOnDiskListItems: PublishProcessor<List<BooksOnDiskListItem>> =
PublishProcessor.create()
@ -112,7 +105,7 @@ class ZimManageViewModelTest {
fun init() {
clearAllMocks()
every { connectivityBroadcastReceiver.action } returns "test"
every { newDownloadDao.downloads() } returns downloads
every { downloadDao.downloads() } returns downloads
every { newBookDao.books() } returns books
every { storageObserver.booksOnFileSystem } returns booksOnFileSystem
every { newLanguagesDao.languages() } returns languages
@ -121,9 +114,17 @@ class ZimManageViewModelTest {
every { application.registerReceiver(any(), any()) } returns mockk()
every { dataSource.booksOnDiskAsListItems() } returns booksOnDiskListItems
viewModel = ZimManageViewModel(
newDownloadDao, newBookDao, newLanguagesDao, downloader,
storageObserver, kiwixService, application, connectivityBroadcastReceiver, bookUtils,
fat32Checker, uriToFileConverter, defaultLanguageProvider, dataSource
downloadDao,
newBookDao,
newLanguagesDao,
storageObserver,
kiwixService,
application,
connectivityBroadcastReceiver,
bookUtils,
fat32Checker,
defaultLanguageProvider,
dataSource
)
testScheduler.triggerActions()
}
@ -150,63 +151,19 @@ class ZimManageViewModelTest {
@Nested
inner class Downloads {
@Test
fun `on emission from database query and render downloads`() {
val expectedStatus = downloadStatus()
expectStatusWith(listOf(expectedStatus))
fun `on emission from database render downloads`() {
expectDownloads()
viewModel.downloadItems
.test()
.assertValue(listOf(DownloadItem(expectedStatus)))
.assertValue(listOf(downloadItem()))
}
@Test
fun `on emission of successful status create a book and delete the download`() {
every { uriToFileConverter.convert(any()) } returns File("test")
val expectedStatus = downloadStatus(
downloadId = 10L,
downloadState = DownloadState.Successful
)
expectStatusWith(listOf(expectedStatus))
val element = expectedStatus.toBookOnDisk(uriToFileConverter)
verify {
newBookDao.insert(listOf(element))
newDownloadDao.delete(10L)
}
}
@Test
fun `if statuses don't have a matching Id for download in db over 3 secs then delete`() {
expectStatusWith(
listOf(downloadStatus(downloadId = 1)),
listOf(downloadModel(downloadId = 1), downloadModel(downloadId = 3))
)
testScheduler.advanceTimeBy(3, SECONDS)
testScheduler.triggerActions()
verify {
newDownloadDao.delete(3)
}
}
@Test
fun `if statuses do have a matching Id for download in db over 3 secs then don't delete`() {
expectStatusWith(
listOf(downloadStatus(downloadId = 1)),
listOf(downloadModel(downloadId = 1))
)
testScheduler.advanceTimeBy(3, SECONDS)
testScheduler.triggerActions()
verify(exactly = 0) {
newDownloadDao.delete(any())
}
}
private fun expectStatusWith(
expectedStatuses: List<DownloadStatus>,
private fun expectDownloads(
expectedDownloads: List<DownloadModel> = listOf(
downloadModel()
)
) {
every { application.getString(any()) } returns ""
every { downloader.queryStatus(expectedDownloads) } returns expectedStatuses
downloads.offer(expectedDownloads)
testScheduler.triggerActions()
testScheduler.advanceTimeBy(1, SECONDS)
@ -387,7 +344,6 @@ class ZimManageViewModelTest {
@Test
fun `library update removes from sources`() {
every { downloader.queryStatus(any()) } returns emptyList()
every { application.getString(R.string.your_languages) } returns "1"
every { application.getString(R.string.other_languages) } returns "2"
val bookAlreadyOnDisk = book(

View File

@ -32,7 +32,7 @@ import org.junit.jupiter.api.Test
import org.kiwix.kiwixmobile.book
import org.kiwix.kiwixmobile.bookOnDisk
import org.kiwix.kiwixmobile.data.ZimContentProvider
import org.kiwix.kiwixmobile.database.newdb.dao.NewDownloadDao
import org.kiwix.kiwixmobile.database.newdb.dao.FetchDownloadDao
import org.kiwix.kiwixmobile.downloader.model.DownloadModel
import org.kiwix.kiwixmobile.library.entity.LibraryNetworkEntity.Book
import org.kiwix.kiwixmobile.resetSchedulers
@ -44,7 +44,7 @@ import java.io.File
class StorageObserverTest {
private val sharedPreferenceUtil: SharedPreferenceUtil = mockk()
private val newDownloadDao: NewDownloadDao = mockk()
private val downloadDao: FetchDownloadDao = mockk()
private val fileSearch: FileSearch = mockk()
private val downloadModel = mockk<DownloadModel>()
private val file = mockk<File>()
@ -66,9 +66,9 @@ class StorageObserverTest {
@BeforeEach fun init() {
clearAllMocks()
every { sharedPreferenceUtil.prefStorage } returns "a"
every { fileSearch.scan("a") } returns files
every { newDownloadDao.downloads() } returns downloads
storageObserver = StorageObserver(sharedPreferenceUtil, newDownloadDao, fileSearch)
every { fileSearch.scan() } returns files
every { downloadDao.downloads() } returns downloads
storageObserver = StorageObserver(downloadDao, fileSearch)
}
@Test

View File

@ -17,10 +17,15 @@
*/
package org.kiwix.kiwixmobile
import com.tonyodev.fetch2.Error
import com.tonyodev.fetch2.Status
import com.tonyodev.fetch2.Status.NONE
import org.kiwix.kiwixmobile.downloader.model.Base64String
import org.kiwix.kiwixmobile.downloader.model.DownloadItem
import org.kiwix.kiwixmobile.downloader.model.DownloadModel
import org.kiwix.kiwixmobile.downloader.model.DownloadState
import org.kiwix.kiwixmobile.downloader.model.DownloadState.Pending
import org.kiwix.kiwixmobile.downloader.model.DownloadStatus
import org.kiwix.kiwixmobile.downloader.model.Seconds
import org.kiwix.kiwixmobile.language.adapter.LanguageListItem.LanguageItem
import org.kiwix.kiwixmobile.library.entity.LibraryNetworkEntity
import org.kiwix.kiwixmobile.library.entity.LibraryNetworkEntity.Book
@ -39,29 +44,37 @@ fun bookOnDisk(
file: File = File("")
) = BookOnDisk(databaseId, book, file)
fun downloadStatus(
downloadId: Long = 0L,
title: String = "",
description: String = "",
downloadState: DownloadState = Pending,
bytesDownloadedSoFar: Long = 0L,
totalSizeBytes: Long = 0L,
lastModified: String = "",
localUri: String? = null,
mediaProviderUri: String? = null,
mediaType: String? = null,
uri: String? = null,
fun downloadModel(
databaseId: Long = 1L,
downloadId: Long = 1L,
file: String = "",
etaInMilliSeconds: Long = 0L,
bytesDownloaded: Long = 1L,
totalSizeOfDownload: Long = 1L,
status: Status = NONE,
error: Error = Error.NONE,
progress: Int = 1,
book: Book = book()
) = DownloadStatus(
downloadId, title, description, downloadState, bytesDownloadedSoFar,
totalSizeBytes, lastModified, localUri, mediaProviderUri, mediaType, uri, book
) = DownloadModel(
databaseId, downloadId, file, etaInMilliSeconds, bytesDownloaded, totalSizeOfDownload,
status, error, progress, book
)
fun downloadModel(
databaseId: Long? = 1L,
fun downloadItem(
downloadId: Long = 1L,
book: Book = book()
) = DownloadModel(databaseId, downloadId, book)
favIcon: Base64String = Base64String("favIcon"),
title: String = "title",
description: String = "description",
bytesDownloaded: Long = 1L,
totalSizeBytes: Long = 1L,
progress: Int = 1,
eta: Seconds = Seconds(0),
state: DownloadState = Pending
) =
DownloadItem(
downloadId, favIcon, title, description, bytesDownloaded,
totalSizeBytes, progress, eta, state
)
fun language(
id: Long = 0,

View File

@ -35,8 +35,9 @@ ext {
set("powerMockVersion", "1.6.6")
set("powerMockJUnitVersion", "1.7.4")
set("baristaVersion", "2.7.1")
set("kotlinVersion", "1.3.41")
set("kotlinVersion", "1.3.50")
set("objectboxVersion", "2.3.4")
set("fetchVersion", "3.1.4")
}
allprojects {

View File

@ -1 +1 @@
include(":app", ":kiwixlib")
include(":app")