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.core:core-ktx:$rootProject.coreKtxVersion"
implementation "androidx.constraintlayout:constraintlayout:$rootProject.constraintLayoutVersion" implementation "androidx.constraintlayout:constraintlayout:$rootProject.constraintLayoutVersion"
implementation "androidx.activity:activity-ktx:$rootProject.activityVersion" implementation "androidx.activity:activity-ktx:$rootProject.activityVersion"
implementation "androidx.fragment:fragment-ktx:$rootProject.fragmentVersion"
implementation 'com.google.code.gson:gson:2.8.8' implementation 'com.google.code.gson:gson:2.8.8'
// WorkManager // WorkManager

View file

@ -13,6 +13,7 @@
<uses-permission android:name="android.permission.WAKE_LOCK"/> <uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.VIBRATE"/> <uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<application <application
android:name=".app.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() .map { Pair(it.id, it.instant) }.toSet()
} }
@Suppress("RedundantSuspendModifier") fun getSubscription(subscriptionId: Long): Subscription? {
@WorkerThread
suspend fun getSubscription(subscriptionId: Long): Subscription? {
return toSubscription(subscriptionDao.get(subscriptionId)) 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.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.media.RingtoneManager import android.media.RingtoneManager
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat 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.R
import io.heckel.ntfy.data.Notification import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.Subscription 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.ui.MainActivity
import io.heckel.ntfy.util.formatMessage import io.heckel.ntfy.util.formatMessage
import io.heckel.ntfy.util.formatTitle 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) { 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) { fun display(subscription: Subscription, notification: Notification) {
Log.d(TAG, "Displaying notification $notification") Log.d(TAG, "Displaying notification $notification")
// Display notification immediately
displayInternal(subscription, notification) 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) { fun cancel(notification: Notification) {
if (notification.notificationId != 0) { if (notification.notificationId != 0) {
Log.d(TAG, "Cancelling notification ${notification.id}: ${notification.message}") Log.d(TAG, "Cancelling notification ${notification.id}: ${notification.message}")
@ -73,8 +73,9 @@ class NotificationService(val context: Context) {
if (notification.attachmentUrl != null) { if (notification.attachmentUrl != null) {
val viewIntent = PendingIntent.getActivity(context, 0, Intent(Intent.ACTION_VIEW, Uri.parse(notification.attachmentUrl)), 0) 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 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, "Copy URL", viewIntent).build())
.addAction(NotificationCompat.Action.Builder(0, "Download", 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()) notificationManager.notify(notification.notificationId, notificationBuilder.build())
} }
private fun downloadPreviewAndUpdate(subscription: Subscription, notification: Notification) { private fun scheduleAttachmentDownload(subscription: Subscription, notification: Notification) {
val previewUrl = notification.attachmentPreviewUrl ?: return Log.d(TAG, "Enqueuing work to download attachment (+ preview if available)")
Log.d(TAG, "Downloading preview image $previewUrl") val workManager = WorkManager.getInstance(context)
val workRequest = OneTimeWorkRequest.Builder(AttachmentDownloadWorker::class.java)
val request = Request.Builder() .setInputData(workDataOf(
.url(previewUrl) "id" to notification.id,
.addHeader("User-Agent", ApiService.USER_AGENT) ))
.build() .build()
client.newCall(request).execute().use { response -> workManager.enqueue(workRequest)
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)
}
}
} }
private fun setContentIntent(builder: NotificationCompat.Builder, subscription: Subscription, notification: Notification): NotificationCompat.Builder? { 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? { private fun detailActivityIntent(subscription: Subscription): PendingIntent? {
val intent = Intent(context, DetailActivity::class.java) val intent = Intent(context, DetailActivity::class.java)
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, subscription.id) intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, subscription.id)

View file

@ -1,9 +1,11 @@
package io.heckel.ntfy.ui package io.heckel.ntfy.ui
import android.Manifest
import android.animation.Animator import android.animation.Animator
import android.animation.AnimatorListenerAdapter import android.animation.AnimatorListenerAdapter
import android.app.AlertDialog import android.app.AlertDialog
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
@ -11,6 +13,7 @@ import android.view.*
import android.widget.Toast import android.widget.Toast
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@ -114,8 +117,44 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
// Background things // Background things
startPeriodicPollWorker() startPeriodicPollWorker()
startPeriodicServiceRestartWorker() 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() { override fun onResume() {
super.onResume() super.onResume()
showHideNotificationMenuItems() showHideNotificationMenuItems()

View file

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