commit 92266869482da5e8f3c43508ac4028a56573a215 Author: LavaDesu Date: Thu May 29 03:12:05 2025 +1000 feat(Scout): init diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..85317b5 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,59 @@ +name: Build + +# https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#concurrency +concurrency: + group: "build" + cancel-in-progress: true + +on: + push: + branches: + - main + paths-ignore: + - '*.md' + +jobs: + build: + runs-on: ubuntu-20.04 + steps: + - name: Checkout + uses: actions/checkout@master + with: + path: "src" + + - name: Checkout builds + uses: actions/checkout@master + with: + ref: "builds" + path: "builds" + + - name: Checkout Aliucord + uses: actions/checkout@master + with: + repository: "Aliucord/Aliucord" + path: "repo" + + - name: Setup JDK 11 + uses: actions/setup-java@v1 + with: + java-version: 11 + + - name: Setup Android SDK + uses: android-actions/setup-android@v2 + + - name: Build Plugins + run: | + cd $GITHUB_WORKSPACE/src + chmod +x gradlew + ./gradlew make generateUpdaterJson + cp **/build/*.zip $GITHUB_WORKSPACE/builds + cp build/updater.json $GITHUB_WORKSPACE/builds + + - name: Push builds + run: | + cd $GITHUB_WORKSPACE/builds + git config --local user.email "actions@github.com" + git config --local user.name "GitHub Actions" + git add . + git commit -m "Build $GITHUB_SHA" || exit 0 # do not error if nothing to commit + git push diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f5a8eb6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +/build +**/build +/captures +.externalNativeBuild +.cxx +local.properties +/libs diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..4452855 --- /dev/null +++ b/LICENCE @@ -0,0 +1,19 @@ +Copyright (c) 2025 Cilly Leang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b979cb2 --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +# `Awoocord Plugins` + +## [Scout](https://github.com/LavaDesu/Awoocord/raw/builds/Scout.zip ) + +Reimplemented features from search of other clients: +- Sorting by oldest first +- Filter by date +- Search from user ID diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..93de107 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,83 @@ +import com.aliucord.gradle.AliucordExtension +import com.android.build.gradle.BaseExtension + +buildscript { + repositories { + google() + mavenCentral() + maven("https://maven.aliucord.com/snapshots") + maven("https://jitpack.io") + } + + dependencies { + classpath("com.android.tools.build:gradle:7.0.4") + classpath("com.aliucord:gradle:main-SNAPSHOT") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.21") + } +} + +allprojects { + repositories { + google() + mavenCentral() + maven("https://maven.aliucord.com/snapshots") + } +} + +fun Project.aliucord(configuration: AliucordExtension.() -> Unit) = extensions.getByName("aliucord").configuration() + +fun Project.android(configuration: BaseExtension.() -> Unit) = extensions.getByName("android").configuration() + +subprojects { + apply(plugin = "com.android.library") + apply(plugin = "com.aliucord.gradle") + apply(plugin = "kotlin-android") + + aliucord { + author("lavadesu", 368398754077868032L) + updateUrl.set("https://raw.githubusercontent.com/LavaDesu/Awoocord/builds/updater.json") + buildUrl.set("https://raw.githubusercontent.com/LavaDesu/Awoocord/builds/%s.zip") + } + + android { + compileSdkVersion(31) + + defaultConfig { + minSdk = 24 + targetSdk = 31 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + tasks.withType { + kotlinOptions { + jvmTarget = "11" + // Disables some unnecessary features + freeCompilerArgs = freeCompilerArgs + + "-Xno-call-assertions" + + "-Xno-param-assertions" + + "-Xno-receiver-assertions" + } + } + } + + dependencies { + val discord by configurations + val implementation by configurations + + // Stubs for all Discord classes + discord("com.discord:discord:aliucord-SNAPSHOT") + implementation("com.aliucord:Aliucord:main-SNAPSHOT") + + implementation("androidx.appcompat:appcompat:1.4.0") + implementation("com.google.android.material:material:1.4.0") + implementation("androidx.constraintlayout:constraintlayout:2.1.2") + } +} + +task("clean") { + delete(rootProject.buildDir) +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..01b80d7 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,19 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Automatically convert third-party libraries to use AndroidX +android.enableJetifier=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f6b961f Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..da0e964 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Wed Jun 09 16:18:24 EST 2021 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.1.1-bin.zip diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..005bcde --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100755 index 0000000..6a68175 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/plugins/Scout/build.gradle.kts b/plugins/Scout/build.gradle.kts new file mode 100644 index 0000000..ec50e58 --- /dev/null +++ b/plugins/Scout/build.gradle.kts @@ -0,0 +1,17 @@ +version = "1.0.0" +description = "Backported and improved search functionality" + +aliucord { + // Changelog of your plugin + changelog.set(""" + 1.0.0 - Initial release >w< + """.trimIndent()) + + // Add additional authors to this plugin + // author("Name", 0) + // author("Name", 0) + + // Excludes this plugin from the updater, meaning it won't show up for users. + // Set this if the plugin is unfinished + excludeFromUpdaterJson.set(true) +} diff --git a/plugins/Scout/src/main/AndroidManifest.xml b/plugins/Scout/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a45f28d --- /dev/null +++ b/plugins/Scout/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/plugins/Scout/src/main/kotlin/com/discord/restapi/RequiredHeadersInterceptor.kt b/plugins/Scout/src/main/kotlin/com/discord/restapi/RequiredHeadersInterceptor.kt new file mode 100644 index 0000000..2992c63 --- /dev/null +++ b/plugins/Scout/src/main/kotlin/com/discord/restapi/RequiredHeadersInterceptor.kt @@ -0,0 +1,5 @@ +package com.discord.restapi + +// Stub +@Suppress("ClassName") +abstract class `RequiredHeadersInterceptor$HeadersProvider` {} diff --git a/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/FilterTypeExtension.kt b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/FilterTypeExtension.kt new file mode 100644 index 0000000..858667f --- /dev/null +++ b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/FilterTypeExtension.kt @@ -0,0 +1,13 @@ +package moe.lava.awoocord.scout + +import com.discord.utilities.search.query.FilterType + +object FilterTypeExtension { + lateinit var BEFORE: FilterType + lateinit var DURING: FilterType + lateinit var AFTER: FilterType + lateinit var SORT: FilterType + lateinit var dates: Array + lateinit var values: Array +} + diff --git a/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/Scout.kt b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/Scout.kt new file mode 100644 index 0000000..d02f20f --- /dev/null +++ b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/Scout.kt @@ -0,0 +1,332 @@ +package moe.lava.awoocord.scout + +import android.content.Context +import android.content.res.Resources +import androidx.core.content.ContextCompat +import com.aliucord.Utils +import com.aliucord.annotations.AliucordPlugin +import com.aliucord.entities.Plugin +import com.aliucord.patcher.PreHook +import com.aliucord.patcher.after +import com.aliucord.patcher.before +import com.discord.BuildConfig +import com.discord.restapi.RequiredHeadersInterceptor +import com.discord.restapi.RequiredHeadersInterceptor.HeadersProvider +import com.discord.restapi.RestAPIBuilder +import com.discord.simpleast.core.parser.Parser +import com.discord.simpleast.core.parser.Rule +import com.discord.stores.StoreSearch +import com.discord.stores.StoreSearchInput +import com.discord.utilities.rest.RestAPI.AppHeadersProvider +import com.discord.utilities.search.network.`SearchFetcher$getRestObservable$3` +import com.discord.utilities.search.query.FilterType +import com.discord.utilities.search.query.node.QueryNode +import com.discord.utilities.search.query.node.content.ContentNode +import com.discord.utilities.search.query.node.filter.FilterNode +import com.discord.utilities.search.query.parsing.QueryParser +import com.discord.utilities.search.strings.SearchStringProvider +import com.discord.utilities.search.suggestion.SearchSuggestionEngine +import com.discord.utilities.search.suggestion.entries.FilterSuggestion +import com.discord.utilities.search.suggestion.entries.SearchSuggestion +import com.discord.widgets.search.suggestions.WidgetSearchSuggestionsAdapter +import com.franmontiel.persistentcookiejar.PersistentCookieJar +import com.franmontiel.persistentcookiejar.cache.SetCookieCache +import com.franmontiel.persistentcookiejar.persistence.SharedPrefsCookiePersistor +import moe.lava.awoocord.scout.api.SearchAPIInterface +import moe.lava.awoocord.scout.parsing.DateNode +import moe.lava.awoocord.scout.parsing.SortNode +import moe.lava.awoocord.scout.parsing.UserIdNode +import moe.lava.awoocord.scout.ui.DatePickerFragment +import moe.lava.awoocord.scout.ui.ScoutResource +import moe.lava.awoocord.scout.ui.ScoutSearchStringProvider + +@AliucordPlugin(requiresRestart = false) +@Suppress("unused", "unchecked_cast") +class Scout : Plugin() { + lateinit var ssProvider: ScoutSearchStringProvider + lateinit var searchApi: SearchAPIInterface + + override fun start(context: Context) { + ssProvider = ScoutSearchStringProvider(context) + searchApi = buildSearchApi(context) + extendFilterType() + patchQueryParser() + patchQuery() + patchSearchUI(context) + } + + override fun stop(context: Context) { + resetFilterType() + patcher.unpatchAll() + } + + // Creates a new custom search API implementation, for the extra `min_id` param in search queries + private fun buildSearchApi(context: Context): SearchAPIInterface { + @Suppress("cast_never_succeeds") + val appHeadersProvider = AppHeadersProvider.INSTANCE as HeadersProvider + val requiredHeadersInterceptor = RequiredHeadersInterceptor(appHeadersProvider) + val persistentCookieJar = PersistentCookieJar(SetCookieCache(), SharedPrefsCookiePersistor(context)) + val restAPIBuilder = RestAPIBuilder(BuildConfig.HOST_API, persistentCookieJar) + + return RestAPIBuilder.`build$default`( + restAPIBuilder, + SearchAPIInterface::class.java, + false, + 0L, + listOf(requiredHeadersInterceptor), + "client_base", + false, + null, + 102, + null + ) as SearchAPIInterface + } + + private val origFilterTypes: Array? = null + // Creates new pseudo-values of the `FilterType` enum for date filters + @Suppress("LocalVariableName") + private fun extendFilterType() { + val cls = FilterType::class.java + val constructor = cls.declaredConstructors[0] + constructor.isAccessible = true + + val field = cls.getDeclaredField("\$VALUES") + field.isAccessible = true + val values = field.get(null) as Array + var nextIdx = values.size + + val BEFORE = constructor.newInstance("BEFORE", nextIdx++) as FilterType + val DURING = constructor.newInstance("DURING", nextIdx++) as FilterType + val AFTER = constructor.newInstance("AFTER", nextIdx++) as FilterType + val SORT = constructor.newInstance("SORT", nextIdx) as FilterType + FilterTypeExtension.BEFORE = BEFORE + FilterTypeExtension.DURING = DURING + FilterTypeExtension.AFTER = AFTER + FilterTypeExtension.SORT = SORT + FilterTypeExtension.dates = arrayOf(BEFORE, DURING, AFTER) + FilterTypeExtension.values = arrayOf(BEFORE, DURING, AFTER, SORT) + + val newValues = values.toMutableList() + newValues.addAll(FilterTypeExtension.values) + field.set(null, newValues.toTypedArray()) + } + + private fun resetFilterType() { + if (origFilterTypes == null) + return logger.error("No unpatched filter types?", null) + + val cls = FilterType::class.java + val field = cls.getDeclaredField("\$VALUES") + field.isAccessible = true + field.set(null, origFilterTypes) + } + + // Patches the search query to also insert `min_id`, required for searching "after:" and "during:" + private fun patchQuery() { + patcher.patch( + `SearchFetcher$getRestObservable$3`::class.java.getDeclaredMethod("call", Integer::class.java), + PreHook { param -> + val self = param.thisObject as `SearchFetcher$getRestObservable$3`<*, *> + val retryAttempts = param.args[0] as Int? + val params = self.`$searchQuery`.params + val maxID = self.`$oldestMessageId`?.let { listOf(it.toString()) } ?: params["max_id"] + param.result = if (self.`$searchTarget`.type == StoreSearch.SearchTarget.Type.GUILD) + searchApi.searchGuildMessages( + self.`$searchTarget`.id, + params["min_id"], + maxID, + params["author_id"], + params["mentions"], + params["channel_id"], + params["has"], + params["content"], + retryAttempts, + self.`$searchQuery`.includeNsfw, + listOf("timestamp"), + params["sort_order"] + ) + else + searchApi.searchChannelMessages( + self.`$searchTarget`.id, + params["min_id"], + maxID, + params["author_id"], + params["mentions"], + params["has"], + params["content"], + retryAttempts, + self.`$searchQuery`.includeNsfw, + listOf("timestamp"), + params["sort_order"] + ) + } + ) + } + + // Patch parser for date parsing + private fun patchQueryParser() { + patcher.after(SearchStringProvider::class.java) { + // We need to access and insert into the rules before the rest + val field = Parser::class.java.getDeclaredField("rules").apply { isAccessible = true } + val rules = field.get(this) as ArrayList> + rules.addAll(0, listOf( + UserIdNode.getUserIdRule(), + DateNode.getBeforeRule(ssProvider.beforeFilterString), + DateNode.getDuringRule(ssProvider.duringFilterString), + DateNode.getAfterRule(ssProvider.afterFilterString), + DateNode.getDateRule(), + SortNode.getFilterRule(ssProvider.sortFilterString), + SortNode.getSortRule(ssProvider), + )) + } + } + + // This is probably the worst bit of this plugin + private fun patchSearchUI(context: Context) { + // Run when a filter suggestion is clicked + // Most of the code is copied from its implementation + // Patch needed to support the new filter types + patcher.before( + "onFilterClicked", + FilterType::class.java, + SearchStringProvider::class.java, + List::class.java, + ) { param -> + val filter = param.args[0] as FilterType + if (filter !in FilterTypeExtension.values) + return@before; // Exit if not an extended filter type + + val replaceAndPublish = StoreSearchInput::class.java.getDeclaredMethod( + "replaceAndPublish", + Int::class.javaPrimitiveType!!, + List::class.java, + List::class.java + ) + replaceAndPublish.isAccessible = true + + val getAnswerReplacementStart = StoreSearchInput::class.java.getDeclaredMethod( + "getAnswerReplacementStart", + List::class.java, + ) + getAnswerReplacementStart.isAccessible = true + + // Original implementation + val filterNode = FilterNode(filter, ssProvider.stringFor(filter)) + val list = (param.args[2] as List).toMutableList() + val lastIndex = if (list.isEmpty()) { + 0 + } else if (list.last() is ContentNode) + list.lastIndex + else + list.size + + // Open a Date Picker + if (filter in FilterTypeExtension.dates) { + replaceAndPublish.invoke(this, lastIndex, listOf(filterNode), list) + DatePickerFragment.open(Utils.appActivity.supportFragmentManager) { + replaceAndPublish.invoke(this, + getAnswerReplacementStart.invoke(this, list), + listOf(filterNode, DateNode(it)), + list + ); + } + } + + if (filter == FilterTypeExtension.SORT) + replaceAndPublish.invoke(this, + lastIndex, + listOf(filterNode, SortNode(ssProvider.sortOldString)), + list + ); + param.result = null + } + + // Patch to set icons + @Suppress("ResourceType") + patcher.before( + "getIconDrawable", + Context::class.java, + FilterType::class.java + ) { param -> + val type = param.args[1] as FilterType + if (type in FilterTypeExtension.dates) + param.result = ContextCompat.getDrawable(context, ScoutResource.DRAWABLE_IC_CLOCK) + if (type == FilterTypeExtension.SORT) + param.result = ContextCompat.getDrawable(context, ScoutResource.DRAWABLE_IC_SORT_WHITE) + } + + // Patch for retrieving sample filter answer/placeholder + patcher.before( + "getAnswerText", + FilterType::class.java + ) { param -> + val type = param.args[0] as FilterType + if (type in FilterTypeExtension.dates) + param.result = ssProvider.getIdentifier("search_answer_date") + if (type == FilterTypeExtension.SORT) + param.result = ScoutResource.SORT_ANSWER + } + + // Patch for retrieving filter name + patcher.before( + "getFilterText", + FilterType::class.java + ) { param -> + val type = param.args[0] as FilterType + val res = when (type) { + FilterTypeExtension.BEFORE -> ssProvider.getIdentifier("search_filter_before") + FilterTypeExtension.DURING -> ssProvider.getIdentifier("search_filter_during") + FilterTypeExtension.AFTER -> ssProvider.getIdentifier("search_filter_after") + FilterTypeExtension.SORT -> ScoutResource.SORT_FILTER + else -> null + } + res?.let { param.result = it } + } + + // Patch formatting utils to use our custom lowercase strings + // This is called by FilterViewHolder.onConfigure, using the results from getAnswerText and getFilterText + patcher.patch( + b.a.k.b::class.java.getDeclaredMethod("c", + Resources::class.java, + Int::class.javaPrimitiveType!!, + Array::class.java, + Function1::class.java + ), + PreHook { param -> + val resID = param.args[1] as Int + val objArr = param.args[2] as Array<*> + val override = when (resID) { + ScoutResource.SORT_FILTER -> ssProvider.sortFilterString + ScoutResource.SORT_ANSWER -> ssProvider.sortOldString + else -> null + } + override?.let { + // Why invoke? Becuase I can't for the life of me get Function1 to cast properly + param.result = b.a.k.b::class.java.getDeclaredMethod("g", + CharSequence::class.java, + Array::class.java, + Function1::class.java + ).invoke(null, it, objArr.copyOf(), param.args[3]) + } + } + ) + + // Patch to add our new filters into the initial suggestions + patcher.after( + "getFilterSuggestions", + CharSequence::class.java, + SearchStringProvider::class.java, + Boolean::class.javaPrimitiveType!!, + ) { param -> + val query = param.args[0] as CharSequence + val res = (param.result as List).toMutableList() + for (type in FilterTypeExtension.values) { + val st = ssProvider.stringFor(type) + ":" + + if (st.contains(query)) + res.add(FilterSuggestion(type)) + } + param.result = res.toList() + } + } +} diff --git a/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/api/SearchAPIInterface.kt b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/api/SearchAPIInterface.kt new file mode 100644 index 0000000..6bbe273 --- /dev/null +++ b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/api/SearchAPIInterface.kt @@ -0,0 +1,44 @@ +package moe.lava.awoocord.scout.api + +import com.discord.models.domain.ModelSearchResponse +import i0.f0.f +import i0.f0.s +import i0.f0.t +import rx.Observable + +// io.f0.f = retrofit @GET +// io.f0.s = retrofit @Path +// io.f0.t = retrofit @Query + +interface SearchAPIInterface { + @f("channels/{channelId}/messages/search") + fun searchChannelMessages( + @s("channelId") channelId: Long, + @t("min_id") minId: List?, + @t("max_id") maxId: List?, + @t("author_id") authorId: List?, + @t("mentions") mentions: List?, + @t("has") has: List?, + @t("content") content: List?, + @t("attempts") attempts: Int?, + @t("include_nsfw") includeNsfw: Boolean?, + @t("sort_by") sortBy: List?, // "timestamp" is one, not sure about any other sort types + @t("sort_order") sortOrder: List?, // "asc" or "desc" + ): Observable + + @f("guilds/{guildId}/messages/search") + fun searchGuildMessages( + @s("guildId") guildId: Long, + @t("min_id") minId: List?, + @t("max_id") maxId: List?, + @t("author_id") authorId: List?, + @t("mentions") mentions: List?, + @t("channel_id") channelId: List?, + @t("has") has: List?, + @t("content") content: List?, + @t("attempts") attempts: Int?, + @t("include_nsfw") includeNsfw: Boolean?, + @t("sort_by") sortBy: List?, + @t("sort_order") sortOrder: List?, + ): Observable +} diff --git a/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/parsing/DateNode.kt b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/parsing/DateNode.kt new file mode 100644 index 0000000..d0ffa02 --- /dev/null +++ b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/parsing/DateNode.kt @@ -0,0 +1,75 @@ +package moe.lava.awoocord.scout.parsing + +import com.discord.simpleast.core.parser.ParseSpec +import com.discord.utilities.SnowflakeUtils +import com.discord.utilities.search.network.SearchQuery +import com.discord.utilities.search.query.FilterType +import com.discord.utilities.search.query.node.answer.AnswerNode +import com.discord.utilities.search.query.node.filter.FilterNode +import com.discord.utilities.search.validation.SearchData +import moe.lava.awoocord.scout.FilterTypeExtension +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.regex.Pattern + +class DateNode(private val date: Long?, private val unparsed: String) : AnswerNode() { + + constructor(unparsed: String) : this(fmt.parse(unparsed)?.time, unparsed) + + companion object { + val fmt = SimpleDateFormat("yyyy-MM-dd", Locale.US) + val regex: Pattern = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}", Pattern.UNICODE_CASE) + fun getDateRule(): ParserRule { + return SimpleParserRule(regex) { matcher, parser, obj -> + checkNotNull(matcher) { "matcher" } + checkNotNull(parser) { "parser" } + val match = matcher.group() + val date = fmt.parse(match) + val node = DateNode(date?.time, match) + ParseSpec(node, obj) + } + } + + private fun getFilterRule(str: String, type: FilterType): ParserRule { + val regex = Pattern.compile("^\\s*?(${str}):", 64); + return SimpleParserRule(regex) { _, _, obj -> + ParseSpec(FilterNode(type, str), obj) + } + } + + fun getBeforeRule(str: String): ParserRule = getFilterRule(str, FilterTypeExtension.BEFORE) + fun getDuringRule(str: String): ParserRule = getFilterRule(str, FilterTypeExtension.DURING) + fun getAfterRule(str: String): ParserRule = getFilterRule(str, FilterTypeExtension.AFTER) + } + + override fun getValidFilters(): Set = FilterTypeExtension.dates.toSet() + override fun isValid(searchData: SearchData?): Boolean = date != null + override fun getText(): CharSequence? = unparsed + + private val snowflake: String? + get() = date?.let { SnowflakeUtils.fromTimestamp(date).toString() } + private val nextDaySnowflake: String? + get() = date?.let { SnowflakeUtils.fromTimestamp(date + 86_400_000).toString() } + + override fun updateQuery( + builder: SearchQuery.Builder?, + searchData: SearchData?, + filterType: FilterType? + ) { + checkNotNull(builder) { "queryBuilder" } + checkNotNull(date) { "date" } + when (filterType) { + FilterTypeExtension.BEFORE -> { + builder.appendParam("max_id", snowflake) + } + FilterTypeExtension.AFTER -> { + builder.appendParam("min_id", nextDaySnowflake) + } + FilterTypeExtension.DURING -> { + builder.appendParam("min_id", snowflake) + builder.appendParam("max_id", nextDaySnowflake) + } + else -> return + } + } +} diff --git a/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/parsing/SimpleParserRule.kt b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/parsing/SimpleParserRule.kt new file mode 100644 index 0000000..cc5408f --- /dev/null +++ b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/parsing/SimpleParserRule.kt @@ -0,0 +1,29 @@ +package moe.lava.awoocord.scout.parsing + +import android.content.Context +import com.discord.simpleast.core.parser.ParseSpec +import com.discord.simpleast.core.parser.Parser +import com.discord.simpleast.core.parser.Rule +import com.discord.utilities.search.query.node.QueryNode +import java.util.regex.Matcher +import java.util.regex.Pattern + +internal typealias ParserRule = Rule +internal class SimpleParserRule( + regex: Pattern, + private val parseMethod: ( + matcher: Matcher, + parser: Parser, + obj: Any + ) -> ParseSpec +) : ParserRule(regex) { + override fun parse( + matcher: Matcher?, + parser: Parser, + obj: Any + ): ParseSpec { + checkNotNull(matcher) { "matcher" } + checkNotNull(parser) { "parser" } + return parseMethod(matcher, parser, obj) + } +} diff --git a/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/parsing/SortNode.kt b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/parsing/SortNode.kt new file mode 100644 index 0000000..e74f2a9 --- /dev/null +++ b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/parsing/SortNode.kt @@ -0,0 +1,46 @@ +package moe.lava.awoocord.scout.parsing + +import android.content.Context +import com.discord.simpleast.core.parser.ParseSpec +import com.discord.simpleast.core.parser.Rule +import com.discord.utilities.search.network.SearchQuery +import com.discord.utilities.search.query.FilterType +import com.discord.utilities.search.query.node.QueryNode +import com.discord.utilities.search.query.node.answer.AnswerNode +import com.discord.utilities.search.query.node.filter.FilterNode +import com.discord.utilities.search.validation.SearchData +import moe.lava.awoocord.scout.FilterTypeExtension +import moe.lava.awoocord.scout.ui.ScoutSearchStringProvider +import java.util.regex.Pattern + +class SortNode(private val text: String): AnswerNode() { + companion object { + fun getSortRule(ssProvider: ScoutSearchStringProvider): Rule { + val regexStr = "^\\s*(${ssProvider.sortOldString})" + val regex = Pattern.compile(regexStr, Pattern.UNICODE_CASE) + return SimpleParserRule(regex) { _, _, obj -> + ParseSpec(SortNode(ssProvider.sortOldString), obj) + } + } + + fun getFilterRule(str: String): ParserRule { + val regex = Pattern.compile("^\\s*?(${str}):", 64); + return SimpleParserRule(regex) { _, _, obj -> + ParseSpec(FilterNode(FilterTypeExtension.SORT, str), obj) + } + } + } + + override fun getValidFilters() = setOf(FilterTypeExtension.SORT) + override fun isValid(searchData: SearchData?) = true + override fun getText() = this.text + + override fun updateQuery( + builder: SearchQuery.Builder?, + searchData: SearchData?, + filterType: FilterType? + ) { + checkNotNull(builder) { "queryBuilder" } + builder.appendParam("sort_order", "asc") + } +} diff --git a/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/parsing/UserIdNode.kt b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/parsing/UserIdNode.kt new file mode 100644 index 0000000..a3c88b7 --- /dev/null +++ b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/parsing/UserIdNode.kt @@ -0,0 +1,41 @@ +package moe.lava.awoocord.scout.parsing + +import android.content.Context +import com.discord.simpleast.core.parser.ParseSpec +import com.discord.simpleast.core.parser.Rule +import com.discord.utilities.search.network.SearchQuery +import com.discord.utilities.search.query.FilterType +import com.discord.utilities.search.query.node.QueryNode +import com.discord.utilities.search.query.node.answer.AnswerNode +import com.discord.utilities.search.validation.SearchData +import java.util.regex.Pattern + +class UserIdNode(private val userID: String) : AnswerNode() { + companion object { + fun getUserIdRule(): Rule { + val regex = Pattern.compile("^\\d{17,19}", Pattern.UNICODE_CASE) + return SimpleParserRule(regex) { matcher, _, obj -> + ParseSpec(UserIdNode(matcher.group()), obj) + } + } + } + + override fun getValidFilters() = setOf(FilterType.FROM, FilterType.MENTIONS) + override fun isValid(searchData: SearchData?) = true + override fun getText() = userID.toString() + + override fun updateQuery( + builder: SearchQuery.Builder?, + searchData: SearchData?, + filterType: FilterType? + ) { + checkNotNull(builder) { "queryBuilder" } + checkNotNull(searchData) { "searchData" } + val str = when (filterType) { + FilterType.FROM -> "author_id" + FilterType.MENTIONS -> "mentions" + else -> return + } + builder.appendParam(str, userID) + } +} diff --git a/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/ui/DatePickerFragment.kt b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/ui/DatePickerFragment.kt new file mode 100644 index 0000000..5230c7b --- /dev/null +++ b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/ui/DatePickerFragment.kt @@ -0,0 +1,34 @@ +package moe.lava.awoocord.scout.ui + +import android.app.DatePickerDialog +import android.app.Dialog +import android.os.Bundle +import android.widget.DatePicker +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentManager +import java.util.Calendar + +class DatePickerFragment( + private val callback: (String) -> Unit +) : DialogFragment(), DatePickerDialog.OnDateSetListener { + companion object { + fun open(fragmentManager: FragmentManager, callback: (date: String) -> Unit) { + DatePickerFragment(callback).show(fragmentManager, "datePicker") + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val c = Calendar.getInstance() + + val year = c.get(Calendar.YEAR) + val month = c.get(Calendar.MONTH) + val day = c.get(Calendar.DAY_OF_MONTH) + + android.app.AlertDialog.THEME_DEVICE_DEFAULT_DARK + return DatePickerDialog(requireContext(), this, year, month, day) + } + + override fun onDateSet(picker: DatePicker, year: Int, month: Int, day: Int) { + callback("%04d-%02d-%02d".format(year, month, day)) + } +} diff --git a/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/ui/ScoutResource.kt b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/ui/ScoutResource.kt new file mode 100644 index 0000000..8700d51 --- /dev/null +++ b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/ui/ScoutResource.kt @@ -0,0 +1,8 @@ +package moe.lava.awoocord.scout.ui + +object ScoutResource { + const val SORT_FILTER = 0xfffffff0.toInt() + const val SORT_ANSWER = 0xfffffff1.toInt() + const val DRAWABLE_IC_CLOCK = 0x7f0803bb + const val DRAWABLE_IC_SORT_WHITE =0x7f080586 +} diff --git a/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/ui/ScoutSearchStringProvider.kt b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/ui/ScoutSearchStringProvider.kt new file mode 100644 index 0000000..ee7ac1b --- /dev/null +++ b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/ui/ScoutSearchStringProvider.kt @@ -0,0 +1,35 @@ +package moe.lava.awoocord.scout.ui + +import android.content.Context +import com.discord.utilities.search.query.FilterType +import moe.lava.awoocord.scout.FilterTypeExtension + +private fun String.decapitalise(context: Context) = + this.replaceFirstChar { it.lowercase(context.resources.configuration.locales[0]) } + +class ScoutSearchStringProvider(private val context: Context) { + fun getIdentifier(name: String) = + context.resources.getIdentifier(name, "string", "com.discord") + fun getString(name: String) = + context.getString(getIdentifier(name)) + + fun stringFor(type: FilterType) = when (type) { + FilterTypeExtension.BEFORE -> beforeFilterString + FilterTypeExtension.DURING -> duringFilterString + FilterTypeExtension.AFTER -> afterFilterString + FilterTypeExtension.SORT -> sortFilterString + else -> throw IllegalArgumentException("invalid extended filter type") + } + + // Surprising!! Discord has localised strings of these + val beforeFilterString: String + get() = getString("search_filter_before") + val duringFilterString: String + get() = getString("search_filter_during") + val afterFilterString: String + get() = getString("search_filter_after") + val sortFilterString: String + get() = getString("sort").decapitalise(context) + val sortOldString: String + get() = getString("search_oldest_short").decapitalise(context) +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..90c91a9 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,14 @@ +rootProject.name = "Awoocord" + +// This file sets what projects are included. Every time you add a new project, you must add it +// to the includes below. + +// Plugins are included like this +include( + "Scout" +) + +rootProject.children.forEach { + // Change kotlin to java if you'd rather use java + it.projectDir = file("plugins/${it.name}") +}