├── .gitignore ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro ├── schemas │ └── com.kamwithk.ankiconnectandroid.routing.database.EntriesDatabase │ │ └── 1.json └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── kamwithk │ │ └── ankiconnectandroid │ │ └── ExampleInstrumentedTest.java │ ├── main │ ├── AndroidManifest.xml │ ├── app_launcher-playstore.png │ ├── java │ │ └── com │ │ │ └── kamwithk │ │ │ └── ankiconnectandroid │ │ │ ├── MainActivity.java │ │ │ ├── Scraper.java │ │ │ ├── Service.java │ │ │ ├── SettingsActivity.java │ │ │ ├── ankidroid_api │ │ │ ├── BinaryFile.java │ │ │ ├── DeckAPI.java │ │ │ ├── IntegratedAPI.java │ │ │ ├── MediaAPI.java │ │ │ ├── ModelAPI.java │ │ │ ├── NoteAPI.java │ │ │ └── Utility.java │ │ │ ├── request_parsers │ │ │ ├── MediaRequest.java │ │ │ ├── NoteRequest.java │ │ │ └── Parser.java │ │ │ └── routing │ │ │ ├── APIHandler.java │ │ │ ├── AnkiAPIRouting.java │ │ │ ├── ForvoAPIRouting.java │ │ │ ├── LocalAudioAPIRouting.java │ │ │ ├── LocalAudioRouteHandler.java │ │ │ ├── RouteHandler.java │ │ │ ├── Router.java │ │ │ ├── database │ │ │ ├── AudioFileEntry.java │ │ │ ├── AudioFileEntryDao.java │ │ │ ├── EntriesDatabase.java │ │ │ ├── Entry.java │ │ │ └── EntryDao.java │ │ │ └── localaudiosource │ │ │ ├── ForvoAudioSource.java │ │ │ ├── JPodAltAudioSource.java │ │ │ ├── JPodAudioSource.java │ │ │ ├── LocalAudioSource.java │ │ │ ├── NHK16AudioSource.java │ │ │ └── Shinmeikai8AudioSource.java │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ ├── app_launcher_foreground.xml │ │ ├── ic_baseline_settings_24.xml │ │ └── ic_launcher_background.xml │ │ ├── layout │ │ ├── activity_main.xml │ │ └── settings_activity.xml │ │ ├── menu │ │ └── toolbar_menu.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── app_launcher.xml │ │ ├── app_launcher_round.xml │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── app_launcher.png │ │ ├── app_launcher_round.png │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-mdpi │ │ ├── app_launcher.png │ │ ├── app_launcher_round.png │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xhdpi │ │ ├── app_launcher.png │ │ ├── app_launcher_round.png │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxhdpi │ │ ├── app_launcher.png │ │ ├── app_launcher_round.png │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxxhdpi │ │ ├── app_launcher.png │ │ ├── app_launcher_round.png │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── values-night │ │ └── themes.xml │ │ ├── values │ │ ├── app_launcher_background.xml │ │ ├── arrays.xml │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ ├── file_provider_paths.xml │ │ └── root_preferences.xml │ └── test │ └── java │ └── com │ └── kamwithk │ └── ankiconnectandroid │ └── ExampleUnitTest.java ├── build.gradle ├── docs └── api.md ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── img ├── gen_android_db.png ├── scanning_inputs.png └── scanning_inputs_yomitan.png └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | .idea/ 5 | /.idea/caches 6 | /.idea/libraries 7 | /.idea/modules.xml 8 | /.idea/workspace.xml 9 | /.idea/navEditor.xml 10 | /.idea/assetWizardSettings.xml 11 | .DS_Store 12 | /build 13 | /captures 14 | .externalNativeBuild 15 | .cxx 16 | local.properties 17 | *.log 18 | *.apk 19 | output.json 20 | misc.xml 21 | deploymentTargetDropDown.xml 22 | render.experimental.xml 23 | *.jks 24 | *.keystore 25 | *.hprof 26 | app/src/main/jniLibs/ 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ankiconnect Android 2 | 3 | Ankiconnect Android allows you to utilize the standard Anki mining workflow on Android devices like phones and eReaders. 4 | Create Anki cards using [Yomitan](https://yomitan.wiki/) on [Firefox Browser](https://play.google.com/store/apps/details?id=org.mozilla.firefox) and add them straight into your Anki deck! 5 | Mine on the go in the same way as you mine on your desktop pc. 6 | Forvo and local audio are now supported! 7 | 8 | 9 | Ankiconnect Android is a from scratch unofficial reimplementation of the [desktop Ankiconnect extension](https://github.com/FooSoft/anki-connect), [desktop Forvo Server extension](https://github.com/jamesnicolas/yomichan-forvo-server) and [desktop Local Audio Server for Yomitan](https://github.com/themoeway/local-audio-yomichan). 10 | It reimplements the core APIs used by Yomitan to work with [Ankidroid](https://github.com/ankidroid/Anki-Android/). 11 | 12 | ## Table of Contents 13 | - [Ankiconnect Android](#ankiconnect-android) 14 | - [Table of Contents](#table-of-contents) 15 | - [Instructions](#instructions) 16 | - [Additional Instructions: Forvo Audio](#additional-instructions-forvo-audio) 17 | - [Additional Instructions: Show Card Button](#additional-instructions-show-card-button) 18 | - [Additional Instructions: Local Audio](#additional-instructions-local-audio) 19 | - [Common Errors and Solutions](#common-errors-and-solutions) 20 | - [First Steps](#first-steps) 21 | - [Problem: The Yomitan popup appears upon scrolling](#problem-the-yomitan-popup-appears-upon-scrolling) 22 | - [Problem: The add button is always greyed out](#problem-the-add-button-is-always-greyed-out) 23 | - [Problem: The add card button does not appear](#problem-the-add-card-button-does-not-appear) 24 | - [Problem: Duplicate checks aren't working](#problem-duplicate-checks-arent-working) 25 | - [Problem: Forvo audio won't load](#problem-forvo-audio-wont-load) 26 | - [Problem: On card add, I get `Incorrect flds argument`](#problem-on-card-add-i-get-incorrect-flds-argument) 27 | - [I still have a problem](#i-still-have-a-problem) 28 | - [Limitations](#limitations) 29 | - [Developer Info](#developer-info) 30 | - [Contributing](#contributing) 31 | 32 | ## Instructions 33 | Here's how to set everything up from scratch (if you've already got Yomitan working, then skip to step 5): 34 | 35 | 1. Install [Firefox Browser](https://play.google.com/store/apps/details?id=org.mozilla.firefox) 36 | 2. Install [Ankidroid](https://play.google.com/store/apps/details?id=com.ichi2.anki) 37 | 3. Install Ankiconnect Android - Download from the [Releases Section](https://github.com/KamWithK/AnkiconnectAndroid/releases/latest) or from [IzzyOnDroid repo](https://apt.izzysoft.de/fdroid/index/apk/com.kamwithk.ankiconnectandroid) 38 | 4. Start the Ankiconnect Android app, accept the permissions and hit start service 39 | 5. Install the [Yomitan extension](https://addons.mozilla.org/en-US/firefox/addon/yomitan/) in Firefox Browser 40 | 6. Configure Yomitan general settings 41 | 1. Ensure advanced settings is enabled (button at the bottom right corner) 42 | 2. Dictionaries: `Import` 1+ dictionaries by clicking `Configure installed and enabled dictionaries` and then `Import` under `Dictionaries` section ([external resources](https://learnjapanese.moe/resources/#dictionaries)) 43 | 3. General: It is recommended to lower the value of `Maximum number of results` to prevent unnecessary lag. A sane value would be `8` 44 | 7. Configure Yomitan Scanning settings 45 | 1. Scanning: `Scan delay` can feel laggy and so can be set to `0` 46 | 2. Scanning: Navigate to `Scanning` → `Configure advanced scanning inputs` 47 | 1. Ensure that advanced options within the `Scanning Inputs` window is enabled. To do this, scroll to the right, and tap on the three dots. 48 | 2.
For Yomitan, use the defaults or match your settings to be like this image: (click here) 49 | 50 |
51 | 3. Navigate to `Scanning` → `Support inputs for devices with touch screens` 52 | * Ensure that `Touch inputs` is checked, and `Pointer inputs` is NOT checked. 53 | 3. Scanning: If you are studying Japanese/Chinese and don't want to popup to appear on any other languages disable `Search text with non-Japanese, Chinese, or Cantonese characters`. 54 | 8. Configure Yomitan Anki settings 55 | 1. Anki: Toggle `Enable Anki integration` on 56 | 2. Anki: Click on `Configure Anki card format` and choose the deck, model and field/values you desire. *(For further customization you can write/modify the script under `Configure Anki card templates`)* 57 | 3. Anki: Ensure `Show Card Tags` is set to "Never" 58 | 59 | > Easier Yomitan setup: If you import settings from a computer, ensure that step 7 and step 8.3 are still followed 60 | 61 | ### Additional Instructions: Forvo Audio 62 | The default audio sources from Yomitan should already work. 63 | However, Forvo can be added as an audio source. 64 | It is recommended that you add this Forvo audio source 65 | because Forvo significantly extends the coverage of audio 66 | compared to the default audio sources from Yomitan. 67 | 68 | 1. Click on `Configure audio playback sources` and under the `Audio` section 69 | 2. Click the `Add` button (top right corner) 70 | 3. **Select `Custom URL (JSON)` and copy paste the following into the `URL` box**: 71 | 72 | ``` 73 | http://localhost:8765/?term={term}&reading={reading} 74 | ``` 75 | 76 | NOTE: This is NOT the same URL as from your PC, the port is different 77 | 78 | 79 | 80 | ### Additional Instructions: Show Card Button 81 | By default, the show card button **will not work**. 82 | The following is a *completely optional* set of instructions for getting the show card button to work: 83 | 84 | 1. Install a **pre-release (alpha) version** of AnkiDroid from their [releases page](https://github.com/ankidroid/Anki-Android/releases). This cannot be a parallel build. 85 | * As of writing this (2023/01/03), the stable release of AnkiDroid 86 | (i.e. the released version on Google Play) does not support the feature of 87 | [opening the card browser window](https://github.com/ankidroid/Anki-Android/pull/11899) 88 | from another app. Therefore, the first step is to manually download and install 89 | the most recent alpha version of AnkiDroid. 90 | * This carries all the risks of using a pre-release version! Only download it 91 | if you know what you are doing. 92 | 93 | 2. After installing AnkiDroid, you must allow Ankiconnect Android to open apps in the background. 94 | * Under Ankiconnect Android, tap on the settings gear at the top right corner. 95 | * Tap on the `Access Overlay Permissions` option. This should lead you to Android's settings page. 96 | * Activate the switch for `Ankiconnect Android` within this settings page. 97 | 98 | From here, you should be able to use the show card button as normal. 99 | 100 | > **Warning**: 101 | > Make sure you save your note changes if you edit your note! If you do not 102 | > save your changes and re-click on the "show card" button from Firefox Browser, you will 103 | > lose all your current note changes! 104 | 105 | ### Additional Instructions: Local Audio 106 | The [(desktop) local audio server](https://github.com/themoeway/local-audio-yomichan) 107 | setup for Yomitan has been ported over to Ankiconnect Android, and can be used similarly. 108 | Again, this is *a completely optional* setup that does not need to be done. 109 | 110 | General information about the setup, including reasons for and against using the setup, 111 | can be found within the above link. 112 | 113 | > **Warning**: 114 | > This setup takes up about 5gb of space on your Android device! Ensure you have enough space before setting this up. 115 | 116 | 1. Ensure you have set up the latest version of the [desktop local audio server](https://github.com/themoeway/local-audio-yomichan) setup. 117 | If you already have the add-on installed, check for updates by navigating to `Tools` → `Add-ons` → (select "Local Audio Server for Yomitan") → `Check for Updates`. 118 | 119 | 2. Generate the Android database. 120 | 121 | > **Note**: 122 | > For Windows users, please temporarily disable Windows defender before doing this step. 123 | > It seems like generating the Android database with Windows Defender enabled can slow it 124 | > down by a factor of 6, meaning a step that should only take 5 minutes 125 | > can easily take over half an hour. 126 | 127 | To generate the database, navigate to (Anki) → `Tools` (top left corner) → `Local Audio Server` → `Generate Android database`. Expect this to take a while (at least a few minutes). 128 | 129 |
Example Image (click here) 130 | 131 | ![generate android database](./img/gen_android_db.png) 132 | 133 |
134 | 135 | This database stores all the audio files into one large file, in order to make file transfer to Android much faster (transferring the folder took about 24 hours, while transferring the large file took less than 3 minutes). 136 | 137 | 3. Copy the files from desktop to Android. 138 | * Locate the add-on folder on desktop. 139 | To do this, navigate to `Tools` → `Add-ons` → (select "Local Audio Server for Yomitan") → `View Files`. 140 | When you are here, navigate to `user_files`. 141 | 142 | * Locate AnkiConnect Android's data folder. By default, it is under: 143 | ``` 144 | (phone)/Android/data/com.kamwithk.ankiconnectandroid/files/ 145 | ``` 146 | However, one can verify the location of the folder by going into the settings 147 | (gear at the top right corner), and tapping on `Print Local Audio Directory`. 148 | The following output specifies that the folder is indeed in the default position: 149 | ``` 150 | /storage/emulated/0/Android/data/com.kamwithk.ankiconnectandroid/files/ 151 | ``` 152 | 153 | * After locating the two folders, copy `android.db` from the desktop's add-on folder 154 | into Ankiconnect Android's data folder. 155 | * If you have a previous `android.db`, please delete this file and any related files (i.e. delete `android.db-shm` and `android.db-wa`) 156 | * Ensure AnkiConnectAndroid is fully closed before copying the database, because 157 | AnkiConnectAndroid may override the new database if it is open. 158 | If you want to be 100% sure that the app is closed, you can restart your device. 159 | * Do NOT copy the entire `user_files` folder. 160 | * After copying the file, this should result in the following: 161 | ``` 162 | /storage/emulated/0/Android/data/com.kamwithk.ankiconnectandroid/files/android.db 163 | ``` 164 | 165 | 4. Setup local audio on Firefox Browser's Yomitan. (Warning: this URL is different than the one on desktop!) 166 | * Click on `Configure audio playback sources` and under the `Audio` section 167 | * Click the `Add` button (top right corner) 168 | * Select `Custom URL (JSON)` and copy paste the following into the `url` box (tap the code box, and then tap the button to the right to copy the text to the clipboard): 169 | ``` 170 | http://localhost:8765/localaudio/get/?term={term}&reading={reading} 171 | ``` 172 | 173 | * The `sources` and `user` parameters should behave exactly like the desktop local audio plugin. 174 | 175 |
176 | Example, with sources in the default order (click here) 177 | 178 | ``` 179 | http://localhost:8765/localaudio/get/?term={term}&reading={reading}&sources=nhk16,shinmeikai8,forvo,jpod,jpod_alternate 180 | ``` 181 | 182 |
183 | 184 | 185 | 5. Ensure it works. 186 | * You can do the 187 | [exact same check](https://github.com/themoeway/local-audio-yomichan#steps) 188 | as the desktop local audio server (the last step), 189 | by scanning 読む and checking that all sources appear. 190 | Be sure to play all of the sources to ensure that the audio is properly fetched. 191 | 192 | 193 | ## Common Errors and Solutions 194 | 195 | ### First Steps 196 | If you are having issues with anything, such as Yomitan being unable to connect to AnkiDroid, please ensure all these steps are followed before continuing: 197 | 198 | * Make sure the latest [app release](https://github.com/KamWithK/AnkiconnectAndroid/releases/latest) is installed. 199 | * If you imported the settings from the PC, try to use the sanitized version upon import, and manually re-add the handlebars after. 200 | * Double check that your Yomitan settings are correct. In particular, check that the `Configure Anki card format...` section, and the audio sources section is correct. 201 | * On rare occasions, settings exported from the computer and imported into your Android device may not work. Instead, try to reset Yomitan's settings and redo everything from scratch. 202 | * Battery saving/automatic optimisation is turned off for Ankidroid, Ankiconnect Android and optionally (but recommended) Firefox browser. 203 | * You allowed Ankiconnect Android to be running in the background (if this option is available on your device). 204 | 205 | 206 | ### Problem: The Yomitan popup appears upon scrolling 207 | Try going through step 5 of the [instructions](#instructions). 208 | In particular, see the step that says 209 | **Ensure `Scanning Inputs` is optimized for mobile (prevents lookups on scrolling)**. 210 | 211 | 212 | ### Problem: The add button is always greyed out 213 | This usually happens if `Enable Content Scanning` is switched off. 214 | To fix this, simply switch it on (under `Yomitan Settings` → `General` → `Enable content scanning`). 215 | 216 | > **Note**: 217 | > If you have this switched off in the first place, it is also very likely that the popup is 218 | > showing up at unwanted times, i.e. while scrolling through a page. 219 | > To solve this, try going through step 5 of the [instructions](#instructions). 220 | 221 | 222 | ### Problem: The add card button does not appear 223 | - Check that the `Enable Anki integration` setting in Yomitan is indeed enabled, and properly connected. 224 | - Under `Anki` → `Configure Anki card format`, ensure that the Deck and Model at the top right corner 225 | are not highlighted in red. If they are, please select the correct deck and/or model. 226 | - Under `Anki` → `Show card tags`, make sure this is set to `Never`. 227 | 228 | 229 | ### Problem: Duplicate checks aren't working 230 | To determine that duplicate checks aren't working: 231 | - Enable duplicate checks in the Yomitan settings (under `Anki` → `Check for card duplicates`), 232 | - Select a word and add a card 233 | - Tap outside of the popup, and re-select the word. Normally, you should not be able to add a card here. 234 | 235 | If you are able to add a card (i.e. you see the plus button), then duplicate checks are indeed not working. 236 | Check that your first field name does not include spaces, 237 | and your first field contents do not include quotes (`"`) or spaces. 238 | If either of those are true, the only way to solve it is by using the Alpha version of AnkiDroid, and 239 | [enable the new backend](https://github.com/ankidroid/Anki-Android/issues/13399) 240 | under the advanced settings. 241 | 242 | 243 | ### Problem: Forvo audio won't load 244 | Please make sure that this exact URL under 245 | [Additional Instructions: Forvo Audio](#additional-instructions-forvo-audio) is used. 246 | This URL is different from the one on PC. 247 | 248 | > **Note**: 249 | > There is always a chance that Forvo has changed the layout of their website, 250 | > which can lead to AnkiConnect Android improperly fetching the audio. 251 | > If you suspect this is the case, please create an issue on Github. 252 | 253 | 254 | ### Problem: On card add, I get `Incorrect flds argument` 255 | This happens when you change the fields of a card. For example, if you added a field, 256 | renamed a field, or deleted a field, then this error may pop up. 257 | To fix it, navigate to `Yomitan Settings` → `Anki` → `Configure Anki card format...`, 258 | and update the model fields (i.e. by switching it to a different model and back). 259 | 260 | 261 | ### I still have a problem 262 | If you've gone through the instructions and are still having trouble, feel free to create an issue here on GitHub or @/dm me on Discord (`@KamWithK#0634` on [TheMoeWay](https://learnjapanese.moe/join/)). Most related discussions happen in [the AnkiConnect Android thread](https://discord.com/channels/617136488840429598/1060781077955887195). 263 | 264 | 265 | 266 | ## Limitations 267 | Because Ankiconnect Android is a small project with a limited scope, not all API queries/cases are implemented/considered. 268 | Currently every known essential feature has been added into the app, however some niche edge cases have been ignored. 269 | 270 | Some examples: 271 | 1. Duplicate checks always occur across the full Anki collection instead of whatever deck is selected (no matter what options are selected, assuming this feature is left enabled) 272 | 2. The show card button will not work on the latest stable release of AnkiDroid. Instead, you must **manually install a pre-release version of AnkiDroid** for it to work. Please see [these instructions](#additional-instructions-show-card-button) for more details on how to make the show card button work. 273 | 3. When viewing the note, the note cannot be viewed directly within card editor. Instead, the note is shown from the card search screen. 274 | 4. You are unable to view the note tags on duplicate notes. 275 | 276 | 277 | ## Developer Info 278 | For developers who are interested in using the API, please see [docs/api.md](./docs/api.md) for a list of all supported API calls. 279 | 280 | ## Contributing 281 | The primary goal of Ankiconnect Android was to support card creation with Yomitan. 282 | Therefore, many API calls are not implemented. 283 | In the spirit of 284 | [Anki-Connect itself](https://github.com/FooSoft/anki-connect#hey-could-you-add-a-new-action-to-support-feature), 285 | this *project operates on a self-serve model*. 286 | Feature requests will not be serviced. 287 | 288 | If you would like a new API call, make a PR with the following criteria: 289 | * Ensure that your API call matches an [existing Anki-Connect action](https://github.com/FooSoft/anki-connect#supported-actions). New actions, or outward modifications to existing actions (i.e. new input parameters) will not be accepted. 290 | * Note: your API call does not have to have the full capability that Anki-Connect provides, so long as 291 | it is documented (see the second point below). 292 | * Add the relevant documentation to [docs/api.md](./docs/api.md), including any edge cases and 293 | important implementation details. 294 | * Attempt to match the code style of the project. 295 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | } 4 | 5 | android { 6 | compileSdk 33 7 | 8 | defaultConfig { 9 | applicationId "com.kamwithk.ankiconnectandroid" 10 | minSdk 26 11 | targetSdk 33 12 | versionCode 13 13 | versionName "1.13" 14 | 15 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 16 | 17 | javaCompileOptions { 18 | annotationProcessorOptions { 19 | arguments += ["room.schemaLocation": "$projectDir/schemas".toString()] 20 | } 21 | } 22 | 23 | } 24 | 25 | buildTypes { 26 | release { 27 | minifyEnabled false 28 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 29 | } 30 | } 31 | 32 | dependenciesInfo { 33 | // Disables dependency metadata when building APKs. 34 | includeInApk = false 35 | // Disables dependency metadata when building Android App Bundles. 36 | includeInBundle = false 37 | } 38 | 39 | compileOptions { 40 | sourceCompatibility JavaVersion.VERSION_1_9 41 | targetCompatibility JavaVersion.VERSION_1_9 42 | } 43 | } 44 | 45 | dependencies { 46 | 47 | implementation 'androidx.appcompat:appcompat:1.6.1' 48 | implementation 'androidx.core:core:1.9.0' 49 | implementation 'com.google.android.material:material:1.8.0' 50 | implementation 'androidx.constraintlayout:constraintlayout:2.1.4' 51 | implementation 'com.google.code.gson:gson:2.9.0' 52 | implementation 'org.nanohttpd:nanohttpd:2.3.1' 53 | implementation 'org.nanohttpd:nanohttpd-nanolets:2.3.1' 54 | implementation 'com.github.ankidroid:Anki-Android:2.17alpha14' 55 | implementation 'org.jsoup:jsoup:1.14.3' 56 | implementation 'androidx.preference:preference:1.2.0' 57 | 58 | def room_version = "2.5.0" 59 | implementation "androidx.room:room-runtime:$room_version" 60 | annotationProcessor "androidx.room:room-compiler:$room_version" 61 | 62 | 63 | testImplementation 'junit:junit:4.13.2' 64 | androidTestImplementation 'androidx.test.ext:junit:1.1.5' 65 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' 66 | } 67 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/schemas/com.kamwithk.ankiconnectandroid.routing.database.EntriesDatabase/1.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 1, 5 | "identityHash": "5d87bb1994094115b2328fad2f85b427", 6 | "entities": [ 7 | { 8 | "tableName": "entries", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `expression` TEXT NOT NULL, `reading` TEXT, `source` TEXT NOT NULL, `speaker` TEXT, `display` TEXT, `file` TEXT NOT NULL, PRIMARY KEY(`id`))", 10 | "fields": [ 11 | { 12 | "fieldPath": "id", 13 | "columnName": "id", 14 | "affinity": "INTEGER", 15 | "notNull": true 16 | }, 17 | { 18 | "fieldPath": "expression", 19 | "columnName": "expression", 20 | "affinity": "TEXT", 21 | "notNull": true 22 | }, 23 | { 24 | "fieldPath": "reading", 25 | "columnName": "reading", 26 | "affinity": "TEXT", 27 | "notNull": false 28 | }, 29 | { 30 | "fieldPath": "source", 31 | "columnName": "source", 32 | "affinity": "TEXT", 33 | "notNull": true 34 | }, 35 | { 36 | "fieldPath": "speaker", 37 | "columnName": "speaker", 38 | "affinity": "TEXT", 39 | "notNull": false 40 | }, 41 | { 42 | "fieldPath": "display", 43 | "columnName": "display", 44 | "affinity": "TEXT", 45 | "notNull": false 46 | }, 47 | { 48 | "fieldPath": "file", 49 | "columnName": "file", 50 | "affinity": "TEXT", 51 | "notNull": true 52 | } 53 | ], 54 | "primaryKey": { 55 | "autoGenerate": false, 56 | "columnNames": [ 57 | "id" 58 | ] 59 | }, 60 | "indices": [ 61 | { 62 | "name": "idx_all", 63 | "unique": false, 64 | "columnNames": [ 65 | "expression", 66 | "reading", 67 | "source" 68 | ], 69 | "orders": [], 70 | "createSql": "CREATE INDEX IF NOT EXISTS `idx_all` ON `${TABLE_NAME}` (`expression`, `reading`, `source`)" 71 | }, 72 | { 73 | "name": "idx_reading_speaker", 74 | "unique": false, 75 | "columnNames": [ 76 | "expression", 77 | "reading", 78 | "speaker" 79 | ], 80 | "orders": [], 81 | "createSql": "CREATE INDEX IF NOT EXISTS `idx_reading_speaker` ON `${TABLE_NAME}` (`expression`, `reading`, `speaker`)" 82 | }, 83 | { 84 | "name": "idx_expr_reading", 85 | "unique": false, 86 | "columnNames": [ 87 | "expression", 88 | "reading" 89 | ], 90 | "orders": [], 91 | "createSql": "CREATE INDEX IF NOT EXISTS `idx_expr_reading` ON `${TABLE_NAME}` (`expression`, `reading`)" 92 | }, 93 | { 94 | "name": "idx_speaker", 95 | "unique": false, 96 | "columnNames": [ 97 | "speaker" 98 | ], 99 | "orders": [], 100 | "createSql": "CREATE INDEX IF NOT EXISTS `idx_speaker` ON `${TABLE_NAME}` (`speaker`)" 101 | }, 102 | { 103 | "name": "idx_reading", 104 | "unique": false, 105 | "columnNames": [ 106 | "reading" 107 | ], 108 | "orders": [], 109 | "createSql": "CREATE INDEX IF NOT EXISTS `idx_reading` ON `${TABLE_NAME}` (`reading`)" 110 | } 111 | ], 112 | "foreignKeys": [] 113 | }, 114 | { 115 | "tableName": "android", 116 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `file` TEXT NOT NULL, `source` TEXT NOT NULL, `data` BLOB NOT NULL, PRIMARY KEY(`id`))", 117 | "fields": [ 118 | { 119 | "fieldPath": "id", 120 | "columnName": "id", 121 | "affinity": "INTEGER", 122 | "notNull": true 123 | }, 124 | { 125 | "fieldPath": "file", 126 | "columnName": "file", 127 | "affinity": "TEXT", 128 | "notNull": true 129 | }, 130 | { 131 | "fieldPath": "source", 132 | "columnName": "source", 133 | "affinity": "TEXT", 134 | "notNull": true 135 | }, 136 | { 137 | "fieldPath": "data", 138 | "columnName": "data", 139 | "affinity": "BLOB", 140 | "notNull": true 141 | } 142 | ], 143 | "primaryKey": { 144 | "autoGenerate": false, 145 | "columnNames": [ 146 | "id" 147 | ] 148 | }, 149 | "indices": [ 150 | { 151 | "name": "idx_android", 152 | "unique": false, 153 | "columnNames": [ 154 | "file", 155 | "source" 156 | ], 157 | "orders": [], 158 | "createSql": "CREATE INDEX IF NOT EXISTS `idx_android` ON `${TABLE_NAME}` (`file`, `source`)" 159 | } 160 | ], 161 | "foreignKeys": [] 162 | } 163 | ], 164 | "views": [], 165 | "setupQueries": [ 166 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", 167 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5d87bb1994094115b2328fad2f85b427')" 168 | ] 169 | } 170 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/kamwithk/ankiconnectandroid/ExampleInstrumentedTest.java: -------------------------------------------------------------------------------- 1 | package com.kamwithk.ankiconnectandroid; 2 | 3 | import android.content.Context; 4 | import androidx.test.platform.app.InstrumentationRegistry; 5 | import androidx.test.ext.junit.runners.AndroidJUnit4; 6 | 7 | import org.junit.Test; 8 | import org.junit.runner.RunWith; 9 | 10 | import static org.junit.Assert.*; 11 | 12 | /** 13 | * Instrumented test, which will execute on an Android device. 14 | * 15 | * @see Testing documentation 16 | */ 17 | @RunWith(AndroidJUnit4.class) 18 | public class ExampleInstrumentedTest { 19 | @Test 20 | public void useAppContext() { 21 | // Context of the app under test. 22 | Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); 23 | assertEquals("com.kamwithk.ankiconnectandroid", appContext.getPackageName()); 24 | } 25 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 29 | 34 | 37 | 38 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 53 | 56 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /app/src/main/app_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KamWithK/AnkiconnectAndroid/8ad56082b262701f56a9138e743ba86aff4b4416/app/src/main/app_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/java/com/kamwithk/ankiconnectandroid/MainActivity.java: -------------------------------------------------------------------------------- 1 | package com.kamwithk.ankiconnectandroid; 2 | 3 | import static android.Manifest.permission.POST_NOTIFICATIONS; 4 | 5 | import android.app.Dialog; 6 | import android.app.NotificationChannel; 7 | import android.app.NotificationManager; 8 | import android.content.DialogInterface; 9 | import android.content.Intent; 10 | import android.os.Build; 11 | import android.os.Bundle; 12 | import android.view.Menu; 13 | import android.view.MenuItem; 14 | import android.view.View; 15 | import android.widget.Toast; 16 | import androidx.appcompat.app.AppCompatActivity; 17 | 18 | import androidx.activity.result.ActivityResultLauncher; 19 | import androidx.activity.result.contract.ActivityResultContracts; 20 | import androidx.annotation.NonNull; 21 | import androidx.appcompat.app.AlertDialog; 22 | import androidx.appcompat.widget.Toolbar; 23 | import androidx.core.content.ContextCompat; 24 | import androidx.fragment.app.DialogFragment; 25 | 26 | import com.kamwithk.ankiconnectandroid.ankidroid_api.IntegratedAPI; 27 | 28 | 29 | public class MainActivity extends AppCompatActivity { 30 | 31 | public static class NotificationsPermissionDialogFragment extends DialogFragment { 32 | @NonNull 33 | @Override 34 | public Dialog onCreateDialog(Bundle savedInstanceState) { 35 | // Use the Builder class for convenient dialog construction 36 | MainActivity activity = (MainActivity) getActivity(); 37 | AlertDialog.Builder builder = new AlertDialog.Builder(activity); 38 | builder.setMessage(R.string.dialog_notif_perm_info) 39 | .setPositiveButton("Ok", new DialogInterface.OnClickListener() { 40 | public void onClick(DialogInterface dialog, int id) { 41 | // ASSUMPTION: NotificationsPermissionDialogFragment is only created 42 | // on API level >= 33 43 | activity.requestPermissionLauncher.launch(POST_NOTIFICATIONS); 44 | } 45 | }) 46 | .setNegativeButton("Cancel", new DialogInterface.OnClickListener() { 47 | public void onClick(DialogInterface dialog, int id) { 48 | activity.startServiceWithoutNotifications(); 49 | } 50 | }); 51 | // Create the AlertDialog object and return it 52 | return builder.create(); 53 | } 54 | } 55 | 56 | public static final String CHANNEL_ID = "ankiConnectAndroid"; 57 | private NotificationManager notificationManager; 58 | private ActivityResultLauncher requestPermissionLauncher; 59 | 60 | @Override 61 | protected void onCreate(Bundle savedInstanceState) { 62 | super.onCreate(savedInstanceState); 63 | setContentView(R.layout.activity_main); 64 | 65 | // toolbar support 66 | Toolbar toolbar = findViewById(R.id.materialToolbar); 67 | setSupportActionBar(toolbar); 68 | 69 | IntegratedAPI.authenticate(this); 70 | 71 | NotificationChannel notificationChannel = new NotificationChannel(CHANNEL_ID, "Ankiconnect Android", NotificationManager.IMPORTANCE_DEFAULT); 72 | notificationManager = getSystemService(NotificationManager.class); 73 | notificationManager.createNotificationChannel(notificationChannel); 74 | 75 | // this cannot be put inside attemptGrantNotifyPermissions, because it is called by 76 | // a onClickListener and crashes the app: https://stackoverflow.com/a/67582633 77 | requestPermissionLauncher = 78 | registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> { 79 | if (!isGranted) { 80 | Toast.makeText(this, "Attempting to start server without notification...", Toast.LENGTH_LONG).show(); 81 | } 82 | startService(); 83 | }); 84 | } 85 | 86 | @Override 87 | public boolean onCreateOptionsMenu(Menu menu) { 88 | getMenuInflater().inflate(R.menu.toolbar_menu, menu); 89 | return true; 90 | } 91 | 92 | @Override 93 | public boolean onOptionsItemSelected(MenuItem item) { 94 | int id = item.getItemId(); 95 | if (id == R.id.action_settings) { 96 | // open settings 97 | Intent intent = new Intent(MainActivity.this, SettingsActivity.class); 98 | startActivity(intent); 99 | return true; 100 | } else { 101 | return super.onOptionsItemSelected(item); 102 | } 103 | } 104 | 105 | public void attemptGrantNotificationPermissions() { 106 | // Register the permissions callback, which handles the user's response to the 107 | // system permissions dialog. Save the return value, an instance of 108 | // ActivityResultLauncher, as an instance variable. 109 | 110 | if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { 111 | // there's nothing else we can do on older SDK versions 112 | startServiceWithoutNotifications(); 113 | return; 114 | } 115 | 116 | // NOTE: I can't find anything about this in the actual documentation, but an 117 | // explanation for shouldShowRequestPermissionRationale is shown below 118 | // (taken from: https://stackoverflow.com/a/39739972): 119 | // 120 | // This method returns true if the app has requested this permission previously and the 121 | // user denied the request. Note: If the user turned down the permission request in the 122 | // past and chose the Don't ask again option in the permission request system dialog, 123 | // this method returns false. 124 | if (shouldShowRequestPermissionRationale(POST_NOTIFICATIONS)) { 125 | // Explain that notifications are "needed" to display the server 126 | new NotificationsPermissionDialogFragment().show(this.getSupportFragmentManager(), "post_notifications_dialog"); 127 | } else { 128 | // Directly ask for the permission. 129 | requestPermissionLauncher.launch(POST_NOTIFICATIONS); 130 | } 131 | 132 | } 133 | 134 | public void startServiceWithoutNotifications() { 135 | Toast.makeText(this, "Attempting to start server without notification...", Toast.LENGTH_LONG).show(); 136 | startService(); 137 | } 138 | 139 | public void startService() { 140 | Intent serviceIntent = new Intent(this, Service.class); 141 | ContextCompat.startForegroundService(this, serviceIntent); 142 | } 143 | 144 | 145 | public void startServiceBtn(View view) { 146 | boolean notificationsEnabled = notificationManager.areNotificationsEnabled(); 147 | if (notificationsEnabled) { 148 | startService(); 149 | } else { 150 | attemptGrantNotificationPermissions(); 151 | } 152 | } 153 | 154 | public void stopServiceBtn(View view) { 155 | Intent serviceIntent = new Intent(this, Service.class); 156 | stopService(serviceIntent); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /app/src/main/java/com/kamwithk/ankiconnectandroid/Scraper.java: -------------------------------------------------------------------------------- 1 | package com.kamwithk.ankiconnectandroid; 2 | 3 | import android.content.Context; 4 | import android.content.SharedPreferences; 5 | import android.util.Base64; 6 | 7 | import androidx.preference.PreferenceManager; 8 | 9 | import org.jsoup.Jsoup; 10 | import org.jsoup.nodes.Document; 11 | import org.jsoup.nodes.Element; 12 | import org.jsoup.select.Elements; 13 | 14 | import java.io.IOException; 15 | import java.nio.charset.StandardCharsets; 16 | import java.util.ArrayList; 17 | import java.util.HashMap; 18 | import java.util.Objects; 19 | import java.util.regex.Matcher; 20 | import java.util.regex.Pattern; 21 | 22 | // Regular expressions and method source - https://github.com/jamesnicolas/yomichan-forvo-server 23 | public class Scraper { 24 | private final Context context; 25 | private final String SERVER_HOST = "https://forvo.com"; 26 | private final String AUDIO_HTTP_HOST = "https://audio00.forvo.com"; 27 | private final String DEFAULT_FORVO_LANGUAGE = "ja"; 28 | 29 | public Scraper(Context context) { 30 | this.context = context; 31 | } 32 | 33 | public ArrayList> scrape(String word, String reading) throws IOException { 34 | SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); 35 | String forvoLanguage = preferences.getString("forvo_language", DEFAULT_FORVO_LANGUAGE); 36 | 37 | ArrayList> audio_sources = scrapeWord(word, forvoLanguage); 38 | 39 | // Get similar words audio if exact word isn't found 40 | if (audio_sources.size() == 0) { 41 | audio_sources = scrapeWord(reading, forvoLanguage); 42 | } 43 | if (audio_sources.size() == 0) { 44 | audio_sources = scrapeSearch(word, forvoLanguage); 45 | } 46 | if (audio_sources.size() == 0) { 47 | audio_sources = scrapeSearch(reading, forvoLanguage); 48 | } 49 | 50 | return audio_sources; 51 | } 52 | 53 | private ArrayList> scrapeWord(String word, String language) throws IOException { 54 | Document document = Jsoup.connect(SERVER_HOST + "/word/" + strip(word) + "/").get(); 55 | Elements elements = document.select("#language-container-" + language + ">article>ul>li:not(.li-ad)"); 56 | 57 | ArrayList> audio_sources = new ArrayList<>(); 58 | 59 | for (Element element : elements) { 60 | //System.out.println(element); 61 | String url = extractURL(Objects.requireNonNull(element.selectFirst(".play"))); 62 | 63 | HashMap user_details = new HashMap<>(); 64 | user_details.put("name", "Forvo (" + extractUsername(element.text()) + ")"); 65 | user_details.put("url", url); 66 | audio_sources.add(user_details); 67 | } 68 | 69 | return audio_sources; 70 | } 71 | 72 | private ArrayList> scrapeSearch(String input, String language) throws IOException { 73 | Document document = Jsoup.connect(SERVER_HOST + "/search/" + strip(input) + "/" + language + "/").get(); 74 | Elements elements = document.select("ul.word-play-list-icon-size-l>li>.play"); 75 | 76 | ArrayList> audio_sources = new ArrayList<>(); 77 | 78 | for (Element element : elements) { 79 | HashMap user_details = new HashMap<>(); 80 | user_details.put("name", "Forvo Search"); 81 | user_details.put("url", extractURL(element)); 82 | audio_sources.add(user_details); 83 | } 84 | 85 | return audio_sources; 86 | } 87 | 88 | // Helper method to get rid of leading/trailing spaces 89 | private String strip(String input) { 90 | return input.replaceAll("^[ \t]+|[ \t]+$", ""); 91 | } 92 | 93 | @SuppressWarnings("ResultOfMethodCallIgnored") 94 | private String extractURL(Element span) { 95 | String play = span.attr("onclick"); 96 | 97 | Pattern pattern = Pattern.compile("([^',\\(\\)]+)"); 98 | Matcher m = pattern.matcher(play); 99 | 100 | // Go to third occurrence 101 | m.find(); 102 | m.find(); 103 | m.find(); 104 | 105 | String file = new String(Base64.decode(m.group(), Base64.DEFAULT), StandardCharsets.UTF_8); 106 | return AUDIO_HTTP_HOST + "/mp3/" + file; 107 | } 108 | 109 | @SuppressWarnings("ResultOfMethodCallIgnored") 110 | private String extractUsername(String text) { 111 | Pattern pattern = Pattern.compile("Pronunciation by([^(]+)\\("); 112 | Matcher matcher = pattern.matcher(strip(text)); 113 | matcher.find(); 114 | 115 | return strip(Objects.requireNonNull(matcher.group(1))); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /app/src/main/java/com/kamwithk/ankiconnectandroid/Service.java: -------------------------------------------------------------------------------- 1 | package com.kamwithk.ankiconnectandroid; 2 | 3 | import android.annotation.SuppressLint; 4 | import android.app.Notification; 5 | import android.app.PendingIntent; 6 | import android.content.Intent; 7 | import android.os.IBinder; 8 | import android.util.Log; 9 | import androidx.annotation.Nullable; 10 | import androidx.core.app.NotificationCompat; 11 | import com.kamwithk.ankiconnectandroid.routing.Router; 12 | 13 | import java.io.IOException; 14 | 15 | import static com.kamwithk.ankiconnectandroid.MainActivity.CHANNEL_ID; 16 | 17 | public class Service extends android.app.Service { 18 | public static final int PORT = 8765; 19 | 20 | private Router server; 21 | 22 | @Override 23 | public void onCreate() { // Only one time 24 | super.onCreate(); 25 | 26 | try { 27 | server = new Router(PORT, this); 28 | } catch (IOException e) { 29 | Log.w("Httpd", "The Server was unable to start"); 30 | e.printStackTrace(); 31 | } 32 | } 33 | 34 | @SuppressLint("UnspecifiedImmutableFlag") 35 | @Override 36 | public int onStartCommand(Intent intent, int flags, int startId) { // Every time start is called 37 | Intent notificationIntent = new Intent(this, MainActivity.class); 38 | PendingIntent pendingIntent = null; 39 | if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { 40 | pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); 41 | } else { 42 | pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT); 43 | } 44 | 45 | Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID) 46 | .setContentTitle("Ankiconnect Android") 47 | .setSmallIcon(R.mipmap.app_launcher) 48 | .setContentIntent(pendingIntent) 49 | .setOngoing(true) 50 | .build(); 51 | 52 | startForeground(1, notification); 53 | 54 | return START_STICKY; 55 | } 56 | 57 | @Override 58 | public void onDestroy() { 59 | server.stop(); 60 | super.onDestroy(); 61 | } 62 | 63 | @Nullable 64 | @Override 65 | public IBinder onBind(Intent intent) { 66 | return null; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /app/src/main/java/com/kamwithk/ankiconnectandroid/SettingsActivity.java: -------------------------------------------------------------------------------- 1 | package com.kamwithk.ankiconnectandroid; 2 | 3 | import android.content.Context; 4 | import android.content.Intent; 5 | import android.os.Build; 6 | import android.os.Bundle; 7 | import android.provider.Settings; 8 | import android.widget.Toast; 9 | 10 | import androidx.appcompat.app.ActionBar; 11 | import androidx.appcompat.app.AppCompatActivity; 12 | import androidx.appcompat.widget.Toolbar; 13 | import androidx.preference.EditTextPreference; 14 | import androidx.preference.Preference; 15 | import androidx.preference.PreferenceFragmentCompat; 16 | 17 | 18 | public class SettingsActivity extends AppCompatActivity { 19 | 20 | @Override 21 | protected void onCreate(Bundle savedInstanceState) { 22 | super.onCreate(savedInstanceState); 23 | setContentView(R.layout.settings_activity); 24 | if (savedInstanceState == null) { 25 | getSupportFragmentManager() 26 | .beginTransaction() 27 | .replace(R.id.settings, new SettingsFragment()) 28 | .commit(); 29 | } 30 | 31 | Toolbar settingsToolbar = findViewById(R.id.settingsToolbar); 32 | setSupportActionBar(settingsToolbar); 33 | 34 | ActionBar actionBar = getSupportActionBar(); 35 | if (actionBar != null) { 36 | // adds back button 37 | actionBar.setDisplayHomeAsUpEnabled(true); 38 | } 39 | } 40 | 41 | public static class SettingsFragment extends PreferenceFragmentCompat { 42 | @Override 43 | public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { 44 | setPreferencesFromResource(R.xml.root_preferences, rootKey); 45 | 46 | Preference preference = findPreference("access_overlay_perms"); 47 | if (preference != null) { 48 | // custom handler of preference: open permissions screen 49 | preference.setOnPreferenceClickListener(p -> { 50 | Intent permIntent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION); 51 | startActivity(permIntent); 52 | return true; 53 | }); 54 | 55 | } 56 | 57 | preference = findPreference("get_dir_path"); 58 | if (preference != null) { 59 | // custom handler of preference: open permissions screen 60 | preference.setOnPreferenceClickListener(p -> { 61 | 62 | Context context = getContext(); 63 | if (context == null) { 64 | Toast.makeText(getContext(), "Cannot get local audio folder, as context is null.", Toast.LENGTH_LONG).show(); 65 | } else { 66 | Toast.makeText(getContext(), "Local audio folder: " + context.getExternalFilesDir(null), Toast.LENGTH_LONG).show(); 67 | // TODO snackbar? 68 | // getView() seems to be null... 69 | // Snackbar snackbar = Snackbar.make(getView().findViewById(R.id.settings), 70 | // "Local audio folder: " + context.getExternalFilesDir(null), 71 | // BaseTransientBottomBar.LENGTH_LONG); 72 | // snackbar.show(); 73 | } 74 | return true; 75 | }); 76 | 77 | } 78 | 79 | EditTextPreference corsHostPreference = findPreference("cors_hostname"); 80 | if (corsHostPreference != null) { 81 | corsHostPreference.setOnBindEditTextListener(editText -> editText.setHint("e.g. http://example.com")); } 82 | } 83 | } 84 | 85 | 86 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kamwithk/ankiconnectandroid/ankidroid_api/BinaryFile.java: -------------------------------------------------------------------------------- 1 | package com.kamwithk.ankiconnectandroid.ankidroid_api; 2 | 3 | /** A binary file whose contents have been read into memory */ 4 | public class BinaryFile { 5 | private String filename; 6 | private byte[] data; 7 | 8 | public String getFilename() { 9 | return filename; 10 | } 11 | 12 | public void setFilename(String filename) { 13 | this.filename = filename; 14 | } 15 | 16 | public byte[] getData() { 17 | return data; 18 | } 19 | 20 | public void setData(byte[] data) { 21 | this.data = data; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/src/main/java/com/kamwithk/ankiconnectandroid/ankidroid_api/DeckAPI.java: -------------------------------------------------------------------------------- 1 | package com.kamwithk.ankiconnectandroid.ankidroid_api; 2 | 3 | import android.content.Context; 4 | import com.ichi2.anki.api.AddContentApi; 5 | 6 | import java.util.HashMap; 7 | import java.util.Map; 8 | 9 | public class DeckAPI { 10 | private final AddContentApi api; 11 | 12 | public DeckAPI(Context context) { 13 | api = new AddContentApi(context); 14 | } 15 | 16 | public String[] deckNames() throws Exception { 17 | Map decks = api.getDeckList(); 18 | 19 | if (decks != null) { 20 | return decks.values().toArray(new String[0]); 21 | } else { 22 | throw new Exception("Couldn't get deck names"); 23 | } 24 | } 25 | 26 | public Map deckNamesAndIds() throws Exception { 27 | Map temporary = api.getDeckList(); 28 | Map decks = new HashMap<>(); 29 | 30 | if (temporary != null) { 31 | // Reverse hashmap to get entries of (Name, ID) 32 | for (Map.Entry entry : temporary.entrySet()) { 33 | decks.put(entry.getValue(), entry.getKey()); 34 | } 35 | 36 | return decks; 37 | } else { 38 | throw new Exception("Couldn't get deck names and IDs"); 39 | } 40 | } 41 | 42 | public Long getDeckID(String name) throws Exception { 43 | for (Map.Entry entry : deckNamesAndIds().entrySet()) { 44 | if (entry.getKey().equalsIgnoreCase(name)) { 45 | return entry.getValue(); 46 | } 47 | } 48 | 49 | // Can't find deck 50 | throw new Exception("Couldn't get deck ID"); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/src/main/java/com/kamwithk/ankiconnectandroid/ankidroid_api/IntegratedAPI.java: -------------------------------------------------------------------------------- 1 | package com.kamwithk.ankiconnectandroid.ankidroid_api; 2 | 3 | import android.app.Activity; 4 | import android.content.Context; 5 | import android.content.Intent; 6 | import android.content.pm.PackageManager; 7 | import android.database.Cursor; 8 | import android.net.Uri; 9 | import android.os.Handler; 10 | import android.os.Looper; 11 | import android.text.TextUtils; 12 | import android.widget.Toast; 13 | import androidx.core.app.ActivityCompat; 14 | import androidx.core.content.ContextCompat; 15 | 16 | import java.io.IOException; 17 | import java.util.*; 18 | 19 | import static com.ichi2.anki.api.AddContentApi.READ_WRITE_PERMISSION; 20 | 21 | import com.kamwithk.ankiconnectandroid.request_parsers.MediaRequest; 22 | import com.ichi2.anki.FlashCardsContract; 23 | import com.ichi2.anki.api.AddContentApi; 24 | import com.kamwithk.ankiconnectandroid.request_parsers.NoteRequest; 25 | 26 | public class IntegratedAPI { 27 | private Context context; 28 | public final DeckAPI deckAPI; 29 | public final ModelAPI modelAPI; 30 | public final NoteAPI noteAPI; 31 | public final MediaAPI mediaAPI; 32 | private final AddContentApi api; // TODO: Combine all API classes??? 33 | 34 | //From anki-connect repo 35 | private static final String CAN_ADD_ERROR_REASON = "cannot create note because it is a duplicate"; 36 | public IntegratedAPI(Context context) { 37 | this.context = context; 38 | 39 | deckAPI = new DeckAPI(context); 40 | modelAPI = new ModelAPI(context); 41 | noteAPI = new NoteAPI(context); 42 | mediaAPI = new MediaAPI(context); 43 | 44 | api = new AddContentApi(context); 45 | } 46 | 47 | public static void authenticate(Context context) { 48 | int permission = ContextCompat.checkSelfPermission(context, READ_WRITE_PERMISSION); 49 | 50 | if (permission != PackageManager.PERMISSION_GRANTED) { 51 | ActivityCompat.requestPermissions((Activity)context, new String[]{READ_WRITE_PERMISSION}, 0); 52 | } 53 | } 54 | 55 | //public File getExternalFilesDir() { 56 | // return context.getExternalFilesDir(null); 57 | //} 58 | 59 | public void addSampleCard() { 60 | Map data = new HashMap<>(); 61 | data.put("Back", "sunrise"); 62 | data.put("Front", "日の出"); 63 | 64 | try { 65 | addNote(data, "Temporary", "Basic", null); 66 | } catch (Exception e) { 67 | e.printStackTrace(); 68 | } 69 | } 70 | 71 | public ArrayList canAddNotes(ArrayList notesToTest) throws Exception { 72 | final String[] NOTE_PROJECTION = {FlashCardsContract.Note._ID, FlashCardsContract.Note.CSUM}; 73 | 74 | if(notesToTest.isEmpty()) { 75 | return new ArrayList<>(); 76 | } 77 | 78 | ArrayList checksums = new ArrayList<>(notesToTest.size()); 79 | ArrayList canAddNote = new ArrayList<>(notesToTest.size()); 80 | NoteRequest.NoteOptions noteOptions = notesToTest.get(0).getOptions(); 81 | 82 | // If duplicate scope is "deck" or "deck root", we need to get extra information to figure out if DID matches. 83 | // If duplicate scope is "deck root" we need to include children, noteOptions.getDeckName() will not be null 84 | HashSet deckIds = new HashSet<>(); 85 | Map deckNamesToIds = deckAPI.deckNamesAndIds(); 86 | String deckName = noteOptions.getDeckName(); 87 | if(deckName == null) { 88 | // Deck, not root 89 | deckName = notesToTest.get(0).getDeckName(); 90 | deckIds.add(deckNamesToIds.get(deckName)); 91 | } 92 | else { 93 | for (String name : deckNamesToIds.keySet()) { 94 | if (name.contains(deckName)) { 95 | deckIds.add(deckNamesToIds.get(name)); 96 | } 97 | } 98 | } 99 | 100 | for (NoteRequest note : notesToTest) { 101 | String key = note.getFieldValue(); 102 | checksums.add(Utility.getFieldChecksum(key)); 103 | } 104 | 105 | // If duplicates are allowed, just need to see if they are valid notes (checksum != 0) 106 | if (noteOptions.isAllowDuplicate()) { 107 | for (long checksum: checksums) { 108 | canAddNote.add(checksum != 0); 109 | } 110 | return canAddNote; 111 | } 112 | 113 | // Grabbing the note options and model for the first note and assuming the rest are the same. 114 | // This is true for yomitan but might not be for other applications. 115 | String modelName = notesToTest.get(0).getModelName(); 116 | 117 | Map modelNameToId = modelAPI.modelNamesAndIds(0); 118 | Long modelId = modelNameToId.get(modelName); 119 | 120 | String selectionQuery = ""; 121 | if (!noteOptions.isCheckAllModels()) { 122 | selectionQuery = String.format( 123 | Locale.US, 124 | "%s=%d and ", 125 | FlashCardsContract.Note.MID, 126 | modelId 127 | ); 128 | } 129 | selectionQuery = selectionQuery + String.format( 130 | Locale.US, 131 | "%s in (%s)", 132 | FlashCardsContract.Note.CSUM, 133 | TextUtils.join(",", checksums) 134 | ); 135 | 136 | final Cursor cursor = context.getContentResolver().query( 137 | FlashCardsContract.Note.CONTENT_URI_V2, 138 | NOTE_PROJECTION, 139 | selectionQuery, 140 | null, 141 | null 142 | ); 143 | 144 | if (cursor == null || cursor.getCount() == 0) { 145 | for (int i = 0; i < notesToTest.size(); i++) { 146 | canAddNote.add(true); 147 | } 148 | } 149 | else { 150 | LinkedHashSet queryChecksums = findChecksumsInQuery( 151 | cursor, 152 | noteOptions.getDuplicateScope().equals("deck"), deckIds); 153 | 154 | for (int i = 0; i < checksums.size(); i++) { 155 | boolean isChecksumFound = !queryChecksums.contains(checksums.get(i)); 156 | canAddNote.add(isChecksumFound); 157 | } 158 | } 159 | 160 | return canAddNote; 161 | } 162 | 163 | private LinkedHashSet findChecksumsInQuery(Cursor cursor, boolean isDuplicateScopeDeck, Set deckIds) { 164 | LinkedHashSet queryChecksums = new LinkedHashSet<>(); 165 | 166 | try (cursor) { 167 | while (cursor.moveToNext()) { 168 | // Build list of CSUM (queryChecksums) 169 | // If an entry in queryChecksums is in checksums, then we have a duplicate 170 | // If scope is "deck", these duplicates need to be checked again for the deck 171 | int idIdx = cursor.getColumnIndexOrThrow(FlashCardsContract.Note._ID); 172 | int csumIdx = cursor.getColumnIndexOrThrow(FlashCardsContract.Note.CSUM); 173 | 174 | long queryNid = cursor.getLong(idIdx); 175 | long queryCsum = cursor.getLong(csumIdx); 176 | 177 | // If duplicate scope is "deck", need an additional query 178 | if (!isDuplicateScopeDeck || isNoteInDeck(queryNid, deckIds)) { 179 | queryChecksums.add(queryCsum); 180 | } 181 | } 182 | } 183 | 184 | return queryChecksums; 185 | } 186 | 187 | private boolean isNoteInDeck(long noteId, Set deckIds) { 188 | // Need to search for all cards with the same note ID, and see if they exist in one of the decks. 189 | final String[] CARD_PROJECTION = {FlashCardsContract.Card.DECK_ID}; 190 | 191 | Uri noteUri = Uri.withAppendedPath(FlashCardsContract.Note.CONTENT_URI, Long.toString(noteId)); 192 | Uri cardUri = Uri.withAppendedPath(noteUri, "cards"); 193 | Cursor cardCursor = context.getContentResolver().query( 194 | cardUri, 195 | CARD_PROJECTION, 196 | null, 197 | null, 198 | null 199 | ); 200 | 201 | if(cardCursor != null) { 202 | try (cardCursor) { 203 | while(cardCursor.moveToNext()) { 204 | int didIdx = cardCursor.getColumnIndexOrThrow(FlashCardsContract.Card.DECK_ID); 205 | long did = cardCursor.getLong(didIdx); 206 | 207 | if (deckIds.contains(did)) { 208 | return true; 209 | } 210 | } 211 | } 212 | } 213 | 214 | return false; 215 | } 216 | 217 | public static class CanAddWithError { 218 | private final boolean canAdd; 219 | private final String error; 220 | 221 | public CanAddWithError(boolean canAdd, String error) { 222 | this.canAdd = canAdd; 223 | this.error = error; 224 | } 225 | 226 | public boolean isCanAdd() { 227 | return canAdd; 228 | } 229 | 230 | public String getError() { 231 | return error; 232 | } 233 | } 234 | 235 | public List canAddNotesWithErrorDetail(ArrayList notesToTest) throws Exception { 236 | List canAddWithErrorList = new ArrayList<>(); 237 | List canAddList = canAddNotes(notesToTest); 238 | 239 | for (boolean canAdd : canAddList) { 240 | CanAddWithError canAddWithError; 241 | if (canAdd) { 242 | canAddWithError = new CanAddWithError(true, null); 243 | } 244 | else { 245 | canAddWithError = new CanAddWithError(false, CAN_ADD_ERROR_REASON); 246 | } 247 | canAddWithErrorList.add(canAddWithError); 248 | } 249 | 250 | return canAddWithErrorList; 251 | } 252 | 253 | /** 254 | * Add flashcards to AnkiDroid through instant add API 255 | * @param data Map of (field name, field value) pairs 256 | * @return The id of the note added 257 | */ 258 | public Long addNote(final Map data, String deck_name, String model_name, Set tags) throws Exception { 259 | Long deck_id = deckAPI.getDeckID(deck_name); 260 | Long model_id = modelAPI.getModelID(model_name, data.size()); 261 | Long note_id = noteAPI.addNote(data, deck_id, model_id, tags); 262 | 263 | if (note_id != null) { 264 | new Handler(Looper.getMainLooper()).post(() -> Toast.makeText(context, "Card added", Toast.LENGTH_SHORT).show()); 265 | return note_id; 266 | } else { 267 | new Handler(Looper.getMainLooper()).post(() -> Toast.makeText(context, "Failed to add card", Toast.LENGTH_SHORT).show()); 268 | throw new Exception("Couldn't add note"); 269 | } 270 | } 271 | 272 | /** 273 | * Adds the media to the collection, and updates noteValues 274 | * 275 | * @param noteValues Map from field name to field value 276 | * @param mediaRequests 277 | * @throws Exception 278 | */ 279 | public void addMedia(Map noteValues, List mediaRequests) throws Exception { 280 | for (MediaRequest media : mediaRequests) { 281 | // mediaAPI.storeMediaFile() doesn't store as the passed in filename, need to use the returned one 282 | Optional data = media.getData(); 283 | Optional url = media.getUrl(); 284 | String stored_filename; 285 | if (data.isPresent()) { 286 | stored_filename = mediaAPI.storeMediaFile(media.getFilename(), data.get()); 287 | } else if (url.isPresent()) { 288 | stored_filename = mediaAPI.downloadAndStoreBinaryFile(media.getFilename(), url.get()); 289 | } else { 290 | throw new Exception("You must provide a \"data\" or \"url\" field. Note that \"path\" is currently not supported on AnkiConnectAndroid."); 291 | } 292 | 293 | String enclosed_filename = ""; 294 | switch (media.getMediaType()) { 295 | case AUDIO: 296 | case VIDEO: 297 | enclosed_filename = "[sound:" + stored_filename + "]"; 298 | break; 299 | case PICTURE: 300 | enclosed_filename = ""; 301 | break; 302 | } 303 | 304 | for (String field : media.getFields()) { 305 | String existingValue = noteValues.get(field); 306 | 307 | if (existingValue == null) { 308 | noteValues.put(field, enclosed_filename); 309 | } else { 310 | noteValues.put(field, existingValue + enclosed_filename); 311 | } 312 | } 313 | } 314 | } 315 | 316 | public void updateNoteFields(long note_id, Map newFields, ArrayList mediaRequests) throws Exception { 317 | /* 318 | * updateNoteFields request looks like: 319 | * id: int, 320 | * fields: { 321 | * field_name: string 322 | * }, 323 | * audio | video | picture: [ 324 | * { 325 | * data: base64 string, 326 | * filename: string, 327 | * fields: string[] 328 | * + more fields that are currently unsupported 329 | * } 330 | * ] 331 | * 332 | * Fields is an incomplete list of fields, and the Anki API expects the the passed in field 333 | * list to be complete. So, need to get the existing fields and only update them if present 334 | * in the request. Also need to reverse map each media file back to the field it will be 335 | * included in and append it enclosed in either or [sound: ] 336 | */ 337 | 338 | String[] modelFieldNames = modelAPI.modelFieldNames(noteAPI.getNoteModelId(note_id)); 339 | String[] originalFields = noteAPI.getNoteFields(note_id); 340 | 341 | // updated fields 342 | HashMap cardFields = new HashMap<>(); 343 | 344 | // Get old fields and update values as needed 345 | for (int i = 0; i < modelFieldNames.length; i++) { 346 | String fieldName = modelFieldNames[i]; 347 | 348 | String newValue = newFields.get(modelFieldNames[i]); 349 | if (newValue != null) { 350 | // Update field to new value 351 | cardFields.put(fieldName, newValue); 352 | // Ankidroids `getFields` won't return empty fields that are at the end of the array 353 | // so `originalFields` may potentially contain less fields than `modelFieldNames` 354 | } else if (originalFields.length >= i + 1) { 355 | cardFields.put(fieldName, originalFields[i]); 356 | } else { 357 | cardFields.put(fieldName, ""); 358 | } 359 | } 360 | 361 | addMedia(cardFields, mediaRequests); 362 | noteAPI.updateNoteFields(note_id, cardFields); 363 | } 364 | 365 | public String storeMediaFile(BinaryFile binaryFile) throws IOException { 366 | return mediaAPI.storeMediaFile(binaryFile.getFilename(), binaryFile.getData()); 367 | } 368 | 369 | public ArrayList guiBrowse(String query) { 370 | // https://github.com/ankidroid/Anki-Android/pull/11899 371 | Uri webpage = Uri.parse("anki://x-callback-url/browser?search=" + query); 372 | Intent webIntent = new Intent(Intent.ACTION_VIEW, webpage); 373 | webIntent.setPackage("com.ichi2.anki"); 374 | // FLAG_ACTIVITY_NEW_TASK is needed in order to display the intent from a different app 375 | // FLAG_ACTIVITY_CLEAR_TOP and Intent.FLAG_ACTIVITY_TASK_ON_HOME is needed in order to not 376 | // cause a long chain of activities within Ankidroid 377 | // (i.e. browser <- word <- browser <- word <- browser <- word) 378 | // FLAG_ACTIVITY_CLEAR_TOP also allows the browser window to refresh with the new word 379 | // if AnkiDroid was already on the card browser activity. 380 | // see: https://stackoverflow.com/a/23874622 381 | webIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_TASK_ON_HOME); 382 | context.startActivity(webIntent); 383 | 384 | // The result doesn't seem to be used by Yomichan at all, so it can be safely ignored. 385 | // If we want to get the results, calling the findNotes() method will likely cause 386 | // unwanted delay. 387 | return new ArrayList<>(); 388 | } 389 | } 390 | 391 | -------------------------------------------------------------------------------- /app/src/main/java/com/kamwithk/ankiconnectandroid/ankidroid_api/MediaAPI.java: -------------------------------------------------------------------------------- 1 | package com.kamwithk.ankiconnectandroid.ankidroid_api; 2 | 3 | import android.annotation.SuppressLint; 4 | import android.content.ContentResolver; 5 | import android.content.ContentValues; 6 | import android.content.Context; 7 | import android.content.Intent; 8 | import android.net.Uri; 9 | import android.util.Log; 10 | import androidx.core.content.FileProvider; 11 | import com.kamwithk.ankiconnectandroid.BuildConfig; 12 | import com.ichi2.anki.FlashCardsContract; 13 | import com.ichi2.anki.api.AddContentApi; 14 | 15 | import java.io.ByteArrayOutputStream; 16 | import java.io.File; 17 | import java.io.FileOutputStream; 18 | import java.io.IOException; 19 | import java.io.InputStream; 20 | import java.net.HttpURLConnection; 21 | import java.net.URL; 22 | 23 | public class MediaAPI { 24 | private Context context; 25 | private final AddContentApi api; 26 | 27 | public MediaAPI(Context context) { 28 | this.context = context; 29 | api = new AddContentApi(context); 30 | } 31 | 32 | /** 33 | * Stores the given file and returns its name, without the initial slash. 34 | */ 35 | @SuppressLint("SetWorldReadable") 36 | public String storeMediaFile(String filename, byte[] data) throws IOException { 37 | // TODO: investigate why filename gets a number attached to it, i.e. file.png -> file_123456789.png 38 | String lastPathSegment = Uri.parse(filename).getLastPathSegment(); 39 | lastPathSegment = lastPathSegment == null ? filename : lastPathSegment; 40 | File file = new File(context.getCacheDir(), lastPathSegment); 41 | 42 | // Write to a temporary file 43 | try (FileOutputStream fileOutputStream = new FileOutputStream(file)) { 44 | fileOutputStream.write(data); 45 | } catch (Exception e) { 46 | Log.w("Error", e); 47 | throw e; 48 | } 49 | 50 | Uri file_uri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID, file); 51 | context.grantUriPermission("com.ichi2.anki", file_uri, Intent.FLAG_GRANT_READ_URI_PERMISSION); 52 | 53 | ContentValues contentValues = new ContentValues(); 54 | contentValues.put(FlashCardsContract.AnkiMedia.FILE_URI, file_uri.toString()); 55 | contentValues.put(FlashCardsContract.AnkiMedia.PREFERRED_NAME, lastPathSegment.replaceAll("\\..*", "")); 56 | 57 | ContentResolver contentResolver = context.getContentResolver(); 58 | Uri returnUri = contentResolver.insert(FlashCardsContract.AnkiMedia.CONTENT_URI, contentValues); 59 | 60 | // Remove temporary file 61 | file.deleteOnExit(); 62 | 63 | return new File(returnUri.getPath()).toString().substring(1); 64 | } 65 | 66 | /** 67 | * Download the requested audio file from the internet and store it on the disk. 68 | * @return The path to the audio file. 69 | */ 70 | public String downloadAndStoreBinaryFile(String fileName, String url) throws IOException { 71 | byte[] data = downloadMediaFile(url); 72 | BinaryFile binaryFile = new BinaryFile(); 73 | binaryFile.setFilename(fileName); 74 | binaryFile.setData(data); 75 | 76 | return storeMediaFile(binaryFile.getFilename(), binaryFile.getData()); 77 | } 78 | 79 | public byte[] downloadMediaFile(String audioUri) throws IOException { 80 | URL url = new URL(audioUri); 81 | HttpURLConnection conn = (HttpURLConnection) url.openConnection(); 82 | 83 | try (InputStream in = conn.getInputStream()) { 84 | try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { 85 | byte[] buffer = new byte[1024 * 5]; 86 | int bytesRead; 87 | while ((bytesRead = in.read(buffer)) != -1) { 88 | out.write(buffer, 0, bytesRead); 89 | } 90 | byte[] data = out.toByteArray(); 91 | return data; 92 | } 93 | } 94 | } 95 | 96 | } 97 | -------------------------------------------------------------------------------- /app/src/main/java/com/kamwithk/ankiconnectandroid/ankidroid_api/ModelAPI.java: -------------------------------------------------------------------------------- 1 | package com.kamwithk.ankiconnectandroid.ankidroid_api; 2 | 3 | import android.content.Context; 4 | import com.ichi2.anki.api.AddContentApi; 5 | 6 | import java.util.HashMap; 7 | import java.util.Map; 8 | 9 | public class ModelAPI { 10 | private final AddContentApi api; 11 | 12 | public ModelAPI(Context context) { 13 | api = new AddContentApi(context); 14 | } 15 | 16 | public String[] modelNames() throws Exception { 17 | Map models = api.getModelList(0); 18 | 19 | if (models != null) { 20 | return models.values().toArray(new String[0]); 21 | } else { 22 | throw new Exception("Couldn't get model names"); 23 | } 24 | } 25 | 26 | public Map modelNamesAndIds(Integer numFields) throws Exception { 27 | Map temporary = api.getModelList(numFields); 28 | Map models = new HashMap<>(); 29 | 30 | if (temporary != null) { 31 | // Reverse hashmap to get entries of (Name, ID) 32 | for (Map.Entry entry : temporary.entrySet()) { 33 | models.put(entry.getValue(), entry.getKey()); 34 | } 35 | 36 | return models; 37 | } else { 38 | throw new Exception("Couldn't get models names and IDs"); 39 | } 40 | } 41 | 42 | public String[] modelFieldNames(Long model_id) { 43 | return api.getFieldList(model_id); 44 | } 45 | 46 | public Long getModelID(String modelName, Integer numFields) throws Exception { 47 | Map modelList = modelNamesAndIds(numFields); 48 | for (Map.Entry entry : modelList.entrySet()) { 49 | if (entry.getKey().equals(modelName)) { 50 | return entry.getValue(); // first model wins 51 | } 52 | } 53 | 54 | // Can't find model 55 | throw new Exception("Couldn't get model ID"); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/src/main/java/com/kamwithk/ankiconnectandroid/ankidroid_api/NoteAPI.java: -------------------------------------------------------------------------------- 1 | package com.kamwithk.ankiconnectandroid.ankidroid_api; 2 | 3 | import android.content.ContentResolver; 4 | import android.content.Context; 5 | import android.database.Cursor; 6 | import android.net.Uri; 7 | import android.text.TextUtils; 8 | 9 | import com.ichi2.anki.FlashCardsContract; 10 | import com.ichi2.anki.api.AddContentApi; 11 | 12 | import java.util.*; 13 | 14 | public class NoteAPI { 15 | private Context context; 16 | private final ContentResolver resolver; 17 | private final AddContentApi api; 18 | 19 | private static final String[] MODEL_PROJECTION = {FlashCardsContract.Note.MID}; 20 | private static final String[] NOTE_ID_PROJECTION = {FlashCardsContract.Note._ID}; 21 | private static final String[] NOTES_INFO_PROJECTION = {FlashCardsContract.Note._ID, FlashCardsContract.Note.MID, FlashCardsContract.Note.TAGS, FlashCardsContract.Note.FLDS}; 22 | 23 | public NoteAPI(Context context) { 24 | this.context = context; 25 | this.resolver = context.getContentResolver(); 26 | api = new AddContentApi(context); 27 | } 28 | 29 | static String escapeQueryStr(String s) { 30 | // first replace: \ -> \\ 31 | // second replace: " -> \" 32 | return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; 33 | } 34 | 35 | /** 36 | * Add flashcards to AnkiDroid through instant add API 37 | * 38 | * @param data Map of (field name, field value) pairs 39 | */ 40 | public Long addNote(final Map data, Long deck_id, Long model_id, Set tags) throws Exception { 41 | String[] allFieldNames = api.getFieldList(model_id); 42 | if (allFieldNames == null) { 43 | throw new Exception("Couldn't get fields"); 44 | } 45 | 46 | // Get list in correct order 47 | String[] fields = new String[allFieldNames.length]; 48 | 49 | for (int i = 0; i < allFieldNames.length; i++) { 50 | fields[i] = data.getOrDefault(allFieldNames[i], ""); 51 | } 52 | 53 | return api.addNote(model_id, deck_id, fields, tags); 54 | } 55 | 56 | public String[] getNoteFields(long note_id) throws Exception { 57 | return api.getNote(note_id).getFields(); 58 | } 59 | 60 | public boolean updateNoteFields(long note_id, final Map data) throws Exception { 61 | long modelId = getNoteModelId(note_id); 62 | String[] allFieldNames = api.getFieldList(modelId); 63 | if (allFieldNames == null) { 64 | throw new Exception("Couldn't get fields"); 65 | } 66 | 67 | // Get list in correct order 68 | String[] fields = new String[data.size()]; 69 | for (int i = 0; i < data.size(); i++) { 70 | fields[i] = data.get(allFieldNames[i]); 71 | } 72 | 73 | return api.updateNoteFields(note_id, fields); 74 | } 75 | 76 | public Long getNoteModelId(long note_id) { 77 | // Manually queries the note with a specific projection to get the model ID 78 | // Code copied/pasted from getNote() in AddContentAPI: 79 | // https://github.com/ankidroid/Anki-Android/blob/1711e56c2b5515ab89c3424b60e60867bb65d492/api/src/main/java/com/ichi2/anki/api/AddContentApi.kt#L244 80 | 81 | Uri noteUri = Uri.withAppendedPath(FlashCardsContract.Note.CONTENT_URI, Long.toString(note_id)); 82 | Cursor cursor = this.resolver.query(noteUri, MODEL_PROJECTION, null, null, null); 83 | 84 | if (cursor == null) { 85 | return null; 86 | } 87 | try { 88 | if (!cursor.moveToNext()) { 89 | return null; 90 | } 91 | int index = cursor.getColumnIndexOrThrow(FlashCardsContract.Note.MID);; 92 | return cursor.getLong(index); // mid 93 | } finally { 94 | cursor.close(); 95 | } 96 | } 97 | 98 | public ArrayList findNotes(String query) { 99 | ArrayList noteIds = new ArrayList<>(); 100 | 101 | final Cursor cursor = this.resolver.query( 102 | FlashCardsContract.Note.CONTENT_URI, 103 | NOTE_ID_PROJECTION, 104 | query, 105 | null, 106 | null 107 | ); 108 | 109 | if (cursor != null) { 110 | if (!cursor.moveToFirst()) { 111 | return noteIds; 112 | } 113 | for (int i = 0; i < cursor.getCount(); i++) { 114 | long id = cursor.getLong(0); 115 | noteIds.add(id); 116 | cursor.moveToNext(); 117 | } 118 | cursor.close(); 119 | } 120 | 121 | return noteIds; 122 | } 123 | 124 | static class NoteInfoField { 125 | private final String value; 126 | private final int order; 127 | 128 | public NoteInfoField(String value, int order) { 129 | this.value = value; 130 | this.order = order; 131 | } 132 | 133 | public String getValue() { 134 | return value; 135 | } 136 | 137 | public int getOrder() { 138 | return order; 139 | } 140 | } 141 | static class NoteInfo { 142 | private final long noteId; 143 | private final String modelName; 144 | private final List tags; 145 | private final Map fields; 146 | 147 | public NoteInfo(long noteId, String modelName, List tags, Map fields) { 149 | this.noteId = noteId; 150 | this.modelName = modelName; 151 | this.tags = tags; 152 | this.fields = fields; 153 | } 154 | 155 | public long getNoteId() { 156 | return noteId; 157 | } 158 | 159 | public String getModelName() { 160 | return modelName; 161 | } 162 | 163 | public List getTags() { 164 | return tags; 165 | } 166 | 167 | public Map getFields() { 168 | return fields; 169 | } 170 | } 171 | 172 | static class Model { 173 | private final long modelId; 174 | private final String modelName; 175 | private final String[] fieldNames; 176 | 177 | public Model(long modelId, String modelName, String[] fieldNames) { 178 | this.modelId = modelId; 179 | this.modelName = modelName; 180 | this.fieldNames = fieldNames; 181 | } 182 | 183 | public long getModelId() { 184 | return modelId; 185 | } 186 | 187 | public String getModelName() { 188 | return modelName; 189 | } 190 | 191 | public String[] getFieldNames() { 192 | return fieldNames; 193 | } 194 | } 195 | 196 | public List notesInfo(ArrayList noteIds) throws Exception { 197 | List notesInfoList = new ArrayList<>(); 198 | String nidQuery = "nid:" + TextUtils.join(",", noteIds); 199 | Map cache = new HashMap<>(); 200 | 201 | Cursor cursor = this.resolver.query( 202 | FlashCardsContract.Note.CONTENT_URI, 203 | NOTES_INFO_PROJECTION, 204 | nidQuery, 205 | null, 206 | null, 207 | null 208 | ); 209 | 210 | if (cursor == null) { 211 | return null; 212 | } 213 | 214 | try (cursor) { 215 | while (cursor.moveToNext()) { 216 | 217 | int idIdx = cursor.getColumnIndexOrThrow(FlashCardsContract.Note._ID); 218 | int midIdx = cursor.getColumnIndexOrThrow(FlashCardsContract.Note.MID); 219 | int tagsIdx = cursor.getColumnIndexOrThrow(FlashCardsContract.Note.TAGS); 220 | int fldsIdx = cursor.getColumnIndexOrThrow(FlashCardsContract.Note.FLDS); 221 | 222 | long id = cursor.getLong(idIdx); 223 | long mid = cursor.getLong(midIdx); 224 | List tags = Arrays.asList(Utility.splitTags(cursor.getString(tagsIdx))); 225 | String[] fieldValues = Utility.splitFields(cursor.getString(fldsIdx)); 226 | Model model = null; 227 | 228 | if (cache.containsKey(mid)) { 229 | model = cache.get(mid); 230 | } 231 | else { 232 | String[] fieldNames = api.getFieldList(mid); 233 | String modelName = api.getModelName(mid); 234 | 235 | model = new Model(mid, modelName, fieldNames); 236 | cache.put(mid, model); 237 | } 238 | 239 | Map fields = new HashMap<>(); 240 | String[] fieldNames = model.getFieldNames(); 241 | 242 | for (int i = 0; i < fieldNames.length; i++) { 243 | String fieldName = fieldNames[i]; 244 | String fieldValue = fieldValues[i]; 245 | NoteInfoField noteInfoField = new NoteInfoField(fieldValue, i); 246 | fields.put(fieldName, noteInfoField); 247 | } 248 | NoteInfo noteInfo = new NoteInfo(id, model.getModelName(), tags, fields); 249 | notesInfoList.add(noteInfo); 250 | } 251 | } 252 | return notesInfoList; 253 | } 254 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kamwithk/ankiconnectandroid/ankidroid_api/Utility.java: -------------------------------------------------------------------------------- 1 | package com.kamwithk.ankiconnectandroid.ankidroid_api; 2 | 3 | import android.text.Html; 4 | import java.math.BigInteger; 5 | import java.nio.charset.StandardCharsets; 6 | import java.security.MessageDigest; 7 | import java.util.regex.Matcher; 8 | import java.util.regex.Pattern; 9 | 10 | public final class Utility { 11 | 12 | private static Pattern STYLE_PATTERN = Pattern.compile("(?s).*?"); 13 | private static Pattern SCRIPT_PATTERN = Pattern.compile("(?s).*?"); 14 | private static Pattern TAG_PATTERN = Pattern.compile("<.*?>"); 15 | private static Pattern IMG_PATTERN = Pattern.compile("]+)[\"']? ?/?>"); 16 | private static Pattern HTML_ENTITIES_PATTERN = Pattern.compile("&#?\\w+;"); 17 | private static final String FIELD_SEPARATOR = Character.toString('\u001f'); 18 | 19 | private Utility() { 20 | } 21 | 22 | // taken from AnkiDroid 23 | public static String[] splitTags(String tags) { 24 | if (tags == null) { 25 | return null; 26 | } 27 | return tags.trim().split("\\s+"); 28 | } 29 | 30 | public static String[] splitFields(String fields) { 31 | return fields != null? fields.split(FIELD_SEPARATOR, -1): null; 32 | } 33 | 34 | public static long getFieldChecksum(String data) { 35 | final String SHA1_ZEROES = "0000000000000000000000000000000000000000"; 36 | String strippedData = stripHTMLMedia(data); 37 | 38 | try { 39 | MessageDigest md = MessageDigest.getInstance("SHA1"); 40 | byte[] digest = md.digest(strippedData.getBytes(StandardCharsets.UTF_8)); 41 | BigInteger bigInteger = new BigInteger(1, digest); 42 | String result = bigInteger.toString(16); 43 | 44 | if(result.length() < 40) { 45 | result = SHA1_ZEROES.substring(0, SHA1_ZEROES.length() - result.length()) + result; 46 | } 47 | return Long.valueOf(result.substring(0, 8), 16); 48 | } 49 | catch (Exception e) { 50 | throw new IllegalStateException("Error making field checksum with SHA1 algorithm and UTF-8 encoding", e); 51 | } 52 | } 53 | 54 | private static String stripHTMLMedia(String s) { 55 | Matcher imgMatcher = IMG_PATTERN.matcher(s); 56 | return stripHTML(imgMatcher.replaceAll(" $1 ")); 57 | } 58 | 59 | private static String stripHTML(String s) { 60 | Matcher htmlMatcher = STYLE_PATTERN.matcher(s); 61 | String strRep = htmlMatcher.replaceAll(""); 62 | htmlMatcher = SCRIPT_PATTERN.matcher(strRep); 63 | strRep = htmlMatcher.replaceAll(""); 64 | htmlMatcher = TAG_PATTERN.matcher(strRep); 65 | strRep = htmlMatcher.replaceAll(""); 66 | return entsToTxt(strRep); 67 | } 68 | 69 | private static String entsToTxt(String html) { 70 | String htmlReplaced = html.replace(" ", " "); 71 | Matcher htmlEntities = HTML_ENTITIES_PATTERN.matcher(htmlReplaced); 72 | StringBuffer sb = new StringBuffer(); 73 | while (htmlEntities.find()) { 74 | htmlEntities.appendReplacement(sb, Html.fromHtml(htmlEntities.group()).toString()); 75 | } 76 | htmlEntities.appendTail(sb); 77 | return sb.toString(); 78 | } 79 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kamwithk/ankiconnectandroid/request_parsers/MediaRequest.java: -------------------------------------------------------------------------------- 1 | package com.kamwithk.ankiconnectandroid.request_parsers; 2 | 3 | import android.util.Base64; 4 | 5 | import androidx.annotation.NonNull; 6 | 7 | import com.google.gson.JsonArray; 8 | import com.google.gson.JsonElement; 9 | import com.google.gson.JsonObject; 10 | 11 | import java.util.ArrayList; 12 | import java.util.List; 13 | import java.util.Optional; 14 | 15 | /** 16 | * Simple class that encodes the media passed into an addNote, updateNoteFields, etc request. 17 | * Currently very limited; only supports the type, data, filename, and fields[] part of the request. 18 | */ 19 | public class MediaRequest { 20 | private final MediaType mediaType; 21 | private final String filename; 22 | private final ArrayList fields; 23 | 24 | private Optional data = Optional.empty(); 25 | private Optional url = Optional.empty(); 26 | 27 | public enum MediaType { 28 | AUDIO, 29 | VIDEO, 30 | PICTURE, 31 | } 32 | 33 | public MediaRequest(MediaType mediaType, String filename, ArrayList fields) { 34 | this.mediaType = mediaType; 35 | this.filename = filename; 36 | this.fields = fields; 37 | } 38 | 39 | public MediaType getMediaType() { 40 | return mediaType; 41 | } 42 | 43 | public String getFilename() { 44 | return filename; 45 | } 46 | 47 | public List getFields() { 48 | return fields; 49 | } 50 | 51 | public Optional getData() { 52 | return data; 53 | } 54 | 55 | public void setData(byte[] data) { 56 | this.data = Optional.of(data); 57 | } 58 | 59 | public void setUrl(String url) { 60 | this.url = Optional.of(url); 61 | } 62 | public Optional getUrl() { 63 | return url; 64 | } 65 | 66 | 67 | 68 | @NonNull 69 | public static MediaRequest fromJson(JsonElement mediaFile, MediaType mediaType) { 70 | // This is the expected format of the mediaFile: 71 | // { 72 | // "url": "https://www.example.com/audio.mp3", 73 | // "filename": "audio_自転車_2023-03-24T15:39:17.151Z", 74 | // "fields": [ 75 | // "Audio" 76 | // ] 77 | // } 78 | JsonObject mediaObject = mediaFile.getAsJsonObject(); 79 | 80 | String filename = mediaObject.get("filename").getAsString(); 81 | JsonArray fields = mediaObject.get("fields").getAsJsonArray(); 82 | 83 | // convert fields to String[] 84 | ArrayList fieldsList = new ArrayList<>(); 85 | for (int i = 0; i < fields.size(); i++) { 86 | fieldsList.add(fields.get(i).getAsString()); 87 | } 88 | 89 | MediaRequest request = new MediaRequest(mediaType, filename, fieldsList); 90 | 91 | if (mediaObject.has("url")) { 92 | request.setUrl(mediaObject.get("url").getAsString()); 93 | } 94 | 95 | if (mediaObject.has("data")) { 96 | request.setData(Base64.decode(mediaObject.get("data").getAsString(), Base64.DEFAULT)); 97 | } 98 | 99 | return request; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /app/src/main/java/com/kamwithk/ankiconnectandroid/request_parsers/NoteRequest.java: -------------------------------------------------------------------------------- 1 | package com.kamwithk.ankiconnectandroid.request_parsers; 2 | 3 | import androidx.annotation.NonNull; 4 | 5 | import com.google.gson.JsonArray; 6 | import com.google.gson.JsonElement; 7 | import com.google.gson.JsonObject; 8 | 9 | import java.util.ArrayList; 10 | import java.util.List; 11 | 12 | public class NoteRequest { 13 | 14 | public static class NoteOptions { 15 | private final boolean allowDuplicate; 16 | 17 | private final String duplicateScope; 18 | 19 | private final String deckName; 20 | 21 | private final boolean checkChildren; 22 | 23 | private final boolean checkAllModels; 24 | 25 | public NoteOptions(boolean allowDuplicate, 26 | String duplicateScope, 27 | String deckName, 28 | boolean checkChildren, 29 | boolean checkAllModels) { 30 | this.allowDuplicate = allowDuplicate; 31 | this.duplicateScope = duplicateScope; 32 | this.deckName = deckName; 33 | this.checkChildren = checkChildren; 34 | this.checkAllModels = checkAllModels; 35 | } 36 | 37 | public boolean isAllowDuplicate() { 38 | return allowDuplicate; 39 | } 40 | 41 | public String getDuplicateScope() { 42 | return duplicateScope; 43 | } 44 | 45 | public String getDeckName() { 46 | return deckName; 47 | } 48 | 49 | public boolean isCheckChildren() { 50 | return checkChildren; 51 | } 52 | 53 | public boolean isCheckAllModels() { 54 | return checkAllModels; 55 | } 56 | } 57 | 58 | private final String fieldName; 59 | private final String fieldValue; 60 | private final String modelName; 61 | 62 | private final String deckName; 63 | 64 | private final List tags; 65 | 66 | private final NoteOptions options; 67 | 68 | public NoteRequest(String fieldName, String fieldValue, String modelName, String deckName, List tags, NoteOptions options) { 69 | this.fieldName = fieldName; 70 | this.fieldValue = fieldValue; 71 | this.modelName = modelName; 72 | this.deckName = deckName; 73 | this.tags = tags; 74 | this.options = options; 75 | } 76 | 77 | public String getFieldName() { 78 | return fieldName; 79 | } 80 | 81 | public String getFieldValue() { 82 | return fieldValue; 83 | } 84 | 85 | public String getModelName() { 86 | return modelName; 87 | } 88 | 89 | public String getDeckName() { 90 | return deckName; 91 | } 92 | 93 | public List getTags() { 94 | return tags; 95 | } 96 | 97 | public NoteOptions getOptions() { 98 | return options; 99 | } 100 | 101 | @NonNull 102 | public static NoteRequest fromJson(JsonElement noteElement) { 103 | ArrayList tagList = new ArrayList<>(); 104 | JsonObject noteObject = noteElement.getAsJsonObject(); 105 | 106 | JsonObject fieldsObject = noteObject.get("fields").getAsJsonObject(); 107 | String field = fieldsObject.keySet().toArray()[0].toString(); 108 | String value = fieldsObject.get(field).getAsString(); 109 | 110 | String modelName = noteObject.get("modelName").getAsString(); 111 | String deckName = noteObject.get("deckName").getAsString(); 112 | 113 | JsonArray jsonTags = noteElement.getAsJsonObject().get("tags").getAsJsonArray(); 114 | for (JsonElement tag : jsonTags) { 115 | tagList.add(tag.getAsString()); 116 | } 117 | 118 | NoteOptions options = null; 119 | if (noteObject.has("options")) { 120 | options = readNoteOptions(noteObject.get("options").getAsJsonObject()); 121 | } 122 | 123 | return new NoteRequest(field, 124 | value, 125 | modelName, 126 | deckName, 127 | tagList, 128 | options); 129 | } 130 | 131 | @NonNull 132 | private static NoteOptions readNoteOptions(JsonObject optionsObject) { 133 | boolean allowDuplicate = false; 134 | String duplicateScope = null; 135 | String duplicateScopeDeckName = null; 136 | boolean duplicateScopeCheckChildren = false; 137 | boolean duplicateScopeCheckAllModels = false; 138 | 139 | 140 | allowDuplicate = optionsObject.get("allowDuplicate").getAsBoolean(); 141 | duplicateScope = optionsObject.get("duplicateScope").getAsString(); 142 | if (optionsObject.has("duplicateScopeOptions")) { 143 | JsonObject duplicateScopeObject = optionsObject.get("duplicateScopeOptions").getAsJsonObject(); 144 | 145 | if (duplicateScopeObject.has("deckName")) { 146 | JsonElement duplicateDeckName = duplicateScopeObject.get("deckName"); 147 | if(!duplicateDeckName.isJsonNull()) { 148 | duplicateScopeDeckName = duplicateDeckName.getAsString(); 149 | } 150 | } 151 | if (duplicateScopeObject.has("deckName")) { 152 | duplicateScopeCheckChildren = duplicateScopeObject.get("checkChildren").getAsBoolean(); 153 | } 154 | if (duplicateScopeObject.has("deckName")) { 155 | duplicateScopeCheckAllModels = duplicateScopeObject.get("checkAllModels").getAsBoolean(); 156 | } 157 | } 158 | 159 | return new NoteOptions(allowDuplicate, 160 | duplicateScope, 161 | duplicateScopeDeckName, 162 | duplicateScopeCheckChildren, 163 | duplicateScopeCheckAllModels); 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /app/src/main/java/com/kamwithk/ankiconnectandroid/request_parsers/Parser.java: -------------------------------------------------------------------------------- 1 | package com.kamwithk.ankiconnectandroid.request_parsers; 2 | 3 | import android.util.Base64; 4 | 5 | import com.google.gson.Gson; 6 | import com.google.gson.GsonBuilder; 7 | import com.google.gson.JsonArray; 8 | import com.google.gson.JsonElement; 9 | import com.google.gson.JsonObject; 10 | import com.google.gson.JsonParser; 11 | import com.google.gson.reflect.TypeToken; 12 | 13 | import java.lang.reflect.Type; 14 | import java.util.ArrayList; 15 | import java.util.Arrays; 16 | import java.util.List; 17 | import java.util.Map; 18 | import java.util.Set; 19 | 20 | public class Parser { 21 | public static Gson gson = new GsonBuilder().setPrettyPrinting().serializeNulls().create(); 22 | public static Gson gsonNoSerialize = new GsonBuilder().setPrettyPrinting().create(); 23 | 24 | public static JsonObject parse(String raw_data) { 25 | return JsonParser.parseString(raw_data).getAsJsonObject(); 26 | } 27 | 28 | public static String get_action(JsonObject data) { 29 | return data.get("action").getAsString(); 30 | } 31 | 32 | public static int get_version(JsonObject data, int fallback) { 33 | if (data.has("version")) { 34 | return data.get("version").getAsInt(); 35 | } 36 | return fallback; 37 | } 38 | 39 | public static String getDeckName(JsonObject raw_data) { 40 | return raw_data.get("params").getAsJsonObject().get("note").getAsJsonObject().get("deckName").getAsString(); 41 | } 42 | 43 | public static String getModelName(JsonObject raw_data) { 44 | return raw_data.get("params").getAsJsonObject().get("note").getAsJsonObject().get("modelName").getAsString(); 45 | } 46 | 47 | public static String getModelNameFromParam(JsonObject raw_data) { 48 | return raw_data.get("params").getAsJsonObject().get("modelName").getAsString(); 49 | } 50 | 51 | public static Map getNoteValues(JsonObject raw_data) { 52 | Type fieldType = new TypeToken>() {}.getType(); 53 | return gson.fromJson(raw_data.get("params").getAsJsonObject().get("note").getAsJsonObject().get("fields"), fieldType); 54 | } 55 | 56 | public static Set getNoteTags(JsonObject raw_data) { 57 | Type fieldType = new TypeToken>() {}.getType(); 58 | return gson.fromJson(raw_data.get("params").getAsJsonObject().get("note").getAsJsonObject().get("tags"), fieldType); 59 | } 60 | 61 | public static String getNoteQuery(JsonObject raw_data) { 62 | return raw_data.get("params").getAsJsonObject().get("query").getAsString(); 63 | } 64 | 65 | public static long getUpdateNoteFieldsId(JsonObject raw_data) { 66 | return raw_data.get("params").getAsJsonObject().get("note").getAsJsonObject().get("id").getAsLong(); 67 | } 68 | 69 | public static Map getUpdateNoteFieldsFields(JsonObject raw_data) { 70 | Type fieldType = new TypeToken>() {}.getType(); 71 | return gson.fromJson(raw_data.get("params").getAsJsonObject().get("note").getAsJsonObject().get("fields"), fieldType); 72 | } 73 | 74 | /** 75 | * For each key ("audio", "video", "picture"), expect EITHER a list or singular json object! 76 | * According to the official Anki-Connect docs: 77 | * > If you choose to include [audio, video, picture keys], they should contain a single object 78 | * > or an array of objects 79 | */ 80 | public static ArrayList getNoteMediaRequests(JsonObject raw_data) { 81 | Map media_types = Map.of( 82 | "audio", MediaRequest.MediaType.AUDIO, 83 | "video", MediaRequest.MediaType.VIDEO, 84 | "picture", MediaRequest.MediaType.PICTURE 85 | ); 86 | JsonObject note_json = raw_data.get("params").getAsJsonObject().get("note").getAsJsonObject(); 87 | 88 | ArrayList request_medias = new ArrayList<>(); 89 | for (Map.Entry entry: media_types.entrySet()) { 90 | JsonElement media_value = note_json.get(entry.getKey()); 91 | if (media_value == null) { 92 | continue; 93 | } 94 | if (media_value.isJsonArray()) { 95 | for (JsonElement media_element: media_value.getAsJsonArray()) { 96 | JsonObject media_object = media_element.getAsJsonObject(); 97 | MediaRequest requestMedia = MediaRequest.fromJson(media_object, entry.getValue()); 98 | request_medias.add(requestMedia); 99 | } 100 | } else if (media_value.isJsonObject()) { 101 | JsonObject media_object = media_value.getAsJsonObject(); 102 | MediaRequest requestMedia = MediaRequest.fromJson(media_object, entry.getValue()); 103 | request_medias.add(requestMedia); 104 | } 105 | } 106 | return request_medias; 107 | } 108 | 109 | /** 110 | * Gets the first field of the note 111 | */ 112 | public static ArrayList getNoteFront(JsonObject raw_data) { 113 | JsonArray notes = raw_data.get("params").getAsJsonObject().get("notes").getAsJsonArray(); 114 | ArrayList projections = new ArrayList<>(); 115 | 116 | for (JsonElement jsonElement : notes) { 117 | projections.add(NoteRequest.fromJson(jsonElement)); 118 | } 119 | 120 | return projections; 121 | } 122 | 123 | public static boolean[] getNoteTrues(JsonObject raw_data) { 124 | int num_notes = raw_data.get("params").getAsJsonObject().get("notes").getAsJsonArray().size(); 125 | boolean[] array = new boolean[num_notes]; 126 | Arrays.fill(array, true); 127 | 128 | return array; 129 | } 130 | 131 | public static ArrayList getNoteIds(JsonObject raw_data) { 132 | ArrayList noteIds = new ArrayList<>(); 133 | JsonArray jsonNoteIds = raw_data.get("params").getAsJsonObject().get("notes").getAsJsonArray(); 134 | for(JsonElement noteId: jsonNoteIds) { 135 | noteIds.add(noteId.getAsLong()); 136 | } 137 | return noteIds; 138 | } 139 | 140 | public static String getMediaFilename(JsonObject raw_data) { 141 | return raw_data.get("params").getAsJsonObject().get("filename").getAsString(); 142 | } 143 | 144 | public static byte[] getMediaData(JsonObject raw_data) { 145 | String encoded = raw_data.get("params").getAsJsonObject().get("data").getAsString(); 146 | return Base64.decode(encoded, Base64.DEFAULT); 147 | } 148 | 149 | public static JsonArray getMultiActions(JsonObject raw_data) { 150 | return raw_data.get("params").getAsJsonObject().get("actions").getAsJsonArray(); 151 | } 152 | } 153 | 154 | -------------------------------------------------------------------------------- /app/src/main/java/com/kamwithk/ankiconnectandroid/routing/APIHandler.java: -------------------------------------------------------------------------------- 1 | package com.kamwithk.ankiconnectandroid.routing; 2 | 3 | import android.content.Context; 4 | import android.util.Log; 5 | 6 | import com.google.gson.JsonObject; 7 | import com.kamwithk.ankiconnectandroid.ankidroid_api.IntegratedAPI; 8 | import com.kamwithk.ankiconnectandroid.request_parsers.Parser; 9 | import fi.iki.elonen.NanoHTTPD; 10 | 11 | import java.util.*; 12 | 13 | public class APIHandler { 14 | private final AnkiAPIRouting ankiAPIRouting; 15 | private final ForvoAPIRouting forvoAPIRouting; 16 | private final LocalAudioAPIRouting localAudioAPIRouting; 17 | 18 | public APIHandler(IntegratedAPI integratedAPI, Context context) { 19 | ankiAPIRouting = new AnkiAPIRouting(integratedAPI); 20 | forvoAPIRouting = new ForvoAPIRouting(context); 21 | localAudioAPIRouting = new LocalAudioAPIRouting(context); 22 | } 23 | 24 | public NanoHTTPD.Response chooseAPI(String json_string, Map> parameters) { 25 | 26 | if ((parameters.containsKey("term") || parameters.containsKey("expression")) && parameters.containsKey("reading")) { 27 | String reading = Objects.requireNonNull(parameters.get("reading")).get(0); 28 | 29 | return forvoAPIRouting.getAudioHandleError(parameters.get("term"), parameters.get("expression"), reading); 30 | } else { 31 | Log.d("AnkiConnectAndroid", "received json: " + json_string); 32 | JsonObject raw_json = Parser.parse(json_string); 33 | return ankiAPIRouting.findRouteHandleError(raw_json); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/src/main/java/com/kamwithk/ankiconnectandroid/routing/AnkiAPIRouting.java: -------------------------------------------------------------------------------- 1 | package com.kamwithk.ankiconnectandroid.routing; 2 | 3 | import com.google.gson.JsonArray; 4 | import com.google.gson.JsonElement; 5 | import com.google.gson.JsonObject; 6 | import com.google.gson.JsonParser; 7 | import com.kamwithk.ankiconnectandroid.ankidroid_api.BinaryFile; 8 | import com.kamwithk.ankiconnectandroid.ankidroid_api.DeckAPI; 9 | import com.kamwithk.ankiconnectandroid.ankidroid_api.IntegratedAPI; 10 | import com.kamwithk.ankiconnectandroid.ankidroid_api.MediaAPI; 11 | import com.kamwithk.ankiconnectandroid.ankidroid_api.ModelAPI; 12 | import com.kamwithk.ankiconnectandroid.request_parsers.NoteRequest; 13 | import com.kamwithk.ankiconnectandroid.request_parsers.Parser; 14 | import com.kamwithk.ankiconnectandroid.request_parsers.MediaRequest; 15 | 16 | import fi.iki.elonen.NanoHTTPD; 17 | 18 | import java.io.IOException; 19 | import java.io.PrintWriter; 20 | import java.io.StringWriter; 21 | import java.util.ArrayList; 22 | import java.util.HashMap; 23 | import java.util.Map; 24 | 25 | import static fi.iki.elonen.NanoHTTPD.newFixedLengthResponse; 26 | 27 | import android.util.Log; 28 | 29 | 30 | public class AnkiAPIRouting { 31 | private final IntegratedAPI integratedAPI; 32 | private final DeckAPI deckAPI; 33 | private final ModelAPI modelAPI; 34 | private final MediaAPI mediaAPI; 35 | 36 | public AnkiAPIRouting(IntegratedAPI integratedAPI) { 37 | this.integratedAPI = integratedAPI; 38 | deckAPI = integratedAPI.deckAPI; 39 | modelAPI = integratedAPI.modelAPI; 40 | mediaAPI = integratedAPI.mediaAPI; 41 | } 42 | 43 | private String findRoute(JsonObject raw_json) throws Exception { 44 | switch (Parser.get_action(raw_json)) { 45 | case "version": 46 | return version(); 47 | case "deckNames": 48 | return deckNames(); 49 | case "deckNamesAndIds": 50 | return deckNamesAndIds(); 51 | case "modelNames": 52 | return modelNames(); 53 | case "modelNamesAndIds": 54 | return modelNamesAndIds(); 55 | case "modelFieldNames": 56 | return modelFieldNames(raw_json); 57 | case "findNotes": 58 | return findNotes(raw_json); 59 | case "guiBrowse": 60 | return guiBrowse(raw_json); 61 | case "canAddNotes": 62 | return canAddNotes(raw_json); 63 | case "canAddNotesWithErrorDetail": 64 | return canAddNotesWithErrorDetail(raw_json); 65 | case "addNote": 66 | return addNote(raw_json); 67 | case "updateNoteFields": 68 | return updateNoteFields(raw_json); 69 | case "storeMediaFile": 70 | return storeMediaFile(raw_json); 71 | case "notesInfo": 72 | return notesInfo(raw_json); 73 | case "multi": 74 | JsonArray actions = Parser.getMultiActions(raw_json); 75 | JsonArray results = new JsonArray(); 76 | 77 | for (JsonElement jsonElement : actions) { 78 | int version = Parser.get_version(jsonElement.getAsJsonObject(), 4); 79 | String routeResult = findRoute(jsonElement.getAsJsonObject()); 80 | 81 | JsonElement routeResultJson = JsonParser.parseString(routeResult); 82 | JsonElement response = formatSuccessReply(routeResultJson, version); 83 | results.add(response); 84 | } 85 | 86 | return Parser.gson.toJson(results); 87 | default: 88 | return default_version(); 89 | } 90 | } 91 | /* taken from anki-connect's web.py: format_success_reply */ 92 | public JsonElement formatSuccessReply(JsonElement raw_json, int version) { 93 | if (version <= 4) { 94 | return raw_json; 95 | } else { 96 | JsonObject reply = new JsonObject(); 97 | reply.add("result", raw_json); 98 | reply.add("error", null); 99 | return reply; 100 | } 101 | } 102 | 103 | public NanoHTTPD.Response findRouteHandleError(JsonObject raw_json) { 104 | try { 105 | int version = Parser.get_version(raw_json, 4); 106 | String response = formatSuccessReply(JsonParser.parseString(findRoute(raw_json)), version).toString(); 107 | Log.d("AnkiConnectAndroid", "response json: " + response); 108 | return returnResponse(response); 109 | } catch (Exception e) { 110 | Map response = new HashMap<>(); 111 | response.put("result", null); 112 | 113 | StringWriter sw = new StringWriter(); 114 | try { 115 | try (PrintWriter pw = new PrintWriter(sw)) { 116 | e.printStackTrace(pw); 117 | } 118 | response.put("error", e.getMessage() + sw); 119 | } finally { 120 | try { 121 | sw.close(); 122 | } catch (IOException ex) { 123 | ex.printStackTrace(); 124 | } 125 | } 126 | return newFixedLengthResponse(NanoHTTPD.Response.Status.OK, "text/json", Parser.gson.toJson(response)); 127 | } 128 | } 129 | 130 | private NanoHTTPD.Response returnResponse(String response) { 131 | return newFixedLengthResponse(NanoHTTPD.Response.Status.OK, "text/json", response); 132 | } 133 | 134 | private String version() { 135 | return "6"; 136 | } 137 | 138 | private String default_version() { 139 | return "AnkiConnect v.6"; 140 | } 141 | 142 | private String deckNames() throws Exception { 143 | return Parser.gson.toJson(deckAPI.deckNames()); 144 | } 145 | 146 | private String deckNamesAndIds() throws Exception { 147 | return Parser.gson.toJson(deckAPI.deckNamesAndIds()); 148 | } 149 | 150 | private String modelNames() throws Exception { 151 | return Parser.gson.toJson(modelAPI.modelNames()); 152 | } 153 | 154 | private String modelNamesAndIds() throws Exception { 155 | return Parser.gson.toJson(modelAPI.modelNamesAndIds(0)); 156 | } 157 | 158 | private String modelFieldNames(JsonObject raw_json) throws Exception { 159 | String model_name = Parser.getModelNameFromParam(raw_json); 160 | if (model_name != null && !model_name.equals("")) { 161 | Long model_id = modelAPI.getModelID(model_name, 0); 162 | 163 | return Parser.gson.toJson(modelAPI.modelFieldNames(model_id)); 164 | } else { 165 | Map response = new HashMap<>(); 166 | response.put("result", null); 167 | response.put("error", "model was not found: "); 168 | 169 | return Parser.gson.toJson(response); 170 | } 171 | } 172 | 173 | private String findNotes(JsonObject raw_json) { 174 | return Parser.gson.toJson(integratedAPI.noteAPI.findNotes(Parser.getNoteQuery(raw_json))); 175 | } 176 | 177 | private String guiBrowse(JsonObject raw_json) { 178 | String query = Parser.getNoteQuery(raw_json); 179 | return Parser.gson.toJson(integratedAPI.guiBrowse(query)); 180 | } 181 | 182 | private String canAddNotes(JsonObject raw_json) throws Exception { 183 | ArrayList notes_to_test = Parser.getNoteFront(raw_json); 184 | return Parser.gson.toJson(integratedAPI.canAddNotes(notes_to_test)); 185 | } 186 | 187 | private String canAddNotesWithErrorDetail(JsonObject raw_json) throws Exception { 188 | ArrayList notes_to_test = Parser.getNoteFront(raw_json); 189 | return Parser.gsonNoSerialize.toJson(integratedAPI.canAddNotesWithErrorDetail(notes_to_test)); 190 | } 191 | 192 | /** 193 | * Add a new note to Anki. 194 | * The note can include media files, which will be downloaded. 195 | * AnkiConnect desktop also supports other formats, but this method only supports downloadable media files. 196 | */ 197 | private String addNote(JsonObject raw_json) throws Exception { 198 | Map noteValues = Parser.getNoteValues(raw_json); 199 | 200 | ArrayList mediaRequests = 201 | Parser.getNoteMediaRequests(raw_json); 202 | integratedAPI.addMedia(noteValues, mediaRequests); 203 | 204 | String noteId = String.valueOf(integratedAPI.addNote( 205 | noteValues, 206 | Parser.getDeckName(raw_json), 207 | Parser.getModelName(raw_json), 208 | Parser.getNoteTags(raw_json) 209 | )); 210 | 211 | return noteId; 212 | } 213 | 214 | private String updateNoteFields(JsonObject raw_json) throws Exception { 215 | integratedAPI.updateNoteFields( 216 | Parser.getUpdateNoteFieldsId(raw_json), 217 | Parser.getUpdateNoteFieldsFields(raw_json), 218 | Parser.getNoteMediaRequests(raw_json) 219 | ); 220 | return "null"; 221 | } 222 | 223 | private String storeMediaFile(JsonObject raw_json) throws Exception { 224 | BinaryFile binaryFile = new BinaryFile(); 225 | binaryFile.setFilename(Parser.getMediaFilename(raw_json)); 226 | binaryFile.setData(Parser.getMediaData(raw_json)); 227 | 228 | return Parser.gson.toJson(integratedAPI.storeMediaFile(binaryFile)); 229 | } 230 | 231 | private String notesInfo(JsonObject raw_json) throws Exception { 232 | ArrayList noteIds = Parser.getNoteIds(raw_json); 233 | return Parser.gson.toJson(integratedAPI.noteAPI.notesInfo(noteIds)); 234 | } 235 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kamwithk/ankiconnectandroid/routing/ForvoAPIRouting.java: -------------------------------------------------------------------------------- 1 | package com.kamwithk.ankiconnectandroid.routing; 2 | 3 | import android.content.Context; 4 | import android.util.Log; 5 | import com.google.gson.JsonObject; 6 | import com.google.gson.reflect.TypeToken; 7 | import com.kamwithk.ankiconnectandroid.Scraper; 8 | import com.kamwithk.ankiconnectandroid.request_parsers.Parser; 9 | import fi.iki.elonen.NanoHTTPD; 10 | 11 | import java.io.IOException; 12 | import java.lang.reflect.Type; 13 | import java.util.ArrayList; 14 | import java.util.HashMap; 15 | import java.util.List; 16 | import java.util.Map; 17 | 18 | import static fi.iki.elonen.NanoHTTPD.newFixedLengthResponse; 19 | 20 | public class ForvoAPIRouting { 21 | private final Scraper scraper; 22 | 23 | public ForvoAPIRouting(Context context) { 24 | scraper = new Scraper(context); 25 | } 26 | 27 | // Term can also be named expression (older versions) 28 | private String getTerm(List term, List expression) { 29 | try { 30 | return term.get(0); 31 | } catch (NullPointerException e) { 32 | return expression.get(0); 33 | } 34 | } 35 | 36 | public NanoHTTPD.Response getAudio(String word, String reading) throws IOException { 37 | ArrayList> audio_sources = scraper.scrape(word, reading); 38 | 39 | Type typeToken = new TypeToken>>() {}.getType(); 40 | 41 | JsonObject response = new JsonObject(); 42 | response.addProperty("type", "audioSourceList"); 43 | response.add("audioSources", Parser.gson.toJsonTree(audio_sources, typeToken)); 44 | 45 | return newFixedLengthResponse( 46 | NanoHTTPD.Response.Status.OK, 47 | "text/json", 48 | Parser.gson.toJson(response) 49 | ); 50 | } 51 | 52 | public NanoHTTPD.Response getAudioHandleError(List term, List expression, String reading) { 53 | try { 54 | return getAudio(getTerm(term, expression), reading); 55 | } catch (IOException e) { 56 | Log.d("Error Scraping", e.toString()); 57 | 58 | Map response = new HashMap<>(); 59 | response.put("result", null); 60 | response.put("error", e.toString()); 61 | 62 | return newFixedLengthResponse(NanoHTTPD.Response.Status.OK, "text/json", Parser.gson.toJson(response)); 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /app/src/main/java/com/kamwithk/ankiconnectandroid/routing/LocalAudioAPIRouting.java: -------------------------------------------------------------------------------- 1 | package com.kamwithk.ankiconnectandroid.routing; 2 | 3 | import static fi.iki.elonen.NanoHTTPD.newFixedLengthResponse; 4 | 5 | import android.content.Context; 6 | import android.util.Log; 7 | 8 | import androidx.room.Room; 9 | import androidx.sqlite.db.SimpleSQLiteQuery; 10 | 11 | import com.google.gson.JsonObject; 12 | import com.google.gson.reflect.TypeToken; 13 | import com.kamwithk.ankiconnectandroid.request_parsers.Parser; 14 | import com.kamwithk.ankiconnectandroid.routing.database.AudioFileEntryDao; 15 | import com.kamwithk.ankiconnectandroid.routing.database.EntriesDatabase; 16 | import com.kamwithk.ankiconnectandroid.routing.database.Entry; 17 | import com.kamwithk.ankiconnectandroid.routing.database.EntryDao; 18 | import com.kamwithk.ankiconnectandroid.routing.localaudiosource.ForvoAudioSource; 19 | import com.kamwithk.ankiconnectandroid.routing.localaudiosource.JPodAltAudioSource; 20 | import com.kamwithk.ankiconnectandroid.routing.localaudiosource.JPodAudioSource; 21 | import com.kamwithk.ankiconnectandroid.routing.localaudiosource.LocalAudioSource; 22 | import com.kamwithk.ankiconnectandroid.routing.localaudiosource.NHK16AudioSource; 23 | import com.kamwithk.ankiconnectandroid.routing.localaudiosource.Shinmeikai8AudioSource; 24 | 25 | import java.io.ByteArrayInputStream; 26 | import java.io.File; 27 | import java.io.UnsupportedEncodingException; 28 | import java.lang.reflect.Type; 29 | import java.net.URLDecoder; 30 | import java.util.ArrayList; 31 | import java.util.Collections; 32 | import java.util.HashMap; 33 | import java.util.LinkedHashMap; 34 | import java.util.List; 35 | import java.util.Map; 36 | import java.util.Objects; 37 | 38 | import fi.iki.elonen.NanoHTTPD; 39 | 40 | /** 41 | * Local audio in AnkidroidAndroid works similarly to the original python script found at 42 | * https://github.com/Aquafina-water-bottle/jmdict-english-yomichan/tree/master/local_audio 43 | * 44 | * Here are the main differences: 45 | * - The memory-based version is not supported. Only the SQL version is supported. 46 | * - The SQLite3 database is *NOT* dynamically created. This means that the user must copy/paste 47 | * the generated entries.db into the correct place within their Android device. The database 48 | * is not created dynamically in order to make the process of implementing this feature 49 | * as simple as possible. 50 | * - The URIs are different: 51 | * - initial get: 52 | * python: http://localhost:5050/?sources=jpod,jpod_alternate,nhk16,forvo&term={term}&reading={reading} 53 | * android: http://localhost:8765/localaudio/get/&sources=jpod,jpod_alternate,nhk16,forvo&term={term}&reading={reading} 54 | * - audio file get: 55 | * python: http://localhost:5050/SOURCE/FILE_PATH_TO_AUDIO_FILE 56 | * android: http://localhost:8765/localaudio/SOURCE/FILE_PATH_TO_AUDIO_FILE 57 | * - NHK98 is not supported (because the audio files aren't available for the original anyways) 58 | */ 59 | public class LocalAudioAPIRouting { 60 | private final Context context; 61 | 62 | // sourceIdToSource is a LinkedHashMap to preserve insertion order 63 | private final LinkedHashMap sourceIdToSource; 64 | 65 | public LocalAudioAPIRouting(Context context) { 66 | this.context = context; 67 | 68 | // TODO: read config 69 | this.sourceIdToSource = new LinkedHashMap<>(); 70 | this.sourceIdToSource.put("nhk16", new NHK16AudioSource()); 71 | this.sourceIdToSource.put("shinmeikai8", new Shinmeikai8AudioSource()); 72 | this.sourceIdToSource.put("forvo", new ForvoAudioSource()); 73 | this.sourceIdToSource.put("jpod", new JPodAudioSource()); 74 | this.sourceIdToSource.put("jpod_alternate", new JPodAltAudioSource()); 75 | } 76 | 77 | private EntriesDatabase getDB() { 78 | // TODO global instance? 79 | File databasePath = new File(context.getExternalFilesDir(null), "android.db"); 80 | EntriesDatabase db = Room.databaseBuilder(context, 81 | EntriesDatabase.class, databasePath.toString()).build(); 82 | return db; 83 | } 84 | 85 | public NanoHTTPD.Response getAudioSourcesHandleError(Map> parameters) { 86 | 87 | String term = getTerm(parameters); 88 | String reading = getReading(parameters); 89 | List sources = getSources(parameters); 90 | List users = getUser(parameters); 91 | 92 | List> audioSourcesResult = new ArrayList<>(); 93 | List args = new ArrayList<>(); 94 | 95 | 96 | // opens database (creates if doesn't exist) 97 | EntriesDatabase db = getDB(); 98 | EntryDao entryDao = db.entryDao(); 99 | 100 | // query generator based off of the original plugin: 101 | // https://github.com/Aquafina-water-bottle/local-audio-yomichan/blob/master/plugin/db_utils.py 102 | // Filter results WHERE "title" = 'My Title' 103 | String selection = "expression = ?\n" + 104 | "AND (reading IS NULL OR reading = ?)\n"; 105 | args.add(term); 106 | args.add(reading); 107 | 108 | // filters by sources if necessary 109 | if (sources.size() != sourceIdToSource.size()) { 110 | String nQuestionMarks = String.join(",", Collections.nCopies(sources.size(), "?")); 111 | selection += "AND (source in (" + nQuestionMarks + "))\n"; 112 | args.addAll(sources); 113 | } 114 | 115 | // filters by speakers if necessary 116 | if (users.size() > 0) { 117 | String nQuestionMarks = String.join(",", Collections.nCopies(users.size(), "?")); 118 | selection += "AND (speaker IS NULL or speaker in (" + nQuestionMarks + "))\n"; 119 | args.addAll(users); 120 | } 121 | 122 | // How you want the results sorted in the resulting Cursor 123 | // order by source 124 | StringBuilder sortOrder = new StringBuilder("(CASE source "); 125 | for (int i = 0; i < sources.size(); i++) { 126 | sortOrder.append("WHEN ? THEN ").append(i).append("\n"); 127 | args.add(sources.get(i)); 128 | } 129 | sortOrder.append(" END)\n"); 130 | 131 | // order by speakers if necessary 132 | if (users.size() > 0) { 133 | sortOrder.append(", (CASE speaker "); 134 | for (int i = 0; i < users.size(); i++) { 135 | sortOrder.append("WHEN ? THEN ").append(i).append("\n"); 136 | args.add(users.get(i)); 137 | } 138 | sortOrder.append(" END)\n"); 139 | } 140 | 141 | String queryString = "\n" + 142 | "SELECT * FROM entries WHERE (" + selection + ")\n" + 143 | "ORDER BY " + sortOrder + ", reading;"; 144 | 145 | SimpleSQLiteQuery query = new SimpleSQLiteQuery(queryString, args.toArray()); 146 | List entries = entryDao.getSources(query); 147 | 148 | for (Entry entry : entries) { 149 | String source = entry.source; 150 | String file = entry.file; 151 | 152 | LocalAudioSource audioSource = sourceIdToSource.get(entry.source); 153 | if (audioSource == null) { 154 | Log.w("AnkiConnectAndroid", "Unknown audio source: " + source); 155 | continue; 156 | } 157 | 158 | String name = audioSource.getSourceName(entry); 159 | String url = audioSource.constructFileURL(file); 160 | 161 | Map audioSourceEntry = new HashMap<>(); 162 | audioSourceEntry.put("name", name); 163 | audioSourceEntry.put("url", url); 164 | 165 | audioSourcesResult.add(audioSourceEntry); 166 | } 167 | 168 | Type typeToken = new TypeToken>>() {}.getType(); 169 | 170 | JsonObject response = new JsonObject(); 171 | response.addProperty("type", "audioSourceList"); 172 | response.add("audioSources", Parser.gson.toJsonTree(audioSourcesResult, typeToken)); 173 | Log.d("AnkiConnectAndroid", "audio sources json: " + Parser.gson.toJson(response)); 174 | 175 | return newFixedLengthResponse( 176 | NanoHTTPD.Response.Status.OK, 177 | "text/json", 178 | Parser.gson.toJson(response) 179 | ); 180 | } 181 | 182 | private NanoHTTPD.Response audioError(String msg) { 183 | Log.w("AnkiConnectAndroid", msg); 184 | return newFixedLengthResponse( 185 | NanoHTTPD.Response.Status.BAD_REQUEST, // 400, mimics python script 186 | NanoHTTPD.MIME_PLAINTEXT, msg 187 | ); 188 | } 189 | 190 | private String getTerm(Map> parameters) { 191 | try { 192 | return parameters.get("term").get(0); 193 | } catch (NullPointerException e) { 194 | return parameters.get("expression").get(0); 195 | } 196 | } 197 | 198 | private String getReading(Map> parameters) { 199 | return Objects.requireNonNull(parameters.get("reading")).get(0); 200 | } 201 | 202 | private List getUser(Map> parameters) { 203 | List _user = parameters.get("user"); 204 | List users = new ArrayList<>(); 205 | if (_user != null && _user.size() > 0) { 206 | users = List.of(_user.get(0).split(",")); 207 | } 208 | return users; 209 | } 210 | 211 | private List getSources(Map> parameters) { 212 | List sources = parameters.get("sources"); 213 | if (sources != null && sources.size() == 1) { 214 | return List.of(sources.get(0).split(",")); 215 | } 216 | 217 | // default order 218 | return new ArrayList<>(sourceIdToSource.keySet()); 219 | } 220 | 221 | public NanoHTTPD.Response getAudioHandleError(String source, String path) { 222 | if (!sourceIdToSource.containsKey(source)) { 223 | return audioError("Unknown source: " + source); 224 | } 225 | 226 | EntriesDatabase db = getDB(); 227 | AudioFileEntryDao audioFileEntryDao = db.audioFileEntryDao(); 228 | 229 | String pathDecoded = path; 230 | try { 231 | pathDecoded = URLDecoder.decode(pathDecoded, "UTF-8"); 232 | } 233 | catch (UnsupportedEncodingException ignored) { 234 | } 235 | 236 | byte[] data = audioFileEntryDao.getData(pathDecoded, source); 237 | 238 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types 239 | String mimeType = null; 240 | if (path.endsWith(".mp3")) { 241 | mimeType = "audio/mpeg"; 242 | } else if (path.endsWith(".aac")) { 243 | mimeType = "audio/aac"; 244 | } else if (path.endsWith(".m4a")) { 245 | mimeType = "audio/mp4"; 246 | } else if (path.endsWith(".ogg") || path.endsWith(".oga") || path.endsWith(".opus")) { 247 | mimeType = "audio/ogg"; 248 | } else if (path.endsWith(".flac")) { 249 | mimeType = "audio/flac"; 250 | } else if (path.endsWith(".wav")) { 251 | mimeType = "audio/wav"; 252 | } 253 | if (mimeType == null) { 254 | return audioError("File is not a supported audio file: " + path); 255 | } 256 | return newFixedLengthResponse(NanoHTTPD.Response.Status.OK, mimeType, new ByteArrayInputStream(data), data.length); 257 | 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /app/src/main/java/com/kamwithk/ankiconnectandroid/routing/LocalAudioRouteHandler.java: -------------------------------------------------------------------------------- 1 | package com.kamwithk.ankiconnectandroid.routing; 2 | 3 | import static fi.iki.elonen.NanoHTTPD.MIME_PLAINTEXT; 4 | import static fi.iki.elonen.NanoHTTPD.newFixedLengthResponse; 5 | 6 | import android.content.Context; 7 | 8 | import java.util.Map; 9 | 10 | import fi.iki.elonen.NanoHTTPD; 11 | import fi.iki.elonen.router.RouterNanoHTTPD; 12 | 13 | 14 | public class LocalAudioRouteHandler extends RouterNanoHTTPD.DefaultHandler { 15 | private LocalAudioAPIRouting routing = null; 16 | 17 | public LocalAudioRouteHandler() { 18 | super(); 19 | } 20 | 21 | @Override 22 | public String getText() { 23 | return "not implemented"; 24 | } 25 | 26 | @Override 27 | public String getMimeType() { 28 | return "text/json"; 29 | } 30 | 31 | @Override 32 | public NanoHTTPD.Response.IStatus getStatus() { 33 | return NanoHTTPD.Response.Status.OK; 34 | } 35 | 36 | public NanoHTTPD.Response get(RouterNanoHTTPD.UriResource uriResource, Map urlParams, NanoHTTPD.IHTTPSession session) { 37 | // setup ??? 38 | // TODO this looks like a hack (same with the main handler!) 39 | if (routing == null) { 40 | Context context = uriResource.initParameter(0, Context.class); // ??? 41 | routing = new LocalAudioAPIRouting(context); 42 | } 43 | 44 | String uri = session.getUri(); 45 | if (uri.equals("/localaudio/get/")) { // get sources 46 | return routing.getAudioSourcesHandleError(session.getParameters()); 47 | } 48 | 49 | // otherwise, it's getting the actual audio file instead 50 | // the uri should be of the format: /localaudio/SOURCE/FILE_NAME 51 | // components should be: ["", "localaudio", SOURCE, FILE_NAME] 52 | String[] uriComponents = uri.split("/", 4); 53 | if (uriComponents.length != 4) { 54 | return newFixedLengthResponse(NanoHTTPD.Response.Status.BAD_REQUEST, MIME_PLAINTEXT, "Invalid uri: " + uri); 55 | } 56 | return routing.getAudioHandleError(uriComponents[2], uriComponents[3]); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /app/src/main/java/com/kamwithk/ankiconnectandroid/routing/RouteHandler.java: -------------------------------------------------------------------------------- 1 | package com.kamwithk.ankiconnectandroid.routing; 2 | 3 | import static com.kamwithk.ankiconnectandroid.routing.Router.contentType; 4 | 5 | import static fi.iki.elonen.NanoHTTPD.newFixedLengthResponse; 6 | 7 | import android.content.Context; 8 | import android.content.SharedPreferences; 9 | 10 | import androidx.preference.PreferenceManager; 11 | 12 | import com.kamwithk.ankiconnectandroid.ankidroid_api.IntegratedAPI; 13 | 14 | import java.io.IOException; 15 | import java.util.HashMap; 16 | import java.util.List; 17 | import java.util.Map; 18 | 19 | import fi.iki.elonen.NanoHTTPD; 20 | import fi.iki.elonen.router.RouterNanoHTTPD; 21 | 22 | public class RouteHandler extends RouterNanoHTTPD.DefaultHandler { 23 | 24 | private APIHandler apiHandler = null; 25 | private static final String PRIVATE_NETWORK_ACCESS_REQUEST = "Access-Control-Request-Private-Network"; 26 | private static final String PRIVATE_NETWORK_ACCESS_RESPONSE = "Access-Control-Allow-Private-Network"; 27 | 28 | 29 | public RouteHandler() { 30 | super(); 31 | } 32 | 33 | @Override 34 | public String getText() { 35 | return "not implemented"; 36 | } 37 | 38 | @Override 39 | public String getMimeType() { 40 | return "text/json"; 41 | } 42 | 43 | @Override 44 | public NanoHTTPD.Response.IStatus getStatus() { 45 | return NanoHTTPD.Response.Status.OK; 46 | } 47 | 48 | public NanoHTTPD.Response get(RouterNanoHTTPD.UriResource uriResource, Map urlParams, NanoHTTPD.IHTTPSession session) { 49 | // Setup 50 | Context context = uriResource.initParameter(0, Context.class); 51 | if (apiHandler == null) { 52 | apiHandler = new APIHandler(new IntegratedAPI(context), context); 53 | } 54 | 55 | // Enforce UTF-8 encoding (response doesn't always contain by default) 56 | session.getHeaders().put("content-type", contentType); 57 | 58 | Map files = new HashMap<>(); 59 | try { 60 | session.parseBody(files); 61 | } catch (IOException | NanoHTTPD.ResponseException e) { 62 | e.printStackTrace(); 63 | } 64 | 65 | Map> parameters = session.getParameters(); 66 | if (parameters == null || parameters.isEmpty() && files.get("postData") == null) { 67 | // No data was provided in the POST request so we return a simple response 68 | NanoHTTPD.Response rep = newFixedLengthResponse("Ankiconnect Android is running."); 69 | addCorsHeaders(context, rep); 70 | return rep; 71 | } 72 | 73 | NanoHTTPD.Response rep = apiHandler.chooseAPI(files.get("postData"), parameters); 74 | 75 | // Include this header so that if a public origin is included in the whitelist, then browsers 76 | // won't fail due to the private network access check 77 | if (Boolean.parseBoolean(session.getHeaders().get(PRIVATE_NETWORK_ACCESS_REQUEST))) { 78 | rep.addHeader(PRIVATE_NETWORK_ACCESS_RESPONSE, "true"); 79 | } 80 | 81 | addCorsHeaders(context, rep); 82 | return rep; 83 | } 84 | 85 | private void addCorsHeaders(Context context, NanoHTTPD.Response rep) { 86 | // Add a CORS header if it is set in the preferences 87 | SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); 88 | String corsHost = sharedPreferences.getString("cors_host", ""); 89 | 90 | if (!corsHost.trim().equals("")) { 91 | rep.addHeader("Access-Control-Allow-Origin", corsHost); 92 | rep.addHeader("Access-Control-Allow-Headers", "*"); 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /app/src/main/java/com/kamwithk/ankiconnectandroid/routing/Router.java: -------------------------------------------------------------------------------- 1 | package com.kamwithk.ankiconnectandroid.routing; 2 | 3 | import android.content.Context; 4 | import fi.iki.elonen.NanoHTTPD; 5 | import fi.iki.elonen.router.RouterNanoHTTPD; 6 | 7 | import java.io.IOException; 8 | 9 | public class Router extends RouterNanoHTTPD { 10 | private Context context; 11 | public static String contentType; 12 | 13 | public Router(Integer port, Context context) throws IOException { 14 | super(port); 15 | this.context = context; 16 | 17 | contentType = new ContentType("; charset=UTF-8").getContentTypeHeader(); 18 | addMappings(); 19 | start(NanoHTTPD.SOCKET_READ_TIMEOUT, false); 20 | } 21 | 22 | public void setContext(Context context) { 23 | this.context = context; 24 | } 25 | 26 | @Override 27 | public void addMappings() { 28 | addRoute("/", RouteHandler.class, this.context); 29 | addRoute("/localaudio/(.)+", LocalAudioRouteHandler.class, this.context); 30 | // for some reason, none of these work, so the above is used instead 31 | // addRoute("/localaudio/:source/(.)+", LocalAudioRouteHandler.class, this.context); 32 | // addRoute("/localaudio/:source/:file", LocalAudioRouteHandler.class, this.context); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/src/main/java/com/kamwithk/ankiconnectandroid/routing/database/AudioFileEntry.java: -------------------------------------------------------------------------------- 1 | package com.kamwithk.ankiconnectandroid.routing.database; 2 | 3 | import androidx.annotation.NonNull; 4 | import androidx.room.ColumnInfo; 5 | import androidx.room.Entity; 6 | import androidx.room.Index; 7 | import androidx.room.PrimaryKey; 8 | 9 | @Entity(tableName = "android", 10 | indices= { 11 | @Index(name="idx_android", value = {"file", "source"}), 12 | } 13 | ) 14 | public class AudioFileEntry { 15 | @PrimaryKey 16 | public int id; 17 | 18 | @NonNull 19 | @ColumnInfo(name = "file") 20 | public String file; 21 | 22 | @NonNull 23 | @ColumnInfo(name = "source") 24 | public String source; 25 | 26 | @NonNull 27 | @ColumnInfo(typeAffinity = ColumnInfo.BLOB, name = "data") 28 | public byte[] data; 29 | } 30 | 31 | -------------------------------------------------------------------------------- /app/src/main/java/com/kamwithk/ankiconnectandroid/routing/database/AudioFileEntryDao.java: -------------------------------------------------------------------------------- 1 | package com.kamwithk.ankiconnectandroid.routing.database; 2 | 3 | import androidx.room.Dao; 4 | import androidx.room.Query; 5 | 6 | @Dao 7 | public interface AudioFileEntryDao { 8 | @Query("SELECT data FROM android WHERE file = :file AND source = :source") 9 | public byte[] getData(String file, String source); 10 | } 11 | -------------------------------------------------------------------------------- /app/src/main/java/com/kamwithk/ankiconnectandroid/routing/database/EntriesDatabase.java: -------------------------------------------------------------------------------- 1 | package com.kamwithk.ankiconnectandroid.routing.database; 2 | 3 | import androidx.room.Database; 4 | import androidx.room.RoomDatabase; 5 | 6 | @Database(entities = {Entry.class, AudioFileEntry.class}, version = 1) 7 | public abstract class EntriesDatabase extends RoomDatabase { 8 | public abstract EntryDao entryDao(); 9 | public abstract AudioFileEntryDao audioFileEntryDao(); 10 | } 11 | -------------------------------------------------------------------------------- /app/src/main/java/com/kamwithk/ankiconnectandroid/routing/database/Entry.java: -------------------------------------------------------------------------------- 1 | package com.kamwithk.ankiconnectandroid.routing.database; 2 | 3 | import androidx.annotation.NonNull; 4 | import androidx.room.ColumnInfo; 5 | import androidx.room.Entity; 6 | import androidx.room.Index; 7 | import androidx.room.PrimaryKey; 8 | 9 | @Entity(tableName = "entries", 10 | indices= { // TODO remove some indices? 11 | @Index(name="idx_all", value = {"expression", "reading", "source"}), 12 | 13 | @Index(name="idx_reading_speaker", value = {"expression", "reading", "speaker"}), 14 | 15 | @Index(name="idx_expr_reading", value = {"expression", "reading"}), 16 | 17 | @Index(name="idx_speaker", value = {"speaker"}), 18 | 19 | @Index(name="idx_reading", value = {"reading"}), 20 | } 21 | ) 22 | public class Entry { 23 | @PrimaryKey 24 | //@NonNull 25 | @ColumnInfo(name = "id") 26 | public int id; 27 | 28 | @NonNull 29 | @ColumnInfo(name = "expression") 30 | public String expression; 31 | 32 | @ColumnInfo(name = "reading") 33 | public String reading; 34 | 35 | @NonNull 36 | @ColumnInfo(name = "source") 37 | public String source; 38 | 39 | @ColumnInfo(name = "speaker") 40 | public String speaker; 41 | 42 | @ColumnInfo(name = "display") 43 | public String display; 44 | 45 | @NonNull 46 | @ColumnInfo(name = "file") 47 | public String file; 48 | } 49 | -------------------------------------------------------------------------------- /app/src/main/java/com/kamwithk/ankiconnectandroid/routing/database/EntryDao.java: -------------------------------------------------------------------------------- 1 | package com.kamwithk.ankiconnectandroid.routing.database; 2 | 3 | import androidx.room.Dao; 4 | import androidx.room.RawQuery; 5 | import androidx.sqlite.db.SupportSQLiteQuery; 6 | 7 | import java.util.List; 8 | 9 | @Dao 10 | public interface EntryDao { 11 | // https://stackoverflow.com/questions/44287465/how-to-dynamically-query-the-room-database-at-runtime 12 | @RawQuery 13 | List getSources(SupportSQLiteQuery query); 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/java/com/kamwithk/ankiconnectandroid/routing/localaudiosource/ForvoAudioSource.java: -------------------------------------------------------------------------------- 1 | package com.kamwithk.ankiconnectandroid.routing.localaudiosource; 2 | 3 | import com.kamwithk.ankiconnectandroid.routing.database.Entry; 4 | 5 | public class ForvoAudioSource extends LocalAudioSource { 6 | public ForvoAudioSource() { 7 | super("forvo", "user_files/forvo_files"); 8 | } 9 | 10 | @Override 11 | public String getSourceName(Entry entry) { 12 | return "Forvo (" + entry.speaker + ")"; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/java/com/kamwithk/ankiconnectandroid/routing/localaudiosource/JPodAltAudioSource.java: -------------------------------------------------------------------------------- 1 | package com.kamwithk.ankiconnectandroid.routing.localaudiosource; 2 | 3 | import com.kamwithk.ankiconnectandroid.routing.database.Entry; 4 | 5 | public class JPodAltAudioSource extends LocalAudioSource { 6 | public JPodAltAudioSource() { 7 | super("jpod_alternate", "user_files/jpod_alternate_files"); 8 | } 9 | 10 | @Override 11 | public String getSourceName(Entry entry) { 12 | return "JPod101 Alt"; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/java/com/kamwithk/ankiconnectandroid/routing/localaudiosource/JPodAudioSource.java: -------------------------------------------------------------------------------- 1 | package com.kamwithk.ankiconnectandroid.routing.localaudiosource; 2 | 3 | import com.kamwithk.ankiconnectandroid.routing.database.Entry; 4 | 5 | public class JPodAudioSource extends LocalAudioSource { 6 | public JPodAudioSource() { 7 | super("jpod", "user_files/jpod_files"); 8 | } 9 | 10 | @Override 11 | public String getSourceName(Entry entry) { 12 | return "JPod101"; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/java/com/kamwithk/ankiconnectandroid/routing/localaudiosource/LocalAudioSource.java: -------------------------------------------------------------------------------- 1 | package com.kamwithk.ankiconnectandroid.routing.localaudiosource; 2 | 3 | import android.net.Uri; 4 | 5 | import com.kamwithk.ankiconnectandroid.Service; 6 | import com.kamwithk.ankiconnectandroid.routing.database.Entry; 7 | 8 | public class LocalAudioSource { 9 | private final String sourceID; 10 | private final String mediaDir; 11 | 12 | public LocalAudioSource(String sourceID, String mediaDir) { 13 | this.sourceID = sourceID; 14 | this.mediaDir = mediaDir; 15 | } 16 | 17 | public String getSourceName(Entry entry) { 18 | return sourceID; 19 | } 20 | 21 | public String getMediaDir() { 22 | return mediaDir; 23 | } 24 | 25 | public String constructFileURL(String filePath) { 26 | Uri.Builder builder = new Uri.Builder(); 27 | String NETLOC = "localhost:" + Service.PORT; 28 | 29 | builder.scheme("http") 30 | .encodedAuthority(NETLOC) // encoded to not escape the : character 31 | .appendPath("localaudio") 32 | .appendPath(sourceID) 33 | .appendPath(filePath); 34 | String uri = builder.build().toString(); 35 | return uri; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/src/main/java/com/kamwithk/ankiconnectandroid/routing/localaudiosource/NHK16AudioSource.java: -------------------------------------------------------------------------------- 1 | package com.kamwithk.ankiconnectandroid.routing.localaudiosource; 2 | 3 | import com.kamwithk.ankiconnectandroid.routing.database.Entry; 4 | 5 | public class NHK16AudioSource extends LocalAudioSource { 6 | public NHK16AudioSource() { 7 | super("nhk16", "user_files/nhk16_files"); 8 | } 9 | 10 | @Override 11 | public String getSourceName(Entry entry) { 12 | return "NHK16 " + entry.display; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/java/com/kamwithk/ankiconnectandroid/routing/localaudiosource/Shinmeikai8AudioSource.java: -------------------------------------------------------------------------------- 1 | package com.kamwithk.ankiconnectandroid.routing.localaudiosource; 2 | 3 | import com.kamwithk.ankiconnectandroid.routing.database.Entry; 4 | 5 | public class Shinmeikai8AudioSource extends LocalAudioSource { 6 | public Shinmeikai8AudioSource() { 7 | super("shinmeikai8", "user_files/shinmeikai8_files"); 8 | } 9 | 10 | @Override 11 | public String getSourceName(Entry entry) { 12 | return "SMK8 " + entry.display; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 9 | 10 | 16 | 19 | 22 | 23 | 24 | 25 | 31 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/app_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 11 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_settings_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 10 | 12 | 14 | 16 | 18 | 20 | 22 | 24 | 26 | 28 | 30 | 32 | 34 | 36 | 38 | 40 | 42 | 44 | 46 | 48 | 50 | 52 | 54 | 56 | 58 | 60 | 62 | 64 | 66 | 68 | 70 | 72 | 74 | 75 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 22 | 23 | 30 | 31 | 38 | 39 | 48 | 49 |