diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..2651a90 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,88 @@ +name: Build-and-Publish + +on: + # triggers on pushes with tag + push: + tags: + - '*.*' + +jobs: + # build the apk for release + build: + runs-on: ubuntu-latest + steps: + # check out repo with submodules + - name: Checkout Repo + uses: actions/checkout@v2 + with: + ref: senpai + submodules: recursive + + # setup jdk 1.8 + - name: Set up JDK + uses: actions/setup-java@v1 + with: + java-version: 1.8 + + # make gradlew executeable + - name: Make Gradle Executable + run: chmod +x ./gradlew + + # build using gradle + - name: Build with Gradle + run: ./gradlew build + + # build apk + - name: Build Release APK + run: ./gradlew assembleRelease + + # sign APK + - name: Sign APK + id: sign_apk + uses: r0adkll/sign-android-release@v1 + with: + releaseDirectory: app/build/outputs/apk/release + signingKeyBase64: ${{ secrets.SIGNING_KEY }} + alias: ${{ secrets.ALIAS }} + keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} + keyPassword: ${{ secrets.KEY_PASSWORD }} + + # rename signed apk + - name: Rename Signed APK + run: mv ${{ steps.sign_apk.outputs.signedReleaseFile }} ./tenshi-content.apk + + # upload artifact + - name: Upload APK Artifact + uses: actions/upload-artifact@v2 + with: + name: apk-build + path: ./tenshi-content.apk + + # upload the built apk to github release + upload_github: + needs: build + runs-on: ubuntu-latest + steps: + # download artifact from previous step + - name: Download APK Artifact + uses: actions/download-artifact@v2 + with: + name: apk-build + path: ./ + + # generate apk checksum + - name: "Generate APK Checksum" + run: sha256sum ./tenshi-content.apk > tenshi-content.sha256 + + # add apk to release + - name: Add APK to release + uses: softprops/action-gh-release@v1 + if: startsWith(github.ref, 'refs/tags/') + with: + body: automatic build + files: | + ./tenshi-content.apk + ./tenshi-content.sha256 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..6bb1f24 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "Extensions-Lib"] + path = Extensions-Lib + url = https://github.com/Tenshiorg/Extensions-Lib.git diff --git a/CONTRIBUTING.MD b/CONTRIBUTING.MD new file mode 100644 index 0000000..e57cd34 --- /dev/null +++ b/CONTRIBUTING.MD @@ -0,0 +1,163 @@ +# Contributing + +# Prerequisites + +Before you start, please note that you should be able to use the following technologies. Existing contributors will not actively teach them to you. + +- Basic Android developement +- Java +- Web Scraping + - HTML + - JavaScript* + - JSoup* + +\* maybe not required depending on your implementation + +## Tools + +- [Android Studio](https://developer.android.com/studio) +- Emulator or phone ready for development and Tenshi installed +- Google Chrome on the host machine (useful when debugging Web Adapters) + + +# Getting Help + +Theres currently no discord or anything setup, so please refer to existing Content Adapters for examples.
+If required, you can also write a issue + +# Writing a Content Adapter + +Content Adapters are a way to provide content, like episode streams, to Tenshi.
+Adapters are implemented as Android Services with a [AIDL](https://developer.android.com/guide/components/aidl) interface. + + +There are two ways of writing a Content Adapter: + +# Web Adapters + +A Web Adapter is essentially just a WebView with Javascript injected into the page.
+A Web Adapter consists of a [json definition](https://github.com/Tenshiorg/Tenshi-Content/blob/kohai/webadapters/adapter-definitions.json) and a [payload](https://github.com/Tenshiorg/Tenshi-Content/tree/kohai/webadapters/payloads) that is injected into the page. + + +To start developing a Web Adapter, clone this repository and set [DEBUG_MODE](https://github.com/Tenshiorg/Tenshi-Content/blob/kohai/app/src/main/java/io/github/shadow578/tenshicontent/webadapter/Constants.java#L14) to true.
+ +## Definition + +The definition of a Web Adapter is loaded every time the WebAdapterService is initialized.
+When in debug mode, write your definition in the file in [raw/debug_definition.json](https://github.com/Tenshiorg/Tenshi-Content/blob/kohai/app/src/main/res/raw/debug_definition.json). + + +### Format + +```json +[ + { + "name": "fouranime.web", + "displayName": "4Anime", + "storagePattern": null, + "searchUrl": "https://4anime.to/?s=%s", + "episodeUrl": "https://4anime.to/%s-episode-%02d", + "payload": "webadapters/payloads/4anime.json", + "userAgentOverride": null, + "domStorageEnabled": null, + "allowContentAccess": null + } +] +``` + +Property | Description +------------|------------ +name | a unique name for your adapter +displayName | the name shown to the user +storagePattern | a regex pattern to validate the persistent storage. null to disable +searchUrl | search url used when persistent storage is empty. %s is replaced with the name of the anime, url- escaped +episodeUrl | url to directly go to a episode. %s is replaced with the contents of persistent storage, %d with the episode number +payload | javascript payload of this adapter, relative to [PAYLOAD_ROOT](https://github.com/Tenshiorg/Tenshi-Content/blob/kohai/app/src/main/java/io/github/shadow578/tenshicontent/webadapter/Constants.java) +userAgentOverride | controls the [user agent](https://developer.android.com/reference/android/webkit/WebSettings#setUserAgentString(java.lang.String)) of the webview. left default if null +domStorageEnabled | controls [dom storage](https://developer.android.com/reference/android/webkit/WebSettings#setDomStorageEnabled(boolean)). left default if null +allowContentAccess | controls [content access](https://developer.android.com/reference/android/webkit/WebSettings#setAllowContentAccess(boolean)). left default if null + + +## Payload + +The payload of a Web Adapter is loaded every time it is injected into the page.
+When in debug mode, write your definition in the file in [raw/debug_payload.js](https://github.com/Tenshiorg/Tenshi-Content/blob/kohai/app/src/main/res/raw/debug_payload.js). + + +Writing the payload is, of course, highly dependent on the page you are writing it for.
+See [webadapter/payloads](https://github.com/Tenshiorg/Tenshi-Content/tree/kohai/webadapters/payloads) for examples. + +The general goals: +- write something to persistent storage that, in combination with the episodeUrl of the definition, gets the user to the episode page as direct as possible +- get the video url from the page +- block (disruptive) ads + +### Javascript interface + +The following functions are accessible to Payloads: + +App. | Description +----------------|------------- +toast(String) | make a toast +log(String) | write a message to Log.d, tag "JSInterface" +logE(String) | write a message to Log.e, tag "JSInterface" + +\* see [JSInterface](https://github.com/Tenshiorg/Extensions-Lib/blob/kohai/extensionsLib/src/main/java/io/github/shadow578/tenshi/extensionslib/content/util/WebViewAdapterActivity.java#L178) + + +Tenshi. | Description +------------------------|------------- +getUniqueName() | the current unique name +getAnimeTitle() | the (english) anime title +getAnimeTitleJp() | the (japanese) anime title +getMalId() | the MAL id of the anime +getPersistentStorage() | get the contents of persistent storage +setPersistentStorage(String) | set the contents of persistent storage +finish(String) | closes the webview and forwards the argument as stream url to Tenshi + +\* see [WebAdapterJs](https://github.com/Tenshiorg/Tenshi-Content/blob/kohai/app/src/main/java/io/github/shadow578/tenshicontent/webadapter/WebAdapterActivity.java#L211) + + +## Debugging + +When running a debug build with DEBUG_MODE enabled, the WebAdapterActivity will enable [Web Contents Debugging](https://developer.android.com/reference/android/webkit/WebView#setWebContentsDebuggingEnabled(boolean)), allowing you to (kinda) open full Chrome DevTools on your webview by opening chrome://inspect on your host PC with the phone or emulator connected to it. + +Quick Note: Chrome is (at least for me) sometimes very slow to connect to the webview. So bring patience when debugging. + + +# Native Adapters + +A native adapter gives you more control over how things work, and also allows you to create a adapter without a ui (or a custom one).
+ +The most barebone Content Adapter just extends [Service](https://developer.android.com/reference/android/app/Service) and returns a implementation of [IContentAdapter.Stub](https://github.com/Tenshiorg/Extensions-Lib/blob/kohai/extensionsLib/src/main/aidl/io/github/shadow578/tenshi/extensionslib/content/IContentAdapter.aidl) in onBind(). + +If you need a activity for your adapter, you can use a [ActivityAdapterService](https://github.com/Tenshiorg/Extensions-Lib/blob/kohai/extensionsLib/src/main/java/io/github/shadow578/tenshi/extensionslib/content/util/ActivityAdapterService.java). + + +After writing your Content Adapter Service, you have to add it to your manifest like so: + +```xml + + + + + + + + + +``` + +the service __must__: +- be exported (android:exported="true") +- define a intent filter with action and category set to "io.github.shadow578.tenshi.content.ADAPTER" +- contain meta-data "io.github.shadow578.tenshi.content.ADAPTER_VERSION" with value set to the same value as in the used extension lib (see IContentAdapter or Constants). + + +## Debugging + +Debugging of Content Adapters is easiest done using the [TestActivity](https://github.com/Tenshiorg/Tenshi-Content/blob/kohai/app/src/main/java/io/github/shadow578/tenshicontent/TestActivity.java). This uses the same logic to bind the adapters and allows for you to set breakpoints. diff --git a/Extensions-Lib b/Extensions-Lib new file mode 160000 index 0000000..892ea2c --- /dev/null +++ b/Extensions-Lib @@ -0,0 +1 @@ +Subproject commit 892ea2c625a177d0fdbb4aabc99d510d23cc4fb0 diff --git a/README.md b/README.md index 80c0d57..10cb41b 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,16 @@ # ![App Icon](.github/res/app-icon.png) Tenshi Content -Tenshi is a free & open source client for [MyAnimeList](https://myanimelist.net) for Android 6.0 and above. + +Tenshi-Content contains content adapters for [Tenshi](https://github.com/Tenshiorg/Tenshi), a free & open source client for [MyAnimeList](https://myanimelist.net) + +If you want to contribute, have a look [here](CONTRIBUTING.md) + ⚠ This Page is under Construction ⚠ + + ## License > Copyright 2021 shadow578 > @@ -19,3 +25,7 @@ Tenshi is a free & open source client for [MyAnimeList](https://myanimelist.net) > 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. + +## Disclaimer + +The developers of this application do not have any affiliation with the available content providers. diff --git a/app/build.gradle b/app/build.gradle index ef7fc54..6bda075 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,7 +11,7 @@ android { minSdkVersion 23 targetSdkVersion 30 versionCode 1 - versionName "1.0" + versionName "0.1" } buildTypes { @@ -31,5 +31,15 @@ dependencies { implementation 'com.google.android.material:material:1.3.0' implementation 'androidx.constraintlayout:constraintlayout:2.0.4' + // jsoup implementation 'org.jsoup:jsoup:1.11.1' + + // retrofit + gson for webadapter + implementation 'com.squareup.retrofit2:retrofit:2.9.0' + implementation 'com.squareup.retrofit2:converter-scalars:2.9.0' + implementation 'com.squareup.retrofit2:converter-gson:2.9.0' + implementation 'com.google.code.gson:gson:2.8.6' + + // Tenshi ExtensionsLib + implementation project(":extensionsLib") } \ No newline at end of file diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..0de52dd --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/debug/java/io/github/shadow578/tenshicontent/TestActivity.java b/app/src/debug/java/io/github/shadow578/tenshicontent/TestActivity.java new file mode 100644 index 0000000..1addcd2 --- /dev/null +++ b/app/src/debug/java/io/github/shadow578/tenshicontent/TestActivity.java @@ -0,0 +1,322 @@ +package io.github.shadow578.tenshicontent; + +import android.annotation.SuppressLint; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.preference.PreferenceManager; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.Spinner; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; + +import java.util.ArrayList; +import java.util.List; + +import io.github.shadow578.tenshi.extensionslib.content.ContentAdapterManager; +import io.github.shadow578.tenshi.extensionslib.content.ContentAdapterWrapper; + +import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.str; + +/** + * a basic activity for testing content adapter services in- process. + * Bind- and calling logic is based on the one found in Tenshi + */ +public class TestActivity extends AppCompatActivity { + //region Prefs Constants + + /** + * pref key for the last selected adapter + */ + public final String KEY_LAST_ADAPTER_INDEX = "LastAdapterIndex"; + + /** + * pref key for the last selected anime + */ + public final String KEY_LAST_ANIME_INDEX = "LastAnimeIndex"; + //endregion + + /** + * a list of anime options for testing + */ + private final Anime[] testableAnime = { + new Anime(41389, "Tonikaku Kawaii", 2), + new Anime(37141, "Hataraku Saibou (TV)", 1), + new Anime(31240, "Re:Zero kara Hajimeru Isekai Seikatsu", 3) + }; + + /** + * content adapter manager + */ + private ContentAdapterManager contentAdapterManager; + + /** + * the currently selected adapter unique name + */ + @NonNull + private String selectedAdapter = ""; + + /** + * the currently selected anime + */ + @NonNull + private Anime selectedAnime = testableAnime[0]; + + /** + * shared preferences of the app + */ + private SharedPreferences prefs; + + /** + * current persistent storage value + */ + private String persistentStorageValue = ""; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_test); + + // initialize preferences + prefs = PreferenceManager.getDefaultSharedPreferences(this); + + // setup anime selection spinner + populateAnimeSelection(); + updateSelectedAnimeDisplayViews(); + + // init content adapter manager + contentAdapterManager = new ContentAdapterManager(this, new ContentAdapterManager.IPersistentStorageProvider() { + @NonNull + @Override + public String getPersistentStorage(@NonNull String uniqueName, int animeId) { + return persistentStorageValue; + } + + @Override + public void setPersistentStorage(@NonNull String uniqueName, int animeId, @NonNull String persistentStorage) { + persistentStorageValue = persistentStorage; + updateQueryButtons(persistentStorage); + } + }); + contentAdapterManager.discoverAndInit(false); + contentAdapterManager.addOnDiscoveryEndCallback(p -> { + // abort if no adapters found + if(p.getAdapterCount() <= 0) + { + Toast.makeText(this, "No adapters loaded!", Toast.LENGTH_SHORT).show(); + return; + } + + // setup spinner for adapter selection + populateAdapterSelection(); + updateAdapterMetaViews(); + + // other stuff... + resetQueryResultViews(); + updateQueryButtons(null); + }); + } + + /** + * setup the spinner for anime selection + */ + private void populateAnimeSelection() { + // get a list of all testable anime names + final ArrayList animeNames = new ArrayList<>(); + for (Anime a : testableAnime) + animeNames.add(a.enTitle + " (EP " + a.episode + ")"); + + // setup the spinner + final Spinner animeSelect = findViewById(R.id.anime_select_spinner); + final ArrayAdapter adapter = new ArrayAdapter<>(this, R.layout.spinner_generic_text, animeNames); + animeSelect.setAdapter(adapter); + animeSelect.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + selectedAnime = testableAnime[position]; + updateSelectedAnimeDisplayViews(); + resetQueryResultViews(); + + // save selection in prefs + prefs.edit().putInt(KEY_LAST_ANIME_INDEX, position).apply(); + } + + @Override + public void onNothingSelected(AdapterView parent) { + + } + }); + + // load index from prefs + int selIndex = prefs.getInt(KEY_LAST_ANIME_INDEX, 0); + animeSelect.setSelection(selIndex >= testableAnime.length ? 0 : selIndex); + } + + /** + * update the selected anime info views + */ + @SuppressLint("SetTextI18n") + private void updateSelectedAnimeDisplayViews() { + final TextView malID = findViewById(R.id.anime_mal_id); + final TextView title = findViewById(R.id.anime_en_title); + final TextView episode = findViewById(R.id.anime_episode); + + malID.setText("MAL ID\n" + selectedAnime.malID); + title.setText("Title (EN)\n" + selectedAnime.enTitle); + episode.setText("Episode\n" + selectedAnime.episode); + } + + /** + * setup the spinner for adapter selection + */ + private void populateAdapterSelection() { + // get a list of all testable adapter names + final List cas = contentAdapterManager.getAdapters(); + final ArrayList adapterNames = new ArrayList<>(); + for (ContentAdapterWrapper a : cas) { + adapterNames.add(a.getDisplayName()); + } + + // setup the spinner + final Spinner adapterSelect = findViewById(R.id.adapter_select_spinner); + final ArrayAdapter adapter = new ArrayAdapter<>(this, R.layout.spinner_generic_text, adapterNames); + adapterSelect.setAdapter(adapter); + adapterSelect.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + selectedAdapter = cas.get(position).getUniqueName(); + updateAdapterMetaViews(); + resetQueryResultViews(); + + // save selection in prefs + prefs.edit().putInt(KEY_LAST_ADAPTER_INDEX, position).apply(); + } + + @Override + public void onNothingSelected(AdapterView parent) { + + } + }); + + // load index from prefs + int selIndex = prefs.getInt(KEY_LAST_ADAPTER_INDEX, 0); + adapterSelect.setSelection(selIndex >= cas.size() ? 0 : selIndex); + } + + /** + * update the adapter metadata views + */ + private void updateAdapterMetaViews() { + // get adapter + final ContentAdapterWrapper ca = contentAdapterManager.getAdapterOrDefault(selectedAdapter); + + // update views + setMetadataViews(ca.getUniqueName(), ca.getDisplayName(), str(ca.getApiVersion())); + } + + /** + * reset the query result views to a default value + */ + private void resetQueryResultViews() { + setQueryResultViews("", ""); + } + + /** + * set the values of the query result views + * + * @param streamUrl the resulting stream url + * @param perStorage the persistent storage value + */ + private void setQueryResultViews(@NonNull String streamUrl, @NonNull String perStorage) { + final TextView url = findViewById(R.id.query_result_url); + final TextView ps = findViewById(R.id.query_result_perStorage); + + url.setText(streamUrl); + ps.setText(perStorage); + } + + /** + * set the text of the metadata details views + * + * @param uniqueName the unique name value + * @param displayName the display name value + * @param apiVer the api version + */ + private void setMetadataViews(@NonNull String uniqueName, @NonNull String displayName, @NonNull String apiVer) { + final TextView un = findViewById(R.id.meta_unique_name); + final TextView dn = findViewById(R.id.meta_display_name); + final TextView api = findViewById(R.id.meta_api_ver); + + un.setText(uniqueName); + dn.setText(displayName); + api.setText(apiVer); + } + + /** + * update the buttons. if peristent storage is null or emtpy, the button is disabled + * + * @param persStorage persistent storage value + */ + private void updateQueryButtons(@Nullable String persStorage) { + // find buttons + final Button queryNoStorage = findViewById(R.id.query_button_no_storage); + final Button queryWithStorage = findViewById(R.id.query_button_with_storage); + + // check if storage is empty + final boolean hasPersistentStorage = persStorage != null && !persStorage.trim().isEmpty(); + + // set onclick without storage + queryNoStorage.setOnClickListener(v -> { + persistentStorageValue = ""; + testCurrentContentAdapter(); + }); + + // set onclick with storage if we have storage + // else disable the button + if (hasPersistentStorage) { + queryWithStorage.setOnClickListener(v -> testCurrentContentAdapter()); + queryWithStorage.setEnabled(true); + } else + queryWithStorage.setEnabled(false); + } + + /** + * test the current content adapter + */ + private void testCurrentContentAdapter() { + // clear result views + resetQueryResultViews(); + + // get wrapper and bind + final ContentAdapterWrapper ca = contentAdapterManager.getAdapter(selectedAdapter); + ca.bind(this); + ca.requestStreamUri(selectedAnime.malID, selectedAnime.enTitle, "", selectedAnime.episode, url -> { + // show results + setQueryResultViews(url, persistentStorageValue); + + // unbind + ca.unbind(this); + }); + } + + /** + * basic anime infos, for testing + */ + private static class Anime { + public final int malID; + public final String enTitle; + public final int episode; + + private Anime(int malID, String enTitle, int episode) { + this.malID = malID; + this.enTitle = enTitle; + this.episode = episode; + } + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_test.xml b/app/src/debug/res/layout/activity_test.xml similarity index 52% rename from app/src/main/res/layout/activity_test.xml rename to app/src/debug/res/layout/activity_test.xml index 1a87ce6..8fe7362 100644 --- a/app/src/main/res/layout/activity_test.xml +++ b/app/src/debug/res/layout/activity_test.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" + android:id="@+id/test_root_view" tools:context=".TestActivity" tools:ignore="HardcodedText"> @@ -12,24 +13,97 @@ android:layout_margin="10dp" android:orientation="vertical"> + + android:text="Adapter for Testing:"/> - + tools:listitem="@layout/spinner_generic_text"/> - + + + + + + + + + + + + + + + + + + + + + + android:background="?android:attr/listDivider" /> + + + + + + + android:text="MAL_ID\n4578" /> + android:text="TITLE_EN\nSome Title" /> + android:text="EPISODE\n2" /> @@ -74,13 +148,29 @@ android:layout_marginBottom="10dp" android:background="?android:attr/listDivider" /> - - + + android:layout_height="wrap_content"> + +