From 63fff52fcf5967ee61fd576135ca5e6a1359a54f Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Wed, 5 Jan 2022 21:40:40 +0100 Subject: [PATCH] WIP; but now I actually am starting to understand what's going on --- app/build.gradle | 1 + app/src/main/AndroidManifest.xml | 1 + .../java/io/heckel/ntfy/data/Repository.kt | 4 +- .../ntfy/msg/AttachmentDownloaderWorker.kt | 128 ++++++++++++++++++ .../io/heckel/ntfy/msg/NotificationService.kt | 83 ++++-------- .../java/io/heckel/ntfy/ui/MainActivity.kt | 41 +++++- build.gradle | 1 + 7 files changed, 197 insertions(+), 62 deletions(-) create mode 100644 app/src/main/java/io/heckel/ntfy/msg/AttachmentDownloaderWorker.kt diff --git a/app/build.gradle b/app/build.gradle index 63498e4..2b1b457 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -63,6 +63,7 @@ dependencies { implementation "androidx.core:core-ktx:$rootProject.coreKtxVersion" implementation "androidx.constraintlayout:constraintlayout:$rootProject.constraintLayoutVersion" implementation "androidx.activity:activity-ktx:$rootProject.activityVersion" + implementation "androidx.fragment:fragment-ktx:$rootProject.fragmentVersion" implementation 'com.google.code.gson:gson:2.8.8' // WorkManager diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bffc978..bd12a74 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,6 +13,7 @@ + + if (!response.isSuccessful || response.body == null) { + throw Exception("Preview download failed: ${response.code}") + } + Log.d(TAG, "Preview download: successful response, proceeding with download") + /*val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) + Log.d(TAG, "dir: $dir") + if (dir == null /*|| !dir.mkdirs()*/) { + throw Exception("Cannot access target storage dir") + }*/ + val contentResolver = applicationContext.contentResolver + val contentValues = ContentValues() + contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, "flower.jpg") + contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg") + contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + val uri = contentResolver.insert(MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), contentValues) + ?: throw Exception("Cannot get content URI") + val out = contentResolver.openOutputStream(uri) ?: throw Exception("Cannot open output stream") + out.use { fileOut -> + response.body!!.byteStream().copyTo(fileOut) + } + + /* + + val file = File(context.cacheDir, "somefile") + context.openFileOutput(file.absolutePath, Context.MODE_PRIVATE).use { fileOut -> + response.body!!.byteStream().copyTo(fileOut) + } + + val file = File(dir, "myfile.txt") + Log.d(TAG, "dir: $dir, file: $file") + FileOutputStream(file).use { fileOut -> + response.body!!.byteStream().copyTo(fileOut) + }*/ + /* + context.openFileOutput(file.absolutePath, Context.MODE_PRIVATE).use { fileOut -> + response.body!!.byteStream().copyTo(fileOut) + }*/ + //val bitmap = BitmapFactory.decodeFile(file.absolutePath) + Log.d(TAG, "now we would display the preview image") + //displayInternal(subscription, notification, bitmap) + } + } + + private fun downloadAttachment(subscription: Subscription, notification: Notification) { + val url = notification.attachmentUrl ?: return + Log.d(TAG, "Downloading attachment from $url") + + val request = Request.Builder() + .url(url) + .addHeader("User-Agent", ApiService.USER_AGENT) + .build() + client.newCall(request).execute().use { response -> + if (!response.isSuccessful || response.body == null) { + throw Exception("Attachment download failed: ${response.code}") + } + val name = notification.attachmentName ?: "attachment.bin" + val mimeType = notification.attachmentType ?: "application/octet-stream" + val resolver = applicationContext.contentResolver + val details = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, name) + put(MediaStore.MediaColumns.MIME_TYPE, mimeType) + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + put(MediaStore.MediaColumns.IS_DOWNLOAD, 1) + } + val uri = resolver.insert(MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), details) + ?: throw Exception("Cannot get content URI") + Log.d(TAG, "Starting download to content URI: $uri") + val out = resolver.openOutputStream(uri) ?: throw Exception("Cannot open output stream") + out.use { fileOut -> + response.body!!.byteStream().copyTo(fileOut) + } + Log.d(TAG, "Attachment download: successful response, proceeding with download") + notifier.update(subscription, notification) + } + } + + companion object { + private const val TAG = "NtfyAttachDownload" + } +} diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt index e8f525c..e9e2314 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt @@ -7,14 +7,15 @@ import android.app.TaskStackBuilder import android.content.Context import android.content.Intent import android.graphics.Bitmap -import android.graphics.BitmapFactory import android.media.RingtoneManager import android.net.Uri import android.os.Build -import android.os.Environment import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import androidx.work.workDataOf import io.heckel.ntfy.R import io.heckel.ntfy.data.Notification import io.heckel.ntfy.data.Subscription @@ -22,28 +23,27 @@ import io.heckel.ntfy.ui.DetailActivity import io.heckel.ntfy.ui.MainActivity import io.heckel.ntfy.util.formatMessage import io.heckel.ntfy.util.formatTitle -import okhttp3.OkHttpClient -import okhttp3.Request -import java.io.File -import java.util.concurrent.TimeUnit class NotificationService(val context: Context) { - private val client = OkHttpClient.Builder() - .callTimeout(15, TimeUnit.SECONDS) // Total timeout for entire request - .connectTimeout(15, TimeUnit.SECONDS) - .readTimeout(15, TimeUnit.SECONDS) - .writeTimeout(15, TimeUnit.SECONDS) - .build() - fun display(subscription: Subscription, notification: Notification) { Log.d(TAG, "Displaying notification $notification") + // Display notification immediately displayInternal(subscription, notification) - if (notification.attachmentPreviewUrl != null) { - downloadPreviewAndUpdate(subscription, notification) + + // Download attachment (+ preview if available) in the background via WorkManager + // The indirection via WorkManager is required since this code may be executed + // in a doze state and Internet may not be available. It's also best practice apparently. + if (notification.attachmentUrl != null) { + scheduleAttachmentDownload(subscription, notification) } } + fun update(subscription: Subscription, notification: Notification) { + Log.d(TAG, "Updating notification $notification") + displayInternal(subscription, notification) + } + fun cancel(notification: Notification) { if (notification.notificationId != 0) { Log.d(TAG, "Cancelling notification ${notification.id}: ${notification.message}") @@ -73,8 +73,9 @@ class NotificationService(val context: Context) { if (notification.attachmentUrl != null) { val viewIntent = PendingIntent.getActivity(context, 0, Intent(Intent.ACTION_VIEW, Uri.parse(notification.attachmentUrl)), 0) + val openIntent = PendingIntent.getActivity(context, 0, Intent(Intent.ACTION_VIEW, Uri.parse("content://media/external/file/39")), 0) notificationBuilder - .addAction(NotificationCompat.Action.Builder(0, "Open", viewIntent).build()) + .addAction(NotificationCompat.Action.Builder(0, "Open", openIntent).build()) .addAction(NotificationCompat.Action.Builder(0, "Copy URL", viewIntent).build()) .addAction(NotificationCompat.Action.Builder(0, "Download", viewIntent).build()) } @@ -93,23 +94,15 @@ class NotificationService(val context: Context) { notificationManager.notify(notification.notificationId, notificationBuilder.build()) } - private fun downloadPreviewAndUpdate(subscription: Subscription, notification: Notification) { - val previewUrl = notification.attachmentPreviewUrl ?: return - Log.d(TAG, "Downloading preview image $previewUrl") - - val request = Request.Builder() - .url(previewUrl) - .addHeader("User-Agent", ApiService.USER_AGENT) + private fun scheduleAttachmentDownload(subscription: Subscription, notification: Notification) { + Log.d(TAG, "Enqueuing work to download attachment (+ preview if available)") + val workManager = WorkManager.getInstance(context) + val workRequest = OneTimeWorkRequest.Builder(AttachmentDownloadWorker::class.java) + .setInputData(workDataOf( + "id" to notification.id, + )) .build() - client.newCall(request).execute().use { response -> - if (!response.isSuccessful || response.body == null) { - Log.d(TAG, "Preview response failed: ${response.code}") - } else { - Log.d(TAG, "Successful response, streaming preview") - val bitmap = BitmapFactory.decodeStream(response.body!!.byteStream()) - displayInternal(subscription, notification, bitmap) - } - } + workManager.enqueue(workRequest) } private fun setContentIntent(builder: NotificationCompat.Builder, subscription: Subscription, notification: Notification): NotificationCompat.Builder? { @@ -125,32 +118,6 @@ class NotificationService(val context: Context) { } } - private fun downloadPreviewAndUpdateXXX(subscription: Subscription, notification: Notification) { - val url = notification.attachmentUrl ?: return - Log.d(TAG, "Downloading attachment from $url") - - val request = Request.Builder() - .url(url) - .addHeader("User-Agent", ApiService.USER_AGENT) - .build() - client.newCall(request).execute().use { response -> - if (!response.isSuccessful || response.body == null) { - Log.d(TAG, "Attachment download failed: ${response.code}") - } else { - Log.d(TAG, "Successful response") - /*val filename = notification.id - val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS + "/ntfy/" + notification.id) - context.openFileOutput(filename, Context.MODE_PRIVATE).use { - response.body!!.byteStream() - }*/ - // TODO work manager - - val bitmap = BitmapFactory.decodeStream(response.body!!.byteStream()) - displayInternal(subscription, notification, bitmap) - } - } - } - private fun detailActivityIntent(subscription: Subscription): PendingIntent? { val intent = Intent(context, DetailActivity::class.java) intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, subscription.id) diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt index 28c381c..5979d39 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -1,9 +1,11 @@ package io.heckel.ntfy.ui +import android.Manifest import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.app.AlertDialog import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri import android.os.Bundle import android.util.Log @@ -11,6 +13,7 @@ import android.view.* import android.widget.Toast import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView @@ -114,8 +117,44 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc // Background things startPeriodicPollWorker() startPeriodicServiceRestartWorker() - } + if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_DENIED) { + ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), 1234); + } + else { + Toast.makeText(this, "Permission already granted", Toast.LENGTH_SHORT).show(); + } + } + override fun onRequestPermissionsResult(requestCode: Int, + permissions: Array, + grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == 1234) { // FIXME + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + Toast.makeText(this@MainActivity, "Camera Permission Granted", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(this@MainActivity, "Camera Permission Denied", Toast.LENGTH_SHORT).show() + } + } + } +/* + public static final int REQUEST_WRITE_STORAGE = 112; + + fun requestPermission(Activity context) { + boolean hasPermission = (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED); + if (!hasPermission) { + ActivityCompat.requestPermissions(context, + new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, + REQUEST_WRITE_STORAGE); + } else { + // You are allowed to write external storage: + String path = Environment.getExternalStorageDirectory().getAbsolutePath() + "/new_folder"; + File storageDir = new File(path); + if (!storageDir.exists() && !storageDir.mkdirs()) { + // This should never happen - log handled exception! + } + } +*/ override fun onResume() { super.onResume() showHideNotificationMenuItems() diff --git a/build.gradle b/build.gradle index 5e9e565..3df7b17 100644 --- a/build.gradle +++ b/build.gradle @@ -33,4 +33,5 @@ ext { coreKtxVersion = '1.3.2' constraintLayoutVersion = '2.0.4' activityVersion = '1.1.0' + fragmentVersion = '1.1.0' }