Commit b5659d4f authored by Jonas L.'s avatar Jonas L.

Add support to import content from the device storage

parent c37e2f1d
......@@ -6,6 +6,7 @@ import java.io.IOException
import java.util.*
object ContentJsonParser {
const val CONTENT_JSON_FILENAME = "data.json"
const val CONTENT_JSON_PATH = "./data.json"
fun parseProject(reader: JsonReader): Project {
......
......@@ -10,6 +10,7 @@ import de.determapp.android.util.Validator
import okhttp3.HttpUrl
import okhttp3.Request
import okio.Okio
import okio.Source
import java.io.File
import java.io.FileReader
import java.io.IOException
......@@ -68,7 +69,6 @@ fun streamContentJson(context: Context, request: ContentJsonDownloadRequest): Pr
fun downloadContentJson(context: Context, request: ContentJsonDownloadRequest): ContentJsonDownloadResponse {
val baseUrl = HttpUrl.parse(request.baseUrl);
val downloadUrl = baseUrl!!.resolve(ContentJsonParser.CONTENT_JSON_PATH);
val contentStorage = ContentStorage.with(context)
// start request
Http.getClientWithCache(context).newCall(
......@@ -83,47 +83,56 @@ fun downloadContentJson(context: Context, request: ContentJsonDownloadRequest):
throw IOException("request failed")
}
// create temp file
val filename = ContentStorage.generateId()
val file = File(contentStorage.contentFilesDirectory, filename)
var success = false
return downloadContentJson(
context = context,
source = response.body()!!.source(),
expectedHash = request.assertHash,
expectedProjectId = request.projectId
)
}
}
contentStorage.contentFilesToKeep.add(filename)
fun downloadContentJson(context: Context, source: Source, expectedProjectId: String, expectedHash: String?): ContentJsonDownloadResponse {
val contentStorage = ContentStorage.with(context)
try {
// write response to file
Okio.buffer(Okio.sink(file)).use {
tempFileSink ->
// create temp file
val filename = ContentStorage.generateId()
val file = File(contentStorage.contentFilesDirectory, filename)
var success = false
tempFileSink.writeAll(response.body()!!.source())
}
contentStorage.contentFilesToKeep.add(filename)
// try to parse response from temp file
val project = ContentJsonParser.parseProject(JsonReader(FileReader(file)))
try {
// write response to file
Okio.buffer(Okio.sink(file)).use { tempFileSink ->
tempFileSink.writeAll(source)
}
// validation
if (project.projectId != request.projectId) {
throw IOException("downloaded package with other id")
}
// try to parse response from temp file
val project = ContentJsonParser.parseProject(JsonReader(FileReader(file)))
if (request.assertHash != null) {
if (request.assertHash != project.hash) {
throw IOException("downloaded package has other hash than expected");
}
}
// validation
if (project.projectId != expectedProjectId) {
throw IOException("downloaded package with other id")
}
success = true
return ContentJsonDownloadResponse(
filename = filename,
project = project,
fileSize = file.length()
);
} finally {
if (!success) {
file.delete()
contentStorage.contentFilesToKeep.remove(filename)
if (expectedHash != null) {
if (expectedHash != project.hash) {
throw IOException("downloaded package has other hash than expected");
}
}
success = true
return ContentJsonDownloadResponse(
filename = filename,
project = project,
fileSize = file.length()
);
} finally {
if (!success) {
file.delete()
contentStorage.contentFilesToKeep.remove(filename)
}
}
}
package de.determapp.android.content.download
import android.content.Context
import android.util.JsonReader
import androidx.core.os.CancellationSignal
import androidx.documentfile.provider.DocumentFile
import de.determapp.android.content.ContentJsonParser
import de.determapp.android.content.ContentStorage
import de.determapp.android.content.database.AppDatabaseInstance
import de.determapp.android.content.database.item.DownloadedLocalNetworkProject
import de.determapp.android.content.projectdata.Project
import de.determapp.android.ui.viewer.PackageSource
import de.determapp.android.ui.viewer.ProjectSpec
import de.determapp.android.util.ProgressListener
import okio.Okio
import java.io.File
import java.io.FileNotFoundException
import java.io.InputStreamReader
fun installDocumentFileContent(context: Context, documentFile: DocumentFile, progressListener: ProgressListener?): Project {
val database = AppDatabaseInstance.with(context)
val contentStorage = ContentStorage.with(context)
val dataJsonFile = documentFile.findFile(ContentJsonParser.CONTENT_JSON_FILENAME) ?:
throw FileNotFoundException()
val project = context.contentResolver.openInputStream(dataJsonFile.uri).use { contentJsonStream ->
InputStreamReader(contentJsonStream).use { contentJsonReader ->
ContentJsonParser.parseProject(JsonReader(contentJsonReader))
}
}
val projectId = project.projectId
val projectSpec = ProjectSpec(project.projectId, PackageSource.DownloadedFromLocalNetwork)
val oldDatabaseEntry = database.localNetworkProjectDao().getProjectByIdSync(projectId)
if (oldDatabaseEntry == null || oldDatabaseEntry.hash != project.hash) {
// download the current version
val downloadResponse = downloadContentJson(
context = context,
source = Okio.source(context.contentResolver.openInputStream(dataJsonFile.uri)!!),
expectedProjectId = projectId,
expectedHash = project.hash
)
updateProjectImages(
context,
documentFile,
project,
null,
ProjectSpec(project.projectId, PackageSource.DownloadedFromLocalNetwork),
CancellationSignal(), // don't keep a reference because we don't want to cancel here
progressListener
)
// save the new project version
if (oldDatabaseEntry == null) {
// create entry
database.localNetworkProjectDao().addProject(
DownloadedLocalNetworkProject(
projectId = project.projectId,
hash = project.hash,
localFilename = downloadResponse.filename,
title = project.title,
description = project.description
)
)
} else {
// update entry
database.localNetworkProjectDao().updateProject(
projectId,
project.hash,
downloadResponse.filename,
downloadResponse.fileSize,
project.title,
project.description
);
// delete old file
contentStorage.contentFilesToKeep.remove(oldDatabaseEntry.localFilename)
File(contentStorage.contentFilesDirectory, oldDatabaseEntry.localFilename).delete()
}
} else {
// only update the images
updateProjectImages(
context,
documentFile,
project,
null,
projectSpec,
CancellationSignal(), // don't keep a reference because we don't want to cancel here
progressListener
)
}
// delete all old images
deleteOldImagesFromProjectStorageDirectory(
projectSpec,
project,
null,
context
)
return project
}
\ No newline at end of file
......@@ -3,6 +3,7 @@ package de.determapp.android.content.download
import android.content.Context
import android.util.Log
import androidx.core.os.CancellationSignal
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.MutableLiveData
import de.determapp.android.BuildConfig
import de.determapp.android.Http
......@@ -17,7 +18,9 @@ import kotlinx.coroutines.channels.consumeEach
import okhttp3.HttpUrl
import okhttp3.Request
import okio.Okio
import okio.Source
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.util.*
......@@ -25,7 +28,7 @@ private const val NUM_OF_PARALLEL_DOWNLOADS = 4
private const val LOG_TAG = "ProjectImageDownloader"
val imageDownloadingNotifications = MutableLiveData<Void>()
fun updateProjectImages(context: Context, baseUrl: String, project: Project, resolution: Int?, projectSpec: ProjectSpec, cancellationSignal: CancellationSignal, progressListener: ProgressListener?) {
fun updateProjectImages(context: Context, getImageSource: (String) -> Source, project: Project, resolution: Int?, projectSpec: ProjectSpec, cancellationSignal: CancellationSignal, progressListener: ProgressListener?) {
if (projectSpec.projectId != project.projectId) {
throw IllegalStateException()
}
......@@ -89,18 +92,10 @@ fun updateProjectImages(context: Context, baseUrl: String, project: Project, res
val tempFileName = ContentStorage.generateId()
val tempFile = File(imageDirectory, tempFileName)
Http.uncachedClient.newCall(
Request.Builder()
.url(HttpUrl.parse(baseUrl)!!.resolve("./image/" + requestedFile.filename)!!)
.build()
).execute().use { response ->
if (!response.isSuccessful) {
throw IOException("request failed")
}
// write response to temp file
// write response to temp file
getImageSource(requestedFile.filename).use { srcFile ->
Okio.buffer(Okio.sink(tempFile)).use { tempFileSink ->
tempFileSink.writeAll(response.body()!!.source())
tempFileSink.writeAll(srcFile)
}
}
......@@ -132,6 +127,50 @@ fun updateProjectImages(context: Context, baseUrl: String, project: Project, res
}
}
fun updateProjectImages(context: Context, baseUrl: String, project: Project, resolution: Int?, projectSpec: ProjectSpec, cancellationSignal: CancellationSignal, progressListener: ProgressListener?) {
updateProjectImages(
context = context,
getImageSource = { filename: String ->
val response = Http.uncachedClient.newCall(
Request.Builder()
.url(HttpUrl.parse(baseUrl)!!.resolve("./image/" + filename)!!)
.build()
).execute()
if (!response.isSuccessful) {
response.close()
throw IOException("request failed")
}
response.body()!!.source()
},
project = project,
resolution = resolution,
projectSpec = projectSpec,
cancellationSignal = cancellationSignal,
progressListener = progressListener
)
}
fun updateProjectImages(context: Context, baseFolder: DocumentFile, project: Project, resolution: Int?, projectSpec: ProjectSpec, cancellationSignal: CancellationSignal, progressListener: ProgressListener?) {
updateProjectImages(
context = context,
getImageSource = { filename: String ->
Okio.source(
context.contentResolver.openInputStream(baseFolder.findFile("image")?.findFile(filename)?.uri
?: throw FileNotFoundException()
)!!
)
},
project = project,
resolution = resolution,
projectSpec = projectSpec,
cancellationSignal = cancellationSignal,
progressListener = progressListener
)
}
fun calculateSize(files: Collection<Image>, resolution: Int?): Long {
var result: Long = 0
......
package de.determapp.android.ui.install
import android.app.Application
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import de.determapp.android.content.download.installDocumentFileContent
import de.determapp.android.util.Progress
import de.determapp.android.util.ProgressListener
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
class InstallFromStorageModel(application: Application) : AndroidViewModel(application) {
private var wasStarted = false
private val statusInternal = MutableLiveData<InstallFromStorageStatus>()
val status: LiveData<InstallFromStorageStatus> = statusInternal
fun init(directoryTreeUri: Uri) {
if (wasStarted) {
return
}
wasStarted = true
GlobalScope.launch {
try {
statusInternal.postValue(WorkingInstallFromStorageStatus(0, 100))
val dir = DocumentFile.fromTreeUri(getApplication(), directoryTreeUri)!!
var done = false
val response = installDocumentFileContent(getApplication(), dir, object: ProgressListener {
override fun onProgressChanged(progress: Progress) {
if (!done) {
statusInternal.postValue(WorkingInstallFromStorageStatus(progress.current, progress.max))
}
}
})
done = true
statusInternal.postValue(DoneInstallFromStorageStatus(response.title))
} catch (ex: Exception) {
statusInternal.postValue(FailedInstallFromStorageStatus(ex))
}
}
}
}
sealed class InstallFromStorageStatus
data class DoneInstallFromStorageStatus(val projectTitle: String): InstallFromStorageStatus()
data class FailedInstallFromStorageStatus(val exception: Exception): InstallFromStorageStatus()
data class WorkingInstallFromStorageStatus(val progress: Int, val max: Int): InstallFromStorageStatus()
\ No newline at end of file
package de.determapp.android.ui.install
import android.app.Dialog
import android.app.ProgressDialog
import android.net.Uri
import android.os.Bundle
import android.widget.Toast
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import de.determapp.android.R
class InstallFromStorageProgressDialog: DialogFragment() {
companion object {
private const val DIRECTORY_TREE_URI = "directory tree uri"
private const val DIALOG_TAG = "InstallFromStorageProgressDialog"
fun newInstance(directoryTreeUri: Uri) = InstallFromStorageProgressDialog().apply {
arguments = Bundle().apply {
putParcelable(DIRECTORY_TREE_URI, directoryTreeUri)
}
}
}
private val model: InstallFromStorageModel by lazy {
ViewModelProviders.of(this).get(InstallFromStorageModel::class.java)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
model.init(arguments!!.getParcelable(DIRECTORY_TREE_URI) as Uri)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = ProgressDialog(context!!, theme)
dialog.setTitle(R.string.dialog_install_from_storage_title)
dialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL)
dialog.isIndeterminate = false
dialog.setProgressNumberFormat("")
dialog.setCancelable(false)
dialog.setCanceledOnTouchOutside(false)
model.status.observe(this, Observer { status ->
when (status) {
is DoneInstallFromStorageStatus -> {
Toast.makeText(
context!!,
getString(R.string.dialog_install_from_storage_success, status.projectTitle),
Toast.LENGTH_SHORT
).show()
dismiss()
}
is WorkingInstallFromStorageStatus -> {
dialog.max = status.max
dialog.progress = status.progress
}
is FailedInstallFromStorageStatus -> {
Toast.makeText(context!!, R.string.dialog_install_from_storage_failed, Toast.LENGTH_SHORT).show()
dismiss()
}
}
})
return dialog
}
fun show(fragmentManager: FragmentManager) = show(fragmentManager, DIALOG_TAG)
}
\ No newline at end of file
package de.determapp.android.ui.storage
import android.app.Activity
import android.app.Application
import android.content.Intent
import android.os.Build
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
......@@ -11,13 +14,19 @@ import androidx.recyclerview.widget.LinearLayoutManager
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import de.determapp.android.R
import de.determapp.android.ui.install.InstallFromStorageProgressDialog
import de.determapp.android.ui.viewer.info.DeleteProjectAlertDialogFragment
import de.determapp.android.ui.viewer.info.DeleteProjectProgressDialogFragment
import kotlinx.android.synthetic.main.fragment_manage_storage.*
class ManageStorageFragment : Fragment(), ManageStorageHandlers {
companion object {
private const val REQUEST_INSTALL = 1
}
val adapter = ManageStorageAdapter()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
......@@ -43,6 +52,17 @@ class ManageStorageFragment : Fragment(), ManageStorageHandlers {
empty.visibility = View.GONE
}
})
install_from_storage.setOnClickListener {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
startActivityForResult(
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE),
REQUEST_INSTALL
)
} else {
Toast.makeText(context!!, R.string.install_from_storage_min_android_toast, Toast.LENGTH_SHORT).show()
}
}
}
override fun onRequestDeleteElement(item: ProjectStorageItem) {
......@@ -52,6 +72,14 @@ class ManageStorageFragment : Fragment(), ManageStorageHandlers {
DeleteProjectAlertDialogFragment().show(item.spec, item.title, fragmentManager!!)
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_INSTALL && resultCode == Activity.RESULT_OK) {
InstallFromStorageProgressDialog.newInstance(data!!.data!!).show(fragmentManager!!)
}
}
}
class ManageStorageModel(application: Application): AndroidViewModel(application) {
......
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.storage.ManageStorageFragment">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler"
<RelativeLayout
android:layout_weight="1"
android:layout_width="match_parent"
android:layout_height="match_parent" />
android:layout_height="0dp">
<TextView
android:id="@+id/empty"
android:visibility="gone"
tools:visibility="visible"
android:textAppearance="?android:textAppearanceLarge"
android:padding="@dimen/view_margin"
android:gravity="center_horizontal"
android:text="@string/fragment_manage_storage_empty"
android:layout_centerVertical="true"
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<TextView
android:id="@+id/empty"
android:visibility="gone"
tools:visibility="visible"
android:textAppearance="?android:textAppearanceLarge"
android:padding="@dimen/view_margin"
android:gravity="center_horizontal"
android:text="@string/fragment_manage_storage_empty"
android:layout_centerVertical="true"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</RelativeLayout>
<Button
android:id="@+id/install_from_storage"
android:text="@string/nav_item_install"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</RelativeLayout>
</LinearLayout>
......@@ -3,6 +3,7 @@
<string name="nav_item_content">Bestimmungsschlüssel</string>
<string name="nav_item_receive">Inhalte empfangen</string>
<string name="nav_item_install">Inhalte vom Speicher installieren</string>
<string name="nav_item_package_sources">Paketquellen</string>
<string name="nav_item_storage">Speicher</string>
<string name="nav_item_update">Updates</string>
......@@ -63,7 +64,7 @@
<string name="viewer_info_dialog_btn_imprint">Impressum des Bestimmungsschlüssels öffnen</string>
<string name="viewer_info_dialog_section_resolution">Auflösung der Bilder</string>
<string name="viewer_info_dialog_section_download">Download</string>
<string name="viewer_info_dialog_download_localnetwork">Dieser Bestimmungsschlüssel wurde über ein lokales Netzwerk übertragen. Aktualisierungen können über die Funktion &quot;Empfangen&quot; installiert werden</string>
<string name="viewer_info_dialog_download_localnetwork">Dieser Bestimmungsschlüssel wurde von einer unbekannten Quelle installiert. Aktualisierungen können über die Funktion \&quot;Empfangen\&quot; oder per Import vom lokalen Speicher installiert werden.</string>
<string name="viewer_info_dialog_btn_download">Herunterladen</string>
<string name="viewer_info_dialog_download_conditions_info">Der Bestimmungsschlüssel wird heruntergeladen, sobald eine unbegrenzte Internverbindung (z.B. WLAN) vorhanden ist, der interne Speicher nicht knapp ist und der Akku nicht fast leer ist.</string>
<string name="viewer_info_dialog_download_conditions_min_info">Der Bestimmungsschlüssel wird heruntergeladen, sobald eine Internverbindung vorhanden ist.</string>
......@@ -108,7 +109,7 @@
<string name="fragment_manage_storage_empty">
Nichts \"belastet\" deinen Speicher - Du hast noch Nichts heruntergeladen
</string>
<string name="manage_storage_kind_local_network">Über ein lokales Netzwerk übertragen</string>
<string name="manage_storage_kind_local_network">Von einer unbekannten Quelle</string>
<string name="manage_storage_kind_web">Aus dem Internet heruntergeladen</string>
<string name="manage_storage_incomplete">Nicht verwendbar, da unvollständig</string>
<string name="fragment_storage_size">Belegter Speicherplatz: %s</string>
......@@ -123,4 +124,9 @@
<string name="fragment_update_content_requested">Eine Aktualisierung wurde angefordert und wird ausgeführt, sobald eine Internverbindung besteht</string>
<string name="fragment_update_content_btn_cancel">Manuelle Aktualisierung abbrechen</string>
<string name="fragment_update_content_btn_request_manually">Jetzt aktualisieren</string>
<string name="dialog_install_from_storage_title">Vom Speicher installieren</string>
<string name="dialog_install_from_storage_success">%s wurde installiert</string>
<string name="dialog_install_from_storage_failed">Installation fehlgeschlagen. Stellen Sie sicher, dass der Ordner einen gültigen DetermApp-Export enthält.</string>
<string name="install_from_storage_min_android_toast">Diese Funktion benötigt Android 5 oder neuer</string>
</resources>
......@@ -4,6 +4,7 @@
<string name="nav_item_content">Determination keys</string>
<string name="nav_item_receive">Receive content</string>
<string name="nav_item_install">Install content from storage</string>
<string name="nav_item_package_sources">Package sources</string>
<string name="nav_item_storage">Storage</string>
<string name="nav_item_update">Updates</string>
......@@ -64,7 +65,7 @@
<string name="viewer_info_dialog_btn_imprint">Open imprint of this identification key</string>
<string name="viewer_info_dialog_section_resolution">Resolution of the pictures</string>
<string name="viewer_info_dialog_section_download">Download</string>
<string name="viewer_info_dialog_download_localnetwork">This identification key was downloaded using a local network. You can install updates using the \"receive\" feature</string>
<string name="viewer_info_dialog_download_localnetwork">This identification key was installed from a unknown source. You can install updates using the \"receive\" feature or by installing from the local storage.</string>
<string name="viewer_info_dialog_btn_download">Download</string>
<string name="viewer_info_dialog_download_conditions_info">The identification key will be downloaded as soon as there is a unmetered connection, enough free storage and enough battery power.</string>
<string name="viewer_info_dialog_download_conditions_min_info">The identification key will be downloaded as soon as there is a internet connection.</string>
......@@ -125,7 +126,7 @@
You have to copy it again to this device to use it.
</string>
<string name="dialog_delete_text_web">
Do you want to remove \"%s\" from the storage.
Do you want to remove \"%s\" from the storage?
You will need a internet connection/ muste download it again to use it.
</string>
<string name="dialog_delete_action">Delete</string>
......@@ -135,7 +136,7 @@
<string name="fragment_manage_storage_empty">
Nothing is in your storage - You did not download anything
</string>
<string name="manage_storage_kind_local_network">Received using a local network</string>
<string name="manage_storage_kind_local_network">From a unknown source</string>
<string name="manage_storage_kind_web">Downloaded from the internet</string>
<string name="manage_storage_incomplete">Not usable because not complete</string>
<string name="fragment_storage_size">Used storage: %s</string>
......@@ -150,4 +151,9 @@
<string name="fragment_update_content_requested">A update was requested and will be executed as soon as a internet connection is available</string>
<string name="fragment_update_content_btn_cancel">Cancel manually update</string>
<string name="fragment_update_content_btn_request_manually">Update now</string>
<string name="dialog_install_from_storage_title">Install from storage</string>
<string name="dialog_install_from_storage_success">%s was installed</string>
<string name="dialog_install_from_storage_failed">Installation failed. Please ensure that the folder contains a valid DetermApp export.</string>
<string name="install_from_storage_min_android_toast">You need Android 5 or newer for this feature</string>
</resources>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment