Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a public receiver for Tasker, Automate, etc. #112

Merged
merged 13 commits into from
Dec 2, 2023
Merged
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
Loading