├── .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 | 
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