WIP; but now I actually am starting to understand what's going on

This commit is contained in:
Philipp Heckel 2022-01-05 21:40:40 +01:00
parent a123edbd8e
commit 63fff52fcf
7 changed files with 197 additions and 62 deletions

View file

@ -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

View file

@ -13,6 +13,7 @@
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<application
android:name=".app.Application"

View file

@ -42,9 +42,7 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
.map { Pair(it.id, it.instant) }.toSet()
}
@Suppress("RedundantSuspendModifier")
@WorkerThread
suspend fun getSubscription(subscriptionId: Long): Subscription? {
fun getSubscription(subscriptionId: Long): Subscription? {
return toSubscription(subscriptionDao.get(subscriptionId))
}

View file

@ -0,0 +1,128 @@
package io.heckel.ntfy.msg
import android.content.ContentValues
import android.content.Context
import android.os.Environment
import android.provider.MediaStore
import android.util.Log
import androidx.work.Worker
import androidx.work.WorkerParameters
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.Subscription
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
class AttachmentDownloadWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) {
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()
private val notifier = NotificationService(context)
override fun doWork(): Result {
if (context.applicationContext !is Application) return Result.failure()
val notificationId = inputData.getString("id") ?: return Result.failure()
val app = context.applicationContext as Application
val notification = app.repository.getNotification(notificationId) ?: return Result.failure()
val subscription = app.repository.getSubscription(notification.subscriptionId) ?: return Result.failure()
if (notification.attachmentPreviewUrl != null) {
downloadPreview(subscription, notification)
}
downloadAttachment(subscription, notification)
return Result.success()
}
private fun downloadPreview(subscription: Subscription, notification: Notification) {
val url = notification.attachmentPreviewUrl ?: return
Log.d(TAG, "Downloading preview 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("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"
}
}

View file

@ -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)

View file

@ -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<String>,
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()

View file

@ -33,4 +33,5 @@ ext {
coreKtxVersion = '1.3.2'
constraintLayoutVersion = '2.0.4'
activityVersion = '1.1.0'
fragmentVersion = '1.1.0'
}