Commit fd6ac67c authored by genofire's avatar genofire
Browse files

Merge branch 'batch-filetransfer' into 'master'

Batch file transfer / app installation

See merge request card10/companion-app-android!11
parents 965e7ba1 05b20535
/*
* Copyright by the original author or authors.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package de.ccc.events.badge.card10.filetransfer
import android.bluetooth.BluetoothGatt
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import de.ccc.events.badge.card10.R
import de.ccc.events.badge.card10.common.ConnectionService
import de.ccc.events.badge.card10.common.GattListener
import de.ccc.events.badge.card10.main.MainFragment
import kotlinx.android.synthetic.main.batch_transfer_fragment.*
import java.lang.Exception
import java.lang.IllegalStateException
private const val TAG = "BatchTransferFragment"
class BatchTransferFragment : Fragment(), FileTransferListener, GattListener {
private lateinit var queue: TransferQueue
private var transfer: FileTransfer? = null
private var isCancelled = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val args = arguments ?: throw IllegalStateException()
val jobs = args.get("jobs") as? Array<TransferJob> ?: throw IllegalStateException()
queue = TransferQueue(jobs)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? =
inflater.inflate(R.layout.batch_transfer_fragment, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
label_status.text = getString(R.string.batch_transfer_label_initializing)
progress.max = 5
button_cancel.setOnClickListener {
// isCancelled = true
startTransfer()
}
button_done.setOnClickListener {
val fragment = MainFragment()
fragmentManager!!.beginTransaction()
.replace(R.id.fragment_container, fragment)
.addToBackStack(null)
.commit()
}
initConnection()
}
private fun initConnection() {
val ctx = context ?: throw IllegalStateException()
ConnectionService.connect(ctx)
}
private fun startTransfer() {
activity?.runOnUiThread {
label_status.text = getString(R.string.batch_transfer_label_transferring)
progress.max = queue.size
}
transferNext()
}
private fun transferNext() {
val item = queue.dequeue()
if (item == null || isCancelled) {
activity?.runOnUiThread {
progress.progress = 0
label_status.text = if (isCancelled) {
getString(R.string.batch_transfer_label_cancelled)
} else {
getString(R.string.batch_transfer_label_complete)
}
button_cancel.visibility = View.GONE
button_done.visibility = View.VISIBLE
}
} else {
transferItem(item)
}
}
private fun transferItem(transferJob: TransferJob) {
try {
val ctx = activity ?: throw IllegalStateException()
val reader = ChunkedReader(ctx, transferJob.sourceUri, ConnectionService.mtu)
val service = ConnectionService.leService ?: throw IllegalStateException()
transfer = FileTransfer(service, reader,this, transferJob.destPath)
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize transfer")
return
}
}
override fun onConnectionStateChange(state: Int) {
if (state == BluetoothGatt.STATE_CONNECTED) {
startTransfer()
}
}
override fun onError() {
activity?.runOnUiThread {
label_status.text = getString(R.string.batch_transfer_label_error)
button_cancel.visibility = View.GONE
button_done.visibility = View.VISIBLE
}
}
override fun onFinish() {
activity?.runOnUiThread {
// TODO: Add workaround for broken progress bars
// https://stackoverflow.com/questions/4348032/android-progressbar-does-not-update-progress-view-drawable
progress.incrementProgressBy(1)
}
transferNext()
}
}
\ No newline at end of file
/*
* Copyright by the original author or authors.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package de.ccc.events.badge.card10.filetransfer
import android.net.Uri
import android.os.Parcel
import android.os.Parcelable
import kotlinx.android.parcel.Parcelize
// Ignore IDE warnings. @Parcelize will take care of everything
@Parcelize
data class TransferJob(
val sourceUri: Uri,
val destPath: String
) : Parcelable
\ No newline at end of file
/*
* Copyright by the original author or authors.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package de.ccc.events.badge.card10.filetransfer
import android.net.Uri
import java.util.*
import kotlin.NoSuchElementException
class TransferQueue() {
private val queue = LinkedList<TransferJob>()
val size: Int
get() = queue.size
constructor(jobs: Array<TransferJob>) : this() {
for (job in jobs) {
queue.add(job)
}
}
fun enqueue(sourceUri: Uri, destPath: String) {
queue.add(TransferJob(sourceUri, destPath))
}
fun dequeue(): TransferJob? {
return try {
queue.removeFirst()
} catch (e: NoSuchElementException) {
null
}
}
fun clear() = queue.clear()
}
\ No newline at end of file
...@@ -22,6 +22,7 @@ ...@@ -22,6 +22,7 @@
package de.ccc.events.badge.card10.hatchery package de.ccc.events.badge.card10.hatchery
import android.net.Uri
import android.os.AsyncTask import android.os.AsyncTask
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
...@@ -29,9 +30,13 @@ import android.view.LayoutInflater ...@@ -29,9 +30,13 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.net.toUri
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import de.ccc.events.badge.card10.R import de.ccc.events.badge.card10.R
import de.ccc.events.badge.card10.common.LoadingDialog import de.ccc.events.badge.card10.common.LoadingDialog
import de.ccc.events.badge.card10.filetransfer.BatchTransferFragment
import de.ccc.events.badge.card10.filetransfer.TransferJob
import kotlinx.android.synthetic.main.app_detail_fragment.* import kotlinx.android.synthetic.main.app_detail_fragment.*
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
...@@ -73,7 +78,8 @@ class AppDetailFragment : Fragment() { ...@@ -73,7 +78,8 @@ class AppDetailFragment : Fragment() {
) { dialog, _ -> dialog.dismiss() } ) { dialog, _ -> dialog.dismiss() }
.create() .create()
ReleaseDownload(app, ctx.cacheDir, loadingDialog, errorDialog).execute() val fm = fragmentManager ?: throw java.lang.IllegalStateException()
ReleaseDownload(app, ctx.cacheDir, loadingDialog, errorDialog, fm).execute()
} }
} }
...@@ -81,10 +87,11 @@ class AppDetailFragment : Fragment() { ...@@ -81,10 +87,11 @@ class AppDetailFragment : Fragment() {
private val app: App, private val app: App,
private val cacheDir: File, private val cacheDir: File,
private val loadingDialog: LoadingDialog, private val loadingDialog: LoadingDialog,
private val errorDialog: AlertDialog private val errorDialog: AlertDialog,
) : AsyncTask<Void, Void, List<String>?>() { private val fragmentManager: FragmentManager
) : AsyncTask<Void, Void, List<TransferJob>?>() {
override fun doInBackground(vararg p0: Void?): List<String>? { override fun doInBackground(vararg p0: Void?): List<TransferJob>? {
return try { return try {
cacheDir.deleteRecursively() cacheDir.deleteRecursively()
cacheDir.mkdir() cacheDir.mkdir()
...@@ -96,7 +103,7 @@ class AppDetailFragment : Fragment() { ...@@ -96,7 +103,7 @@ class AppDetailFragment : Fragment() {
inputStream.copyTo(outputStream) inputStream.copyTo(outputStream)
val appFiles = mutableListOf<String>() val appFiles = mutableListOf<TransferJob>()
val tarStream = TarArchiveInputStream(GzipCompressorInputStream(file.inputStream())) val tarStream = TarArchiveInputStream(GzipCompressorInputStream(file.inputStream()))
while (true) { while (true) {
val entry = tarStream.nextTarEntry ?: break val entry = tarStream.nextTarEntry ?: break
...@@ -110,7 +117,7 @@ class AppDetailFragment : Fragment() { ...@@ -110,7 +117,7 @@ class AppDetailFragment : Fragment() {
targetFile.createNewFile() targetFile.createNewFile()
Log.d(TAG, "Extracting ${entry.name} to ${targetFile.absolutePath}") Log.d(TAG, "Extracting ${entry.name} to ${targetFile.absolutePath}")
tarStream.copyTo(targetFile.outputStream()) tarStream.copyTo(targetFile.outputStream())
appFiles.add("apps/${entry.name}") appFiles.add(TransferJob(targetFile.toUri(), "apps/${entry.name}"))
} }
val launcher = createLauncher(app.slug, cacheDir) val launcher = createLauncher(app.slug, cacheDir)
...@@ -122,17 +129,27 @@ class AppDetailFragment : Fragment() { ...@@ -122,17 +129,27 @@ class AppDetailFragment : Fragment() {
} }
} }
override fun onPostExecute(result: List<String>?) { override fun onPostExecute(jobs: List<TransferJob>?) {
if (result == null) { if (jobs == null) {
loadingDialog.dismiss() loadingDialog.dismiss()
errorDialog.show() errorDialog.show()
return return
} }
loadingDialog.dismiss() loadingDialog.dismiss()
val bundle = Bundle()
bundle.putParcelableArray("jobs", jobs.toTypedArray())
val fragment = BatchTransferFragment()
fragment.arguments = bundle
fragmentManager.beginTransaction()
.replace(R.id.fragment_container, fragment)
.addToBackStack(null)
.commit()
} }
fun createLauncher(slug: String, cacheDir: File): String { fun createLauncher(slug: String, cacheDir: File): TransferJob {
val fileName = "$slug.py" val fileName = "$slug.py"
val file = File(cacheDir, fileName) val file = File(cacheDir, fileName)
file.createNewFile() file.createNewFile()
...@@ -145,7 +162,7 @@ class AppDetailFragment : Fragment() { ...@@ -145,7 +162,7 @@ class AppDetailFragment : Fragment() {
file.writeText(src) file.writeText(src)
return fileName return TransferJob(file.toUri(), fileName)
} }
} }
} }
...@@ -40,7 +40,7 @@ class HatcheryClient { ...@@ -40,7 +40,7 @@ class HatcheryClient {
// TODO: Filter by category // TODO: Filter by category
val request = Request.Builder() val request = Request.Builder()
.url("$HATCHERY_BASE_URL/eggs/list/json") .url("$HATCHERY_BASE_URL/basket/card10/list/json")
.build() .build()
val response: Response val response: Response
......
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical" android:orientation="vertical"
android:gravity="center_vertical"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="@dimen/default_list_item_height"> android:layout_height="@dimen/default_list_item_height"
android:background="?selectableItemBackground">
<TextView <TextView
android:id="@+id/label_name" android:id="@+id/label_name"
...@@ -10,6 +12,6 @@ ...@@ -10,6 +12,6 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="start" android:layout_gravity="start"
android:paddingLeft="@dimen/default_list_item_margin_side" android:paddingLeft="@dimen/default_list_item_margin_side"
android:textStyle="bold"/> android:textAppearance="@style/TextAppearance.AppCompat.Body2"/>
</LinearLayout> </LinearLayout>
\ No newline at end of file
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="@dimen/activity_padding">
<LinearLayout android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
android:orientation="vertical"
android:gravity="center_horizontal">
<ProgressBar android:layout_width="@dimen/batch_transfer_progress"
android:layout_height="wrap_content"
android:id="@+id/progress"
android:indeterminate="false"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"/>
<TextView android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/label_status"/>
</LinearLayout>
<Button android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
android:id="@+id/button_cancel"
android:text="@string/batch_transfer_button_cancel"/>
<Button android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
android:id="@+id/button_done"
android:visibility="gone"
android:text="@string/batch_transfer_button_done"/>
</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
...@@ -7,6 +7,8 @@ ...@@ -7,6 +7,8 @@
<dimen name="main_button_margin">16dp</dimen> <dimen name="main_button_margin">16dp</dimen>
<dimen name="send_label_margin">24dp</dimen> <dimen name="send_label_margin">24dp</dimen>
<dimen name="batch_transfer_progress">200dp</dimen>
<dimen name="app_list_item_padding">16dp</dimen> <dimen name="app_list_item_padding">16dp</dimen>
<dimen name="app_detail_description_margin">16dp</dimen> <dimen name="app_detail_description_margin">16dp</dimen>
...@@ -16,5 +18,5 @@ ...@@ -16,5 +18,5 @@
<dimen name="loading_dialog_text_margin">16dp</dimen> <dimen name="loading_dialog_text_margin">16dp</dimen>
<dimen name="default_list_item_margin_side">16dp</dimen> <dimen name="default_list_item_margin_side">16dp</dimen>
<dimen name="default_list_item_height">48dp</dimen> <dimen name="default_list_item_height">56dp</dimen>
</resources> </resources>
...@@ -19,6 +19,15 @@ ...@@ -19,6 +19,15 @@
<string name="file_transfer_hint_destination">Destination path</string> <string name="file_transfer_hint_destination">Destination path</string>
<string name="file_transfer_label_destination_help">(e.g. /test.py)</string> <string name="file_transfer_label_destination_help">(e.g. /test.py)</string>
<string name="batch_transfer_label_initializing">Initializing…</string>
<string name="batch_transfer_label_transferring">Transferring files…</string>
<string name="batch_transfer_label_complete">Files transferred</string>
<string name="batch_transfer_label_error">Transfer failed</string>
<string name="batch_transfer_label_cancelled">Transfer cancelled</string>
<string name="batch_transfer_button_cancel">Cancel</string>
<string name="batch_transfer_button_done">Done</string>
<string name="loading_dialog_loading">Loading</string> <string name="loading_dialog_loading">Loading</string>
<string name="dialog_action_ok">OK</string> <string name="dialog_action_ok">OK</string>
<string name="dialog_action_cancel">Cancel</string> <string name="dialog_action_cancel">Cancel</string>
......
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