Skip to content

Commit

Permalink
Merge pull request #112 from NatKarmios/public-receiver
Browse files Browse the repository at this point in the history
Add a public receiver for Tasker, Automate, etc.
  • Loading branch information
amberin committed Dec 2, 2023
2 parents 19ab9b4 + d7aca05 commit 19753f3
Show file tree
Hide file tree
Showing 17 changed files with 522 additions and 2 deletions.
4 changes: 4 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,10 @@

<receiver android:name=".android.NewNoteBroadcastReceiver"/>

<receiver android:name=".android.external.ExternalAccessReceiver"
android:exported="true">
</receiver>

<receiver android:name=".android.TimeChangeBroadcastReceiver" android:exported="false">
<intent-filter>
<action android:name="android.intent.action.TIME_SET"/>
Expand Down
18 changes: 17 additions & 1 deletion app/src/main/java/com/orgzly/android/data/DataRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -613,7 +613,7 @@ class DataRepository @Inject constructor(

} else {
db.runInTransaction(Callable {
moveSubtrees(noteIds, Place.UNDER, target.noteId)
moveSubtrees(noteIds, target.place, target.noteId)
})
}
}
Expand Down Expand Up @@ -1280,6 +1280,22 @@ class DataRepository @Inject constructor(
return db.note().getNoteAndAncestors(noteId)
}

fun getNoteAtPath(fullPath: String): NoteView? {
val (bookName, path) = run {
val pathParts = fullPath.split("/")
if (pathParts.isEmpty()) return null
pathParts[0] to pathParts.drop(1).joinToString("/")
}
return if (path.split("/").any { it.isNotEmpty() })
getNotes(bookName)
.filter { ("/$path").endsWith("/" + it.note.title) }
.firstOrNull { view ->
getNoteAndAncestors(view.note.id)
.joinToString("/") { it.title } == path
}
else null
}

fun getNotesAndSubtrees(ids: Set<Long>): List<Note> {
return db.note().getNotesForSubtrees(ids)
}
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/com/orgzly/android/di/AppComponent.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.orgzly.android.TimeChangeBroadcastReceiver
import com.orgzly.android.di.module.ApplicationModule
import com.orgzly.android.di.module.DataModule
import com.orgzly.android.di.module.DatabaseModule
import com.orgzly.android.external.actionhandlers.ExternalAccessActionHandler
import com.orgzly.android.reminders.NoteReminders
import com.orgzly.android.reminders.RemindersBroadcastReceiver
import com.orgzly.android.sync.SyncWorker
Expand Down Expand Up @@ -86,4 +87,5 @@ interface AppComponent {
fun inject(arg: RemindersBroadcastReceiver)
fun inject(arg: NotificationBroadcastReceiver)
fun inject(arg: SharingShortcutsManager)
fun inject(arg: ExternalAccessActionHandler)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.orgzly.android.external

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.google.gson.GsonBuilder
import com.orgzly.android.external.actionhandlers.*
import com.orgzly.android.external.types.Response

class ExternalAccessReceiver : BroadcastReceiver() {
val actionHandlers = listOf(
GetOrgInfo(),
RunSearch(),
EditNotes(),
EditSavedSearches(),
ManageWidgets()
)

override fun onReceive(context: Context?, intent: Intent?) {
val response = actionHandlers.asSequence()
.mapNotNull { it.handle(intent!!, context!!) }
.firstOrNull()
?: Response(false, "Invalid action")
val gson = GsonBuilder().serializeNulls().create()
resultData = gson.toJson(response)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.orgzly.android.external.actionhandlers

import android.content.Intent
import com.orgzly.android.external.types.ExternalHandlerFailure

class EditNotes : ExternalAccessActionHandler() {
override val actions = listOf(
action(::addNote, "ADD_NOTE"),
action(::editNote, "EDIT_NOTE"),
action(::refileNote, "REFILE_NOTE", "REFILE_NOTES"),
action(::moveNote, "MOVE_NOTE", "MOVE_NOTES"),
action(::deleteNote, "DELETE_NOTE", "DELETE_NOTES")
)

private fun addNote(intent: Intent): String {
val place = intent.getNotePlace()
val newNote = intent.getNotePayload()
val note = dataRepository.createNote(newNote, place)
return "${note.id}"
}

private fun editNote(intent: Intent) {
val noteView = intent.getNote()
val newNote = intent.getNotePayload(title=noteView.note.title)
dataRepository.updateNote(noteView.note.id, newNote)
}

private fun refileNote(intent: Intent) {
val notes = intent.getNoteIds()
val place = intent.getNotePlace()
dataRepository.refileNotes(notes, place)
}

private fun moveNote(intent: Intent) {
val notes = intent.getNoteIds()
with(dataRepository) { when (intent.getStringExtra("DIRECTION")) {
"UP" -> moveNote(intent.getBook().id, notes, -1)
"DOWN" -> moveNote(intent.getBook().id, notes, 1)
"LEFT" -> promoteNotes(notes)
"RIGHT" -> demoteNotes(notes)
else -> throw ExternalHandlerFailure("invalid direction")
} }
}

private fun deleteNote(intent: Intent) {
intent.getNoteIds().groupBy {
dataRepository.getNoteView(it)?.bookName
?: throw ExternalHandlerFailure("invalid note id $it")
}.forEach { (bookName, notes) ->
dataRepository.deleteNotes(dataRepository.getBook(bookName)!!.id, notes.toSet())
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.orgzly.android.external.actionhandlers

import android.content.Intent
import com.orgzly.android.db.entity.SavedSearch
import com.orgzly.android.external.types.ExternalHandlerFailure

class EditSavedSearches : ExternalAccessActionHandler() {
override val actions = listOf(
action(::addSavedSearch, "ADD_SAVED_SEARCH"),
action(::editSavedSearch, "EDIT_SAVED_SEARCH"),
action(::moveSavedSearch, "MOVE_SAVED_SEARCH"),
action(::deleteSavedSearch, "DELETE_SAVED_SEARCH"),
)

private fun addSavedSearch(intent: Intent): String {
val savedSearch = intent.getNewSavedSearch()
val id = dataRepository.createSavedSearch(savedSearch)
return "$id"
}

private fun editSavedSearch(intent: Intent) {
val savedSearch = intent.getSavedSearch()
val newSavedSearch = intent.getNewSavedSearch(allowBlank = true)
dataRepository.updateSavedSearch(SavedSearch(
savedSearch.id,
newSavedSearch.name.ifBlank { savedSearch.name },
newSavedSearch.query.ifBlank { savedSearch.query },
savedSearch.position
))
}

private fun moveSavedSearch(intent: Intent) {
val savedSearch = intent.getSavedSearch()
when (intent.getStringExtra("DIRECTION")) {
"UP" -> dataRepository.moveSavedSearchUp(savedSearch.id)
"DOWN" -> dataRepository.moveSavedSearchDown(savedSearch.id)
else -> throw ExternalHandlerFailure("invalid direction")
}
}

private fun deleteSavedSearch(intent: Intent) {
val savedSearch = intent.getSavedSearch()
dataRepository.deleteSavedSearches(setOf(savedSearch.id))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.orgzly.android.external.actionhandlers

import android.content.Context
import android.content.Intent
import com.orgzly.android.App
import com.orgzly.android.data.DataRepository
import com.orgzly.android.external.types.Response
import javax.inject.Inject

abstract class ExternalAccessActionHandler : ExternalIntentParser {
@Inject
override lateinit var dataRepository: DataRepository

init {
@Suppress("LeakingThis")
App.appComponent.inject(this)
}

abstract val actions: List<List<Pair<String, (Intent, Context) -> Any>>>
private val fullNameActions by lazy {
actions.flatten().toMap().mapKeys { (key, _) -> "com.orgzly.android.$key" }
}

fun action(f: (Intent, Context) -> Any, vararg names: String) = names.map { it to f }

@JvmName("intentAction")
fun action(f: (Intent) -> Any, vararg names: String) =
action({ i, _ -> f(i) }, *names)

@JvmName("contextAction")
fun action(f: (Context) -> Any, vararg names: String) =
action({ _, c -> f(c) }, *names)

fun action(f: () -> Any, vararg names: String) =
action({ _, _ -> f() }, *names)


fun handle(intent: Intent, context: Context) = try {
fullNameActions[intent.action!!]
?.let { it(intent, context) }
?.let { Response(true, if (it is Unit) null else it) }
} catch (e: Exception) {
Response(false, e.message)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package com.orgzly.android.external.actionhandlers

import android.content.Intent
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import com.orgzly.android.data.DataRepository
import com.orgzly.android.db.entity.NoteView
import com.orgzly.android.db.entity.SavedSearch
import com.orgzly.android.external.types.ExternalHandlerFailure
import com.orgzly.android.query.user.InternalQueryParser
import com.orgzly.android.ui.NotePlace
import com.orgzly.android.ui.Place
import com.orgzly.android.ui.note.NotePayload
import com.orgzly.org.OrgProperties

interface ExternalIntentParser {
val dataRepository: DataRepository

fun Intent.getNotePayload(title: String? = null): NotePayload {
val rawJson = getStringExtra("NOTE_PAYLOAD")
val json = try {
JsonParser.parseString(rawJson)
.let { if (it.isJsonObject) it.asJsonObject else null }!!
} catch (e: Exception) {
throw ExternalHandlerFailure("failed to parse json: ${e.message}\n$rawJson")
}
return NotePayload(
json.getString("title") ?: title
?: throw ExternalHandlerFailure("no title supplied!\n$rawJson"),
json.getString("content"),
json.getString("state"),
json.getString("priority"),
json.getString("scheduled"),
json.getString("deadline"),
json.getString("closed"),
(json.getString("tags") ?: "")
.split(" +".toRegex())
.filter { it.isNotEmpty() },
OrgProperties().apply {
json["properties"]?.asMap?.forEach { (k, v) -> this[k] = v }
}
)
}

private fun getNoteByQuery(rawQuery: String?): NoteView {
if (rawQuery == null)
throw ExternalHandlerFailure("couldn't find note")
val query = InternalQueryParser().parse(rawQuery)
val notes = dataRepository.selectNotesFromQuery(query)
if (notes.isEmpty())
throw ExternalHandlerFailure("couldn't find note")
if (notes.size > 1)
throw ExternalHandlerFailure("query \"$rawQuery\" gave multiple results")
return notes[0]
}

fun Intent.getNote(prefix: String = "") =
dataRepository.getNoteView(getLongExtra("${prefix}NOTE_ID", -1))
?: dataRepository.getNoteAtPath(getStringExtra("${prefix}NOTE_PATH") ?: "")
?: getNoteByQuery(getStringExtra("${prefix}NOTE_QUERY"))

fun Intent.getNoteAndProps(prefix: String = "") = getNote(prefix).let {
it to dataRepository.getNoteProperties(it.note.id)
}

fun Intent.getBook(prefix: String = "") =
dataRepository.getBook(getLongExtra("${prefix}BOOK_ID", -1))
?: dataRepository.getBook(getStringExtra("${prefix}BOOK_NAME") ?: "")
?: throw ExternalHandlerFailure("couldn't find book")

fun Intent.getNotePlace() = try {
getNote(prefix="PARENT_").let { noteView ->
val place = try {
Place.valueOf(getStringExtra("PLACEMENT") ?: "")
} catch (e: IllegalArgumentException) { Place.UNDER }
dataRepository.getBook(noteView.bookName)?.let { book ->
NotePlace(book.id, noteView.note.id, place)
}
}
} catch (e: ExternalHandlerFailure) { null } ?: try {
NotePlace(getBook(prefix="PARENT_").id)
} catch (e: ExternalHandlerFailure) {
throw ExternalHandlerFailure("couldn't find parent note/book")
}

fun Intent.getNoteIds(allowSingle: Boolean = true, allowEmpty: Boolean = false): Set<Long> {
val id = if (allowSingle) getLongExtra("NOTE_ID", -1) else null
val ids = getLongArrayExtra("NOTE_IDS")?.toTypedArray() ?: emptyArray()
val path =
if (allowSingle)
getStringExtra("NOTE_PATH")
?.let { dataRepository.getNoteAtPath(it)?.note?.id }
else null
val paths = (getStringArrayExtra("NOTE_PATHS") ?: emptyArray())
.mapNotNull { dataRepository.getNoteAtPath(it)?.note?.id }
.toTypedArray()
return listOfNotNull(id, *ids, path, *paths).filter { it >= 0 }.toSet().also {
if (it.isEmpty() && !allowEmpty)
throw ExternalHandlerFailure("no notes specified")
}
}

fun Intent.getSavedSearch() =
dataRepository.getSavedSearch(getLongExtra("SAVED_SEARCH_ID", -1))
?: dataRepository.getSavedSearches()
.find { it.name == getStringExtra("SAVED_SEARCH_NAME") }
?: throw ExternalHandlerFailure("couldn't find saved search")

fun Intent.getNewSavedSearch(allowBlank: Boolean = false): SavedSearch {
val name = getStringExtra("SAVED_SEARCH_NEW_NAME")
val query = getStringExtra("SAVED_SEARCH_NEW_QUERY")
if (!allowBlank && (name.isNullOrBlank() || query.isNullOrBlank()))
throw ExternalHandlerFailure("invalid parameters for new saved search")
return SavedSearch(0, name ?: "", query ?: "", 0)
}

private fun JsonObject.getString(name: String) = this[name]?.let {
if (it.isJsonPrimitive && it.asJsonPrimitive.isString)
it.asJsonPrimitive.asString
else null
}

private val JsonElement.asMap: Map<String, String>?
get() = if (this.isJsonObject) {
this.asJsonObject
.entrySet()
.map {
if (it.value.isJsonPrimitive)
it.key to it.value.asJsonPrimitive.asString
else return null
}
.toMap()
} else null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.orgzly.android.external.actionhandlers

import android.content.Intent
import com.orgzly.android.external.types.*

class GetOrgInfo : ExternalAccessActionHandler() {
override val actions = listOf(
action(::getBooks, "GET_BOOKS"),
action(::getSavedSearches, "GET_SAVED_SEARCHES"),
action(::getNote, "GET_NOTE")
)

private fun getBooks() =
dataRepository.getBooks().map(Book::from).toTypedArray()

private fun getSavedSearches() =
dataRepository.getSavedSearches().map(SavedSearch::from).toTypedArray()

private fun getNote(intent: Intent) =
Note.from(intent.getNoteAndProps())
}
Loading

0 comments on commit 19753f3

Please sign in to comment.