Arbitrary attachments

This commit is contained in:
Philipp Heckel 2022-02-11 20:34:08 -05:00
parent 8100e68b8d
commit 9afdf5e6e7
9 changed files with 134 additions and 53 deletions

View file

@ -29,7 +29,7 @@ class ApiService {
.build()
private val parser = NotificationParser()
fun publish(baseUrl: String, topic: String, user: User?, message: String, title: String, priority: Int, tags: List<String>, delay: String, body: RequestBody? = null) {
fun publish(baseUrl: String, topic: String, user: User?, message: String, title: String, priority: Int, tags: List<String>, delay: String, body: RequestBody? = null, filename: String = "") {
val url = topicUrl(baseUrl, topic)
Log.d(TAG, "Publishing to $url")
@ -46,6 +46,9 @@ class ApiService {
if (delay.isNotEmpty()) {
builder.addHeader("X-Delay", delay)
}
if (filename.isNotEmpty()) {
builder.addHeader("X-Filename", filename)
}
if (body != null) {
builder
.addHeader("X-Message", message)

View file

@ -18,7 +18,7 @@ import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.db.*
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.queryFilename
import io.heckel.ntfy.util.fileName
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
@ -132,7 +132,7 @@ class DownloadWorker(private val context: Context, params: WorkerParameters) : W
}
}
Log.d(TAG, "Attachment download: successful response, proceeding with download")
val actualName = queryFilename(context, uri.toString(), attachment.name)
val actualName = fileName(context, uri.toString(), attachment.name)
save(attachment.copy(
name = actualName,
size = bytesCopied,

View file

@ -110,7 +110,6 @@ class AddFragment : DialogFragment() {
endIconImageView.minimumWidth = dimension.toInt()
subscribeBaseUrlLayout.requestLayout()
// Fields for "login page"
loginUsernameText = view.findViewById(R.id.add_dialog_login_username)
loginPasswordText = view.findViewById(R.id.add_dialog_login_password)

View file

@ -68,9 +68,9 @@ class DetailAdapter(private val activity: Activity, private val repository: Repo
private val tagsView: TextView = itemView.findViewById(R.id.detail_item_tags_text)
private val menuButton: ImageButton = itemView.findViewById(R.id.detail_item_menu_button)
private val attachmentImageView: ImageView = itemView.findViewById(R.id.detail_item_attachment_image)
private val attachmentBoxView: View = itemView.findViewById(R.id.detail_item_attachment_box)
private val attachmentIconView: ImageView = itemView.findViewById(R.id.detail_item_attachment_icon)
private val attachmentInfoView: TextView = itemView.findViewById(R.id.detail_item_attachment_info)
private val attachmentBoxView: View = itemView.findViewById(R.id.share_content_file_box)
private val attachmentIconView: ImageView = itemView.findViewById(R.id.share_content_file_icon)
private val attachmentInfoView: TextView = itemView.findViewById(R.id.share_content_file_info)
fun bind(notification: Notification) {
this.notification = notification
@ -157,17 +157,7 @@ class DetailAdapter(private val activity: Activity, private val repository: Repo
return
}
attachmentInfoView.text = formatAttachmentDetails(context, attachment, exists)
attachmentIconView.setImageResource(if (attachment.type?.startsWith("image/") == true) {
R.drawable.ic_file_image_red_24dp
} else if (attachment.type?.startsWith("video/") == true) {
R.drawable.ic_file_video_orange_24dp
} else if (attachment.type?.startsWith("audio/") == true) {
R.drawable.ic_file_audio_purple_24dp
} else if ("application/vnd.android.package-archive" == attachment.type) {
R.drawable.ic_file_app_gray_24dp
} else {
R.drawable.ic_file_document_blue_24dp
})
attachmentIconView.setImageResource(mimeTypeToIconResource(attachment.type))
val attachmentBoxPopupMenu = createAttachmentPopup(context, attachmentBoxView, notification, attachment, exists) // Heavy lifting not during on-click
if (attachmentBoxPopupMenu != null) {
attachmentBoxView.setOnClickListener { attachmentBoxPopupMenu.show() }
@ -275,7 +265,7 @@ class DetailAdapter(private val activity: Activity, private val repository: Repo
}
private fun formatAttachmentDetails(context: Context, attachment: Attachment, exists: Boolean): String {
val name = queryFilename(context, attachment.contentUri, attachment.name)
val name = fileName(context, attachment.contentUri, attachment.name)
val notYetDownloaded = !exists && attachment.progress == PROGRESS_NONE
val downloading = !exists && attachment.progress in 0..99
val deleted = !exists && (attachment.progress == PROGRESS_DONE || attachment.progress == PROGRESS_DELETED)

View file

@ -13,14 +13,13 @@ import android.view.View
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.util.ContentUriRequestBody
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.supportedImage
import io.heckel.ntfy.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -35,6 +34,9 @@ class ShareActivity : AppCompatActivity() {
private lateinit var menu: Menu
private lateinit var sendItem: MenuItem
private lateinit var contentImage: ImageView
private lateinit var contentFileBox: View
private lateinit var contentFileInfo: TextView
private lateinit var contentFileIcon: ImageView
private lateinit var contentText: TextView
private lateinit var topicText: TextView
private lateinit var progress: ProgressBar
@ -57,6 +59,9 @@ class ShareActivity : AppCompatActivity() {
// UI elements
contentText = findViewById(R.id.share_content_text)
contentImage = findViewById(R.id.share_content_image)
contentFileBox = findViewById(R.id.share_content_file_box)
contentFileInfo = findViewById(R.id.share_content_file_info)
contentFileIcon = findViewById(R.id.share_content_file_icon)
topicText = findViewById(R.id.share_topic_text)
progress = findViewById(R.id.share_progress)
progress.visibility = View.GONE
@ -93,8 +98,8 @@ class ShareActivity : AppCompatActivity() {
private fun handleSendText(intent: Intent) {
intent.getStringExtra(Intent.EXTRA_TEXT)?.let { text ->
contentImage.visibility = View.GONE
contentText.text = text
show()
}
}
@ -105,18 +110,33 @@ class ShareActivity : AppCompatActivity() {
val bitmapStream = resolver.openInputStream(fileUri!!)
val bitmap = BitmapFactory.decodeStream(bitmapStream)
contentImage.setImageBitmap(bitmap)
contentImage.visibility = View.VISIBLE
contentText.text = getString(R.string.share_content_image_text)
} catch (_: Exception) {
show(image = true)
} catch (e: Exception) {
fileUri = null
contentImage.visibility = View.GONE
contentText.text = getString(R.string.share_content_image_error)
contentText.text = ""
errorText.text = getString(R.string.share_content_image_error, e.message)
show(error = true)
}
}
private fun handleSendFile(intent: Intent) {
fileUri = intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri ?: return
contentText.text = getString(R.string.share_content_file_text)
try {
val resolver = applicationContext.contentResolver
val info = fileStat(this, fileUri)
val mimeType = resolver.getType(fileUri!!)
contentText.text = getString(R.string.share_content_file_text)
contentFileInfo.text = "${info.filename}\n${formatBytes(info.size)}"
contentFileIcon.setImageResource(mimeTypeToIconResource(mimeType))
show(file = true)
} catch (e: Exception) {
fileUri = null
contentText.text = ""
errorText.text = getString(R.string.share_content_file_error, e.message)
show(error = true)
}
}
override fun onSupportNavigateUp(): Boolean {
@ -142,6 +162,13 @@ class ShareActivity : AppCompatActivity() {
}
}
private fun show(image: Boolean = false, file: Boolean = false, error: Boolean = false) {
contentImage.visibility = if (image) View.VISIBLE else View.GONE
contentFileBox.visibility = if (file) View.VISIBLE else View.GONE
errorImage.visibility = if (error) View.VISIBLE else View.GONE
errorText.visibility = if (error) View.VISIBLE else View.GONE
}
private fun onShareClick() {
val baseUrl = "https://ntfy.sh" // FIXME
val topic = topicText.text.toString()
@ -150,6 +177,11 @@ class ShareActivity : AppCompatActivity() {
lifecycleScope.launch(Dispatchers.IO) {
val user = repository.getUser(baseUrl)
try {
val filename = if (fileUri != null) {
fileStat(this@ShareActivity, fileUri).filename
} else {
""
}
val body = if (fileUri != null) {
val resolver = applicationContext.contentResolver
ContentUriRequestBody(resolver, fileUri!!)
@ -165,10 +197,14 @@ class ShareActivity : AppCompatActivity() {
priority = 3,
tags = emptyList(),
delay = "",
body = body // May be null
body = body, // May be null
filename = filename // May be empty
)
runOnUiThread {
finish()
Toast
.makeText(this@ShareActivity, getString(R.string.share_successful), Toast.LENGTH_LONG)
.show()
}
} catch (e: Exception) {
runOnUiThread {

View file

@ -12,6 +12,7 @@ import android.os.PowerManager
import android.provider.OpenableColumns
import android.view.Window
import androidx.appcompat.app.AppCompatDelegate
import io.heckel.ntfy.R
import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.Subscription
@ -129,7 +130,7 @@ fun formatTitle(notification: Notification): String {
// Checks in the most horrible way if a content URI exists; I couldn't find a better way
fun fileExists(context: Context, contentUri: String?): Boolean {
return try {
queryFilenameInternal(context, contentUri) // Throws if the file does not exist
fileStat(context, Uri.parse(contentUri)) // Throws if the file does not exist
true
} catch (_: Exception) {
false
@ -137,25 +138,35 @@ fun fileExists(context: Context, contentUri: String?): Boolean {
}
// Queries the filename of a content URI
fun queryFilename(context: Context, contentUri: String?, fallbackName: String): String {
fun fileName(context: Context, contentUri: String?, fallbackName: String): String {
return try {
queryFilenameInternal(context, contentUri)
val info = fileStat(context, Uri.parse(contentUri))
info.filename
} catch (_: Exception) {
fallbackName
}
}
fun queryFilenameInternal(context: Context, contentUri: String?): String {
fun fileStat(context: Context, contentUri: Uri?): FileInfo {
if (contentUri == null) throw Exception("URI is null")
val resolver = context.applicationContext.contentResolver
val cursor = resolver.query(Uri.parse(contentUri), null, null, null, null) ?: throw Exception("Query returned null")
val cursor = resolver.query(contentUri, null, null, null, null) ?: throw Exception("Query returned null")
return cursor.use { c ->
val nameIndex = c.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)
val sizeIndex = c.getColumnIndexOrThrow(OpenableColumns.SIZE)
c.moveToFirst()
c.getString(nameIndex)
FileInfo(
filename = c.getString(nameIndex),
size = c.getLong(sizeIndex)
)
}
}
data class FileInfo(
val filename: String,
val size: Long,
)
// Status bar color fading to match action bar, see https://stackoverflow.com/q/51150077/1440785
fun fadeStatusBarColor(window: Window, fromColor: Int, toColor: Int) {
val statusBarColorAnimation = ValueAnimator.ofObject(ArgbEvaluator(), fromColor, toColor)
@ -195,6 +206,20 @@ fun formatBytes(bytes: Long, decimals: Int = 1): String {
return java.lang.String.format("%.${decimals}f %cB", value / 1024.0, ci.current())
}
fun mimeTypeToIconResource(mimeType: String?): Int {
return if (mimeType?.startsWith("image/") == true) {
R.drawable.ic_file_image_red_24dp
} else if (mimeType?.startsWith("video/") == true) {
R.drawable.ic_file_video_orange_24dp
} else if (mimeType?.startsWith("audio/") == true) {
R.drawable.ic_file_audio_purple_24dp
} else if (mimeType == "application/vnd.android.package-archive") {
R.drawable.ic_file_app_gray_24dp
} else {
R.drawable.ic_file_document_blue_24dp
}
}
fun supportedImage(mimeType: String?): Boolean {
return listOf("image/jpeg", "image/png").contains(mimeType)
}
@ -231,12 +256,10 @@ class ContentUriRequestBody(
private val contentResolver: ContentResolver,
private val contentUri: Uri
) : RequestBody() {
override fun contentType(): MediaType? {
val contentType = contentResolver.getType(contentUri)
return contentType?.toMediaTypeOrNull()
}
override fun writeTo(sink: BufferedSink) {
val inputStream = contentResolver.openInputStream(contentUri) ?: throw IOException("Couldn't open content URI for reading")
inputStream.source().use { source ->

View file

@ -4,7 +4,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal" android:padding="10dp">
android:orientation="horizontal" android:paddingStart="15dp" android:paddingEnd="15dp" android:paddingTop="10dp" android:paddingBottom="10dp">
<ProgressBar
style="?android:attr/progressBarStyle"
@ -21,7 +21,7 @@
android:paddingBottom="2dp"
android:text="@string/share_content_title"
android:textAlignment="viewStart"
android:textAppearance="@style/TextAppearance.AppCompat.Medium" android:paddingStart="4dp"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
<com.google.android.material.imageview.ShapeableImageView
@ -31,7 +31,7 @@
android:scaleType="fitStart"
android:adjustViewBounds="true" android:maxHeight="150dp"
app:shapeAppearanceOverlay="@style/roundedCornersImageView" android:visibility="visible"
app:layout_constraintTop_toBottomOf="@id/share_content_title" android:layout_marginStart="3dp"/>
app:layout_constraintTop_toBottomOf="@id/share_content_title" android:layout_marginTop="5dp"/>
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/share_content_text"
android:layout_width="match_parent"
@ -39,6 +39,34 @@
android:importantForAutofill="no"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent"
android:lines="10" android:gravity="start|top" app:layout_constraintTop_toBottomOf="@id/share_content_image" android:minLines="1" android:layout_marginTop="5dp"/>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content" app:layout_constraintTop_toBottomOf="@id/share_content_text"
android:id="@+id/share_content_file_box" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:visibility="visible" android:layout_marginTop="5dp"
android:background="?android:attr/selectableItemBackground"
android:clickable="true" android:focusable="true" android:padding="4dp" android:paddingStart="0dp">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content" app:srcCompat="@drawable/ic_cancel_gray_24dp"
android:id="@+id/share_content_file_icon" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toStartOf="@+id/share_content_file_info" android:layout_marginEnd="5dp"
app:layout_constraintBottom_toBottomOf="parent"
/>
<TextView
android:text="some file.mp3\n7.1 MB"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:id="@+id/share_content_file_info"
android:textColor="?android:attr/textColorPrimary"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
app:layout_constraintStart_toEndOf="@+id/share_content_file_icon"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/share_content_file_icon"
app:layout_constraintBottom_toBottomOf="@+id/share_content_file_icon"/>
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/share_topic_title"
android:layout_width="0dp"
@ -47,9 +75,9 @@
android:paddingBottom="3dp"
android:text="Share to topic"
android:textAlignment="viewStart"
android:textAppearance="@style/TextAppearance.AppCompat.Medium" android:paddingStart="4dp"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/share_content_text" android:layout_marginTop="10dp"/>
app:layout_constraintTop_toBottomOf="@id/share_content_file_box" android:layout_marginTop="10dp"/>
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/share_topic_text"
android:layout_width="match_parent"

View file

@ -87,13 +87,13 @@
app:layout_constraintStart_toStartOf="parent" android:layout_marginStart="10dp"
app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="10dp"
app:layout_constraintTop_toBottomOf="@id/detail_item_attachment_image"
app:layout_constraintBottom_toTopOf="@id/detail_item_attachment_box"
app:layout_constraintBottom_toTopOf="@id/share_content_file_box"
app:layout_constraintHorizontal_bias="0.0" android:layout_marginTop="2dp"
/>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content" app:layout_constraintTop_toBottomOf="@id/detail_item_tags_text"
android:id="@+id/detail_item_attachment_box" app:layout_constraintStart_toStartOf="parent"
android:id="@+id/share_content_file_box" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" android:layout_marginStart="10dp" android:layout_marginEnd="10dp"
app:layout_constraintBottom_toTopOf="@id/detail_item_padding_bottom"
android:visibility="visible" android:layout_marginTop="2dp"
@ -102,27 +102,27 @@
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content" app:srcCompat="@drawable/ic_cancel_gray_24dp"
android:id="@+id/detail_item_attachment_icon" app:layout_constraintStart_toStartOf="parent"
android:id="@+id/share_content_file_icon" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toStartOf="@+id/detail_item_attachment_info" android:layout_marginEnd="5dp"
app:layout_constraintEnd_toStartOf="@+id/share_content_file_info" android:layout_marginEnd="5dp"
app:layout_constraintBottom_toBottomOf="parent"
/>
<TextView
android:text="attachment.jpg\n58 MB, not downloaded, expires 1/2/2022 10:30 PM"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:id="@+id/detail_item_attachment_info"
android:id="@+id/share_content_file_info"
android:textColor="?android:attr/textColorPrimary"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
app:layout_constraintStart_toEndOf="@+id/detail_item_attachment_icon"
app:layout_constraintStart_toEndOf="@+id/share_content_file_icon"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/detail_item_attachment_icon"
app:layout_constraintBottom_toBottomOf="@+id/detail_item_attachment_icon"/>
app:layout_constraintTop_toTopOf="@+id/share_content_file_icon"
app:layout_constraintBottom_toBottomOf="@+id/share_content_file_icon"/>
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="5dp" android:id="@+id/detail_item_padding_bottom"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/detail_item_attachment_box"/>
app:layout_constraintTop_toBottomOf="@id/share_content_file_box"/>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -191,8 +191,10 @@
<string name="share_content_title">Message preview</string>
<string name="share_content_text_hint">Add the content you\'d like to share here</string>
<string name="share_content_image_text">An image was shared with you</string>
<string name="share_content_image_error">Cannot read image</string>
<string name="share_content_file_text">An file was shared with you</string>
<string name="share_content_image_error">Cannot read image: %1$s</string>
<string name="share_content_file_text">A file was shared with you</string>
<string name="share_content_file_error">Cannot read file infos: %1$s</string>
<string name="share_successful">Message successfully published</string>
<!-- Notification dialog -->
<string name="notification_dialog_title">Pause notifications</string>