Commit 7213f2db authored by Joey's avatar Joey

export: use storage access framework to export files

Signed-off-by: Joey's avatarJoey <bevilacquajoey@gmail.com>
Change-Id: I66ff2d4d6b16ae72d5bcf42724cc2c075465a3c4
parent c7439f56
......@@ -10,7 +10,6 @@
package="it.diab.export">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<permission
android:name="it.diab.export.ALLOW_EXPORT"
......
......@@ -13,27 +13,27 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.net.Uri
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import it.diab.core.util.PreferencesUtil
import it.diab.data.repositories.GlucoseRepository
import it.diab.data.repositories.InsulinRepository
import it.diab.core.util.PreferencesUtil
import it.diab.export.writer.CsvWriter
import it.diab.export.writer.MlWriter
import it.diab.export.writer.XlsxWriter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.io.File
class ExportService : Service() {
private lateinit var glucoseRepository: GlucoseRepository
private lateinit var insulinRepository: InsulinRepository
private lateinit var notificationManager: NotificationManager
private lateinit var outUri: Uri
private val job = Job()
private val serviceScope = CoroutineScope(Dispatchers.IO + job)
......@@ -61,9 +61,11 @@ class ExportService : Service() {
createChannelIfNeeded()
}
outUri = intent?.getParcelableExtra(Intent.EXTRA_ORIGINATING_URI) ?: return START_NOT_STICKY
startForeground(RUNNING_NOTIFICATION_ID, notification)
when (intent?.getIntExtra(EXPORT_TARGET, -1)) {
when (intent.getIntExtra(EXPORT_TARGET, -1)) {
TARGET_CSV -> exportCsv(this::onTaskCompleted)
TARGET_XLSX -> exportXlxs(this::onTaskCompleted)
}
......@@ -80,7 +82,7 @@ class ExportService : Service() {
.setProgress(100, 10, true)
.build()
private fun buildCompletedNotification(file: File?, success: Boolean) {
private fun buildCompletedNotification(success: Boolean) {
val message = getString(if (success) R.string.export_completed_success else R.string.export_completed_failure)
val notification = NotificationCompat.Builder(this, CHANNEL)
......@@ -89,50 +91,52 @@ class ExportService : Service() {
.setContentText(message)
.setColor(ContextCompat.getColor(this, R.color.colorAccent))
if (success && file != null && file.exists()) {
val uri = FileProvider.getUriForFile(this, "it.diab.files", file)
val shareIntent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
type = "application/octet-stream"
}
val shareIntent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_STREAM, outUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
type = "application/octet-stream"
}
val sharePendingIntent = PendingIntent.getActivity(this, 0,
Intent.createChooser(shareIntent, getString(R.string.export_share)), PendingIntent.FLAG_CANCEL_CURRENT)
val sharePendingIntent = PendingIntent.getActivity(
this, 0,
Intent.createChooser(shareIntent, getString(R.string.export_share)), PendingIntent.FLAG_CANCEL_CURRENT
)
val openIntent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, contentResolver.getType(uri))
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val openIntent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(outUri, contentResolver.getType(outUri))
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val openPendingIntent = PendingIntent.getActivity(this, 0,
Intent.createChooser(openIntent, getString(R.string.export_share)), PendingIntent.FLAG_CANCEL_CURRENT)
val openPendingIntent = PendingIntent.getActivity(
this, 0,
Intent.createChooser(openIntent, getString(R.string.export_share)), PendingIntent.FLAG_CANCEL_CURRENT
)
notification.addAction(R.drawable.ic_export, getString(R.string.export_share), sharePendingIntent)
.setContentIntent(openPendingIntent)
.setAutoCancel(true)
}
notification.addAction(R.drawable.ic_export, getString(R.string.export_share), sharePendingIntent)
.setContentIntent(openPendingIntent)
.setAutoCancel(true)
notificationManager.notify(COMPLETED_NOTIFICATION_ID, notification.build())
}
private fun exportCsv(onTaskCompleted: (File?, Boolean) -> Unit) {
private fun exportCsv(onTaskCompleted: (Boolean) -> Unit) {
val lowThreshold = PreferencesUtil.getGlucoseLowThreshold(this)
val highThreshold = PreferencesUtil.getGlucoseHighThreshold(this)
serviceScope.launch {
val trainResult = CsvWriter.exportTrain(this, glucoseRepository)
val testResult = CsvWriter.exportTest(this,
glucoseRepository,
lowThreshold..highThreshold
)
val descriptor = contentResolver.openFileDescriptor(outUri, "w")?.fileDescriptor
if (descriptor == null) {
onTaskCompleted(false)
return
}
GlobalScope.launch(Dispatchers.Main) { onTaskCompleted(null, trainResult && testResult) }
serviceScope.launch {
val writer = MlWriter(descriptor, glucoseRepository, lowThreshold..highThreshold)
val result = writer.export()
GlobalScope.launch(Dispatchers.Main) { onTaskCompleted(result) }
}
}
private fun exportXlxs(onTaskCompleted: (File?, Boolean) -> Unit) {
private fun exportXlxs(onTaskCompleted: (Boolean) -> Unit) {
val glucoseHeaders = listOf(
getString(R.string.export_sheet_glucose_value),
getString(R.string.export_sheet_glucose_date),
......@@ -149,21 +153,22 @@ class ExportService : Service() {
getString(R.string.export_sheet_insulin_half_units)
)
val descriptor = contentResolver.openFileDescriptor(outUri, "rw")
if (descriptor == null) {
onTaskCompleted(false)
return
}
serviceScope.launch {
val result = XlsxWriter.exportSheet(
this,
glucoseRepository,
insulinRepository,
glucoseHeaders,
insulinHeaders
)
GlobalScope.launch(Dispatchers.Main) { onTaskCompleted(result, result != null) }
val writer = XlsxWriter(this, descriptor, glucoseRepository, insulinRepository)
val result = writer.exportSheet(glucoseHeaders, insulinHeaders)
GlobalScope.launch(Dispatchers.Main) { onTaskCompleted(result) }
}
}
private fun onTaskCompleted(output: File?, result: Boolean) {
private fun onTaskCompleted(result: Boolean) {
notificationManager.cancel(RUNNING_NOTIFICATION_ID)
buildCompletedNotification(output, result)
buildCompletedNotification(result)
stopSelf()
}
......
/*
* Copyright (c) 2019 Bevilacqua Joey
*
* Licensed under the GNU GPLv3 license
*
* The text of the license can be found in the LICENSE file
* or at https://www.gnu.org/licenses/gpl.txt
*/
package it.diab.export.utils
import android.app.Activity
import android.app.KeyguardManager
import android.content.Intent
import android.net.Uri
import android.os.Build
import androidx.fragment.app.Fragment
import it.diab.export.ExportService
import it.diab.export.R
class SecureFilePickerHelper(
private val fragment: Fragment,
private val callbacks: Callbacks
) {
private val res = fragment.resources
fun authenticate(category: Int) {
val keyguardManager = fragment.context?.getSystemService(KeyguardManager::class.java)
val title = res.getString(R.string.export_ask_auth_title)
val message = res.getString(R.string.export_ask_auth_message)
val requestIntent = keyguardManager?.createConfirmDeviceCredentialIntent(title, message)
if (requestIntent == null) {
callbacks.onAuthentication(category, AuthResult.NOT_NEEDED)
return
}
fragment.startActivityForResult(requestIntent, REQUEST_AUTHENTICATION or category)
}
fun pickDestination(category: Int) {
val type = when (category) {
ML -> MIME_ML
XLSX -> MIME_XLSX
else -> throw IllegalArgumentException("$category is not a valid category")
}
val name = when (category) {
ML -> "diab_ml.zip"
XLSX -> "diab.xlsx"
else -> throw IllegalArgumentException("$category is not a valid category")
}
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
.addCategory(Intent.CATEGORY_OPENABLE)
.setType(type)
.putExtra(Intent.EXTRA_TITLE, name)
fragment.startActivityForResult(intent, REQUEST_PICK or category)
}
fun onResult(
requestCode: Int,
resultCode: Int,
data: Intent?
) {
val success = resultCode == Activity.RESULT_OK
when {
(requestCode and REQUEST_AUTHENTICATION) != 0 -> callbacks.onAuthentication(
requestCode xor REQUEST_AUTHENTICATION,
if (success) AuthResult.SUCCESS else AuthResult.FAILURE
)
(requestCode and REQUEST_PICK) != 0 -> onPicked(
requestCode xor REQUEST_PICK,
data?.data
)
}
}
private fun onPicked(category: Int, data: Uri?) {
val action = when (category) {
ML -> ExportService.TARGET_CSV
XLSX -> ExportService.TARGET_XLSX
else -> throw IllegalArgumentException("$category is not a valid category")
}
val intent = Intent(fragment.requireContext(), ExportService::class.java)
.putExtra(ExportService.EXPORT_TARGET, action)
.putExtra(Intent.EXTRA_ORIGINATING_URI, data)
if (Build.VERSION.SDK_INT >= 26) {
fragment.requireContext().startForegroundService(intent)
} else {
fragment.requireContext().startService(intent)
}
}
interface Callbacks {
fun onAuthentication(category: Int, result: AuthResult)
}
enum class AuthResult {
SUCCESS,
FAILURE,
NOT_NEEDED
}
companion object {
const val ML = 1
const val XLSX = 1 shl 1
const val REQUEST_PICK = 1 shl 2
const val REQUEST_AUTHENTICATION = 1 shl 3
const val MIME_ML = "application/zip"
const val MIME_XLSX = "application/octet-stream"
}
}
\ No newline at end of file
/*
* Copyright (c) 2019 Bevilacqua Joey
*
* Licensed under the GNU GPLv3 license
*
* The text of the license can be found in the LICENSE file
* or at https://www.gnu.org/licenses/gpl.txt
*/
package it.diab.export.utils.extensions
import android.util.Log
import java.util.Arrays
fun ByteArray.splitBy(chunkSize: Int): Array<ByteArray> {
val splitLen = Math.ceil(size / chunkSize.toDouble()).toInt()
return Array(splitLen) { position ->
val start = chunkSize * position
var end = start + chunkSize
// Avoid out of bounds
if (end >= size) {
end = size - 1
}
Arrays.copyOfRange(this, start, end)
}.also {
Log.d("OHAI", "${this.size} -> ${it.size} (${it.last().size})")
}
}
\ No newline at end of file
/*
* Copyright (c) 2019 Bevilacqua Joey
*
* Licensed under the GNU GPLv3 license
*
* The text of the license can be found in the LICENSE file
* or at https://www.gnu.org/licenses/gpl.txt
*/
package it.diab.export.writer
import android.os.Environment
import androidx.annotation.WorkerThread
import it.diab.data.entities.Glucose
import it.diab.data.entities.TimeFrame
import it.diab.data.repositories.GlucoseRepository
import it.diab.core.util.DateUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import java.io.File
import java.io.FileWriter
import java.io.IOException
object CsvWriter {
private const val FULL_HEADER = "value,eatLevel,insulin\n"
private const val DAYS_TO_EXPORT = DateUtils.DAY * 60
private const val OUT_DIR_NAME = "diab"
suspend fun exportTrain(scope: CoroutineScope, repository: GlucoseRepository): Boolean {
val baseName = "train_%1\$d.csv"
val end = System.currentTimeMillis()
val start = end - DAYS_TO_EXPORT
val list = repository.getInDateRange(start, end).filter { it.insulinValue0 > 0 }
val documentsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS)
val outDir = File(documentsDir, OUT_DIR_NAME).apply {
if (!exists()) {
mkdirs()
}
}
// Build the files asynchronously to speed things up
val morningDeferred = scope.async {
writeFile(
list.filter { it.timeFrame == TimeFrame.MORNING },
baseName.format(TimeFrame.MORNING.ordinal),
outDir
)
}
val lunchDeferred = scope.async {
writeFile(
list.filter { it.timeFrame == TimeFrame.LUNCH },
baseName.format(TimeFrame.LUNCH.ordinal),
outDir
)
}
val dinnerDeferred = scope.async {
writeFile(
list.filter { it.timeFrame == TimeFrame.DINNER },
baseName.format(TimeFrame.DINNER.ordinal),
outDir
)
}
return try {
morningDeferred.await()
lunchDeferred.await()
dinnerDeferred.await()
true
} catch (e: IOException) {
false
}
}
suspend fun exportTest(
scope: CoroutineScope,
repository: GlucoseRepository,
range: IntRange
): Boolean {
val baseName = "test_%1\$d.csv"
val end = System.currentTimeMillis()
val start = end - DAYS_TO_EXPORT
val list = repository.getInDateRange(start, end)
val documentsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS)
val outDir = File(documentsDir, OUT_DIR_NAME).apply {
if (!exists()) {
mkdirs()
}
}
val morningDeferred = scope.async {
writeFile(
extractGoodOutcomes(list, range, TimeFrame.MORNING, TimeFrame.LUNCH),
baseName.format(TimeFrame.MORNING.ordinal),
outDir
)
}
val lunchDeferred = scope.async {
writeFile(
extractGoodOutcomes(list, range, TimeFrame.LUNCH, TimeFrame.DINNER),
baseName.format(TimeFrame.LUNCH.ordinal),
outDir
)
}
val dinnerDeferred = scope.async {
writeFile(
extractGoodOutcomes(list, range, TimeFrame.DINNER, TimeFrame.NIGHT),
baseName.format(TimeFrame.DINNER.ordinal),
outDir
)
}
return try {
morningDeferred.await()
lunchDeferred.await()
dinnerDeferred.await()
true
} catch (e: IOException) {
false
}
}
@WorkerThread
private fun writeFile(list: List<Glucose>, name: String, parent: File) {
val file = File(parent, name)
val writer = FileWriter(file)
val builder = StringBuilder()
list.forEach {
builder.append("${it.value},${it.eatLevel},${it.insulinValue0}\n")
}
writer.use {
it.write(FULL_HEADER)
it.write(builder.toString())
}
}
@WorkerThread
private fun extractGoodOutcomes(
list: List<Glucose>,
optimalRange: IntRange,
targetTimeFrame: TimeFrame,
maxNextTimeFrame: TimeFrame
): List<Glucose> {
val result = mutableListOf<Glucose>()
var i = list.size - 1
while (i > 0) {
val item = list[i]
i--
if (item.timeFrame != targetTimeFrame) {
// Not a timeFrame we want
continue
}
// We already moved the index to the next element
val next = list[i]
if (next.timeFrame.toInt() > maxNextTimeFrame.toInt()) {
// This is too far from a desirable target to see whether the previous one was good
continue
}
if (next.value in optimalRange) {
result.add(item)
}
}
return result
}
}
\ No newline at end of file
/*
* Copyright (c) 2019 Bevilacqua Joey
*
* Licensed under the GNU GPLv3 license
*
* The text of the license can be found in the LICENSE file
* or at https://www.gnu.org/licenses/gpl.txt
*/
package it.diab.export.writer
import it.diab.core.util.DateUtils
import it.diab.data.entities.Glucose
import it.diab.data.entities.TimeFrame
import it.diab.data.repositories.GlucoseRepository
import it.diab.export.utils.extensions.splitBy
import java.io.FileDescriptor
import java.io.FileOutputStream
import java.io.IOException
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
class MlWriter(
private val outDescriptor: FileDescriptor,
private val repository: GlucoseRepository,
private val filterRange: IntRange
) {
fun export(): Boolean {
val end = System.currentTimeMillis()
val start = end - DAYS_TO_EXPORT
ZipOutputStream(FileOutputStream(outDescriptor)).use { oStream ->
return try {
exportTrain(oStream, TimeFrame.MORNING, start, end)
exportTrain(oStream, TimeFrame.LUNCH, start, end)
exportTrain(oStream, TimeFrame.DINNER, start, end)
exportTest(oStream, TimeFrame.MORNING, TimeFrame.LUNCH, start, end)
exportTest(oStream, TimeFrame.LUNCH, TimeFrame.DINNER, start, end)
exportTest(oStream, TimeFrame.DINNER, TimeFrame.NIGHT, start, end)
true
} catch (e: IOException) {
false
}
}
}
private fun exportTrain(
zipStream: ZipOutputStream,
timeFrame: TimeFrame,
start: Long,
end: Long
) {
zipStream.putNextEntry(ZipEntry(BASE_TRAIN.format(timeFrame.ordinal)))
val list = repository.getInDateRange(start, end)
.filter { it.insulinId0 > 0 && it.value in filterRange && it.timeFrame == timeFrame }
val content = StringBuilder().run {
append(COLUMNS_HEADER)
list.forEach {
append("${it.value},${it.eatLevel},${it.insulinValue0}\n")
}
toString()
}
content.toByteArray().splitBy(2048).forEach(zipStream::write)
// DON'T CLOSE THE STREAM HERE
}
private fun exportTest(
zipStream: ZipOutputStream,
targetTimeFrame: TimeFrame,
nextTimeFrame: TimeFrame,
start: Long,
end: Long
) {
zipStream.putNextEntry(ZipEntry(BASE_TEST.format(targetTimeFrame.ordinal)))
val list = repository.getInDateRange(start, end)
.filter { it.timeFrame == targetTimeFrame && it.insulinValue0 > 0 }
val content = StringBuilder().run {
append(COLUMNS_HEADER)
getGoodOutcomes(list, targetTimeFrame, nextTimeFrame).forEach {
append("${it.value},${it.eatLevel},${it.insulinValue0}\n")
}
toString()
}
content.toByteArray().splitBy(2048).forEach(zipStream::write)
// DON'T CLOSE THE STREAM HERE
}
private fun getGoodOutcomes(
list: List<Glucose>,
targetTimeFrame: TimeFrame,
nextTimeFrame: TimeFrame
): List<Glucose> {
val result = mutableListOf<Glucose>()
var i = list.size - 1
while (i > 0) {
val item = list[i]
i--
if (item.timeFrame != targetTimeFrame) {
// Not a timeFrame we want
continue
}
// We already moved the index to the next element
val next = list[i]
if (next.timeFrame.ordinal > nextTimeFrame.ordinal) {
// This is too far from a desirable target to see whether the outcome was good
continue
}
if (next.value in filterRange) {
result.add(item)
}
}
return result
}
companion object {
private const val COLUMNS_HEADER = "value,eatLevel,insulin\n"
private const val DAYS_TO_EXPORT = DateUtils.DAY * 60
private const val BASE_TRAIN = "train_%1\$s.csv"
private const val BASE_TEST = "test_%1\$s.csv"
}
}
\ No newline at end of file
......@@ -9,13 +9,13 @@
package it.diab.export.writer
import android.os.Build
import android.os.Environment
import android.os.ParcelFileDescriptor
import android.util.Log
import androidx.annotation.WorkerThread
import it.diab.data.repositories.GlucoseRepository
import it.diab.data.repositories.InsulinRepository
import it.diab.core.util.extensions.forEachWithIndex
import it.diab.core.util.extensions.format
import it.diab.data.repositories.GlucoseRepository
import it.diab.data.repositories.InsulinRepository
import it.diab.export.BuildConfig
import it.diab.export.utils.extensions.setAlternateBackground
import it.diab.export.utils.extensions.write
......@@ -23,54 +23,44 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import org.dhatim.fastexcel.Workbook
import org.dhatim.fastexcel.Worksheet
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
object XlsxWriter {
private const val TAG = "ExportService"
private const val DATE_FORMAT = "yyyy-MM-dd HH:mm"
class XlsxWriter(
private val scope: CoroutineScope,
private val outDescriptor: ParcelFileDescriptor,
private val glucoseRepository: GlucoseRepository,
private val insulinRepository: InsulinRepository
) {
suspend fun exportSheet(
scope: CoroutineScope,
glucoseRepository: GlucoseRepository,
insulinRepository: InsulinRepository,
glucoseHeaders: List<String>,
insulinHeaders: List<String>
): File? {
val documentsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS)
val outDir = File(documentsDir, "diab").apply {
if (!exists()) {
mkdirs()
}
}
val date = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date())
val file = File(outDir, "diab-$date.xlsx")
): Boolean {
FileOutputStream(file).use {
val workBook = Workbook(it, BuildConfig.APPLICATION_ID, null)
val glucoseSheet = workBook.newWorksheet("Glucose")
val insulinSheet = workBook.newWorksheet("Insulin")
outDescriptor.use { parcelDescriptor ->