Skip to content

Commit 78e2e31

Browse files
authored
Feature/anki import export (#33)
* docs: add Anki import/export feature tracking and implementation plan - Add ANKI_PHASE_TRACKING.md for phase progress tracking - Add ANKI_IMPLEMENTATION_PLAN.md with complete implementation details - Branch: feature/anki-import-export * docs: update Anki implementation plan with critical corrections Major updates: - Media JSON is separate ZIP file, NOT in database - Use file-based SQLite for export (not in-memory) - Add deck name conflict resolution with auto-rename - Use android.text.Html for proper HTML parsing - Add Unicode NFC normalization - Generate thumbnails for imported images - Add database transactions for bulk operations - Register new commands in CommandProviderModule - Clarify exact ZIP structure - Handle HTML entities, line breaks, special characters - Preserve image file extensions - Clean up temp files with try-finally - Maintain RxJava pattern (no blockingGet) - Add comprehensive test cases - Document all user decisions and implementation gotchas * feat: implement Phase 1 Anki package classes with critical fixes - Create anki package with model classes (AnkiNote, AnkiCard, AnkiDeck, AnkiField, AnkiNotetype, AnkiTemplate) - Implement ApkgParser with ZIP extraction, database reading, and JSON parsing - Implement ApkgGenerator with database creation and ZIP packaging - Add all missing fields to model classes to match Anki database schema - Fix critical bugs: missing csum column, UTF-8 encoding, ID collision prevention - Add Card 2 template and latexPre/latexPost fields to Basic notetype - Add proper UTF-8 encoding to media JSON parsing - Fix media extraction regex to prevent directory traversal - Document database closing requirement in ApkgParser * feat: implement Phase 2 Anki import functionality - Create AnkiImporter component with APKG parsing and Flash Deck mapping - Add .apkg format detection in ExportImportCmd - Register AnkiImporter in AppProviderModule - Add Indonesian translation for error_invalid_apkg string - Update implementation plan and phase tracking docs - Update AnkiImporter location to component package - Update method signature to return List<DeckModel> directly - Update registration location to AppProviderModule - Add Indonesian translation file to documentation - Mark Phase 2 as 100% complete * feat: implement Phase 3 Anki export functionality * feat: implement Phase 4 UI updates for Anki .apkg file support * fix: default ExportImportTest * feat: complete Phase 5-6 testing with comprehensive test suite and bug fixes - Add 5 test files with 22 test methods covering import, export, round-trip, and parser - Fix JSONObject iteration issues in ApkgParser.java (lines 111, 221) - Improve resource cleanup in AnkiExporter and AnkiImporter with try-finally blocks - Update documentation with test completion status and file structure - All tests compile successfully, ready for device/emulator execution * fix: ApkgParserTest failures - fix file path, test data, and assertions * fix: AnkiExporterTest image creation uses valid JPEG/PNG structure Replace invalid dummy byte headers with properly structured images using Android's Bitmap API. Fixes BitmapFactory.decodeStream failures during test execution caused by incomplete JPEG/PNG files. * fix: strip Anki media tags from imported text and sort cards by ordinal - Remove [sound:...] tags and object replacement characters from imported question/answer fields - Sort imported cards by ordinal position to maintain order - Fixes AnkiImporterTest failures for voice and image imports * refactor: replace Html.fromHtml with HtmlCompat.fromHtml * docs: update README with Anki integration details and remove temporary documentation * fix: resolve critical issues in Anki import/export components - AnkiImporter: - Fix NPE on null note.flds field - Move Pattern objects from static to instance fields - Optimize media lookup O(n)→O(1) using inverse map - AnkiExporter: - Add null validation for deck.id and deck.name - Throw ValidationException instead of silent fallback to arbitrary deck - Use timestamp in output filename to prevent concurrent overwrites * fix: add null checks and optimize Anki parser/generator performance - Add cursor.isNull() checks for all getString() calls in ApkgParser to prevent NPE - Optimize AnkiExporter deck lookup from O(n²) to O(n) using HashMap - Add parameter validation and null checks to ApkgGenerator insert methods - Improve ID generation collision resistance using timestamp XOR with random - Use explicit StandardCharsets.UTF_8 for media JSON encoding
1 parent 332694f commit 78e2e31

22 files changed

Lines changed: 3364 additions & 3 deletions

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,33 @@ A simple and easy to use flash card app to help you study.
2424
* Add notification timer to periodically asking you question
2525
* Support dark mode and light mode
2626
* Easily export & share your decks to your friends
27+
* Import and export decks in Anki `.apkg` format (supports Basic cards with images and audio)
2728
* Record voices and attach images for the cards
2829
* Create shortcut to show random card from deck for casual study (Android 8 and above)
2930
* Flash bot to smartly suggest list of card to test you
3031

32+
### Anki Integration
33+
34+
The app supports bidirectional import/export with Anki `.apkg` format:
35+
36+
**Supported:**
37+
- Basic notetype cards (Front/Back fields)
38+
- Images (JPEG, PNG) for questions and answers
39+
- Audio recordings (MP3) for questions and answers
40+
- Nested deck hierarchies (flattened to single level with " - " separator)
41+
42+
**Limitations:**
43+
- Only imports Basic notetype cards (other notetypes like Cloze are skipped)
44+
- Anki scheduling/review data is not imported or exported
45+
- Tags are not supported
46+
- HTML content is simplified (line breaks converted, basic image/audio tags preserved)
47+
48+
**Technical Details:**
49+
- Uses file-based SQLite database for export (Anki 2.1 format)
50+
- Generates valid `.apkg` files compatible with Anki Desktop and AnkiMobile
51+
- Auto-resolves deck name conflicts with numeric suffixes
52+
- Gracefully handles missing media files during import
53+
3154
## Project Structure
3255

3356
This project is a multi-module Android application.

app/src/androidTest/java/m/co/rh/id/a_flash_deck/app/ExportImportCmdTest.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
import java.util.concurrent.Executors;
4242

4343
import m.co.rh.id.a_flash_deck.app.provider.command.ExportImportCmd;
44+
import m.co.rh.id.a_flash_deck.app.provider.component.AnkiExporter;
45+
import m.co.rh.id.a_flash_deck.app.provider.component.AnkiImporter;
4446
import m.co.rh.id.a_flash_deck.app.util.provider.TestDatabaseProviderModule;
4547
import m.co.rh.id.a_flash_deck.base.dao.DeckDao;
4648
import m.co.rh.id.a_flash_deck.base.entity.Card;
@@ -74,6 +76,8 @@ public void provides(ProviderRegistry providerRegistry, Provider provider) {
7476
providerRegistry.register(ExecutorService.class, Executors::newSingleThreadExecutor);
7577
providerRegistry.register(ILogger.class, () -> new AndroidLogger(ILogger.VERBOSE));
7678
providerRegistry.register(FileHelper.class, () -> new FileHelper(provider));
79+
providerRegistry.registerLazy(AnkiImporter.class, () -> new AnkiImporter(provider));
80+
providerRegistry.registerLazy(AnkiExporter.class, () -> new AnkiExporter(provider));
7781
}
7882

7983
@Override
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
/*
2+
* Copyright (C) 2021-2026 Ruby Hartono
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
18+
package m.co.rh.id.a_flash_deck.app.provider.component;
19+
20+
import android.content.Context;
21+
22+
import androidx.test.ext.junit.runners.AndroidJUnit4;
23+
import androidx.test.platform.app.InstrumentationRegistry;
24+
25+
import org.junit.After;
26+
import org.junit.Before;
27+
import org.junit.Test;
28+
import org.junit.runner.RunWith;
29+
30+
import java.io.File;
31+
import java.io.IOException;
32+
import java.util.ArrayList;
33+
import java.util.List;
34+
import java.util.concurrent.ExecutorService;
35+
import java.util.concurrent.Executors;
36+
import java.util.zip.ZipFile;
37+
38+
import m.co.rh.id.a_flash_deck.app.util.provider.TestDatabaseProviderModule;
39+
import m.co.rh.id.a_flash_deck.base.dao.DeckDao;
40+
import m.co.rh.id.a_flash_deck.base.entity.Card;
41+
import m.co.rh.id.a_flash_deck.base.entity.Deck;
42+
import m.co.rh.id.a_flash_deck.base.provider.FileHelper;
43+
import m.co.rh.id.alogger.AndroidLogger;
44+
import m.co.rh.id.alogger.ILogger;
45+
import m.co.rh.id.aprovider.Provider;
46+
import m.co.rh.id.aprovider.ProviderModule;
47+
import m.co.rh.id.aprovider.ProviderRegistry;
48+
49+
import static org.junit.Assert.assertNotNull;
50+
import static org.junit.Assert.assertTrue;
51+
import static org.junit.Assert.fail;
52+
53+
/**
54+
* Instrumented test suite for Anki export functionality.
55+
*
56+
* <p>Tests verify that {@link AnkiExporter} correctly creates valid .apkg files
57+
* from Flash Deck entities. Tests cover various scenarios including:</p>
58+
* <ul>
59+
* <li>Simple text-only decks</li>
60+
* <li>Decks with image media (JPEG, PNG)</li>
61+
* <li>Decks with audio media (MP3)</li>
62+
* </ul>
63+
*
64+
* <p>Each test validates:</p>
65+
* <ul>
66+
* <li>APKG file is created successfully</li>
67+
* <li>APKG has valid structure (collection.anki21, media JSON)</li>
68+
* <li>Media files are included with correct numeric naming</li>
69+
* <li>ZIP structure matches Anki specification</li>
70+
* </ul>
71+
*
72+
* @since 1.0
73+
*/
74+
@RunWith(AndroidJUnit4.class)
75+
public class AnkiExporterTest {
76+
private static final String DBNAME = AnkiExporterTest.class.getName() + "-testDb";
77+
78+
private Provider testProvider;
79+
private FileHelper fileHelper;
80+
private DeckDao deckDao;
81+
private File tempDir;
82+
83+
@Before
84+
public void beforeTest() throws IOException {
85+
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
86+
tempDir = new File(appContext.getCacheDir(), "anki_export_test_" + System.currentTimeMillis());
87+
tempDir.mkdirs();
88+
89+
testProvider = Provider.createProvider(appContext, new ProviderModule() {
90+
@Override
91+
public void provides(ProviderRegistry providerRegistry, Provider provider) {
92+
providerRegistry.registerModule(new TestDatabaseProviderModule(DBNAME));
93+
providerRegistry.register(ExecutorService.class, Executors::newSingleThreadExecutor);
94+
providerRegistry.register(ILogger.class, () -> new AndroidLogger(ILogger.VERBOSE));
95+
providerRegistry.register(FileHelper.class, () -> new FileHelper(provider));
96+
}
97+
98+
@Override
99+
public void dispose(Provider provider) {
100+
101+
}
102+
});
103+
104+
fileHelper = testProvider.get(FileHelper.class);
105+
deckDao = testProvider.get(DeckDao.class);
106+
}
107+
108+
@After
109+
public void afterTest() {
110+
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
111+
testProvider.dispose();
112+
appContext.deleteDatabase(DBNAME);
113+
114+
if (tempDir != null && tempDir.exists()) {
115+
deleteDirectory(tempDir);
116+
}
117+
}
118+
119+
/**
120+
* Tests exporting a simple deck with no media files.
121+
*
122+
* <p>This test verifies that:</p>
123+
* <ul>
124+
* <li>APKG file is created and exists</li>
125+
* <li>File has correct .apkg extension</li>
126+
* <li>File has non-zero size</li>
127+
* <li>ZIP contains required collection.anki21 database</li>
128+
* <li>ZIP contains media JSON file</li>
129+
* </ul>
130+
*/
131+
@Test
132+
public void exportSimpleDeck_createsValidApkg() throws IOException {
133+
Deck deck = AnkiTestDataHelper.createTestDeck("Export Test");
134+
deckDao.insertDeck(deck);
135+
136+
Card card = AnkiTestDataHelper.createTestCard(deck.id, 1, "Question", "Answer");
137+
deckDao.insertCard(card);
138+
139+
AnkiExporter exporter = new AnkiExporter(testProvider);
140+
File apkgFile = exporter.exportApkg(new ArrayList<>(List.of(deck)));
141+
142+
assertNotNull(apkgFile);
143+
assertTrue(apkgFile.exists());
144+
assertTrue(apkgFile.getName().endsWith(".apkg"));
145+
assertTrue(apkgFile.length() > 0);
146+
147+
validateApkgStructure(apkgFile);
148+
}
149+
150+
/**
151+
* Tests exporting a deck with question and answer images.
152+
*
153+
* <p>This test verifies that:</p>
154+
* <ul>
155+
* <li>Image files are correctly copied into ZIP</li>
156+
* <li>Images use numeric filenames (0, 1, 2...)</li>
157+
* <li>Media JSON correctly maps numeric IDs to filenames</li>
158+
* <li>Expected number of media files are present</li>
159+
* </ul>
160+
*/
161+
@Test
162+
public void exportDeckWithImages_includesMediaInApkg() throws IOException {
163+
Deck deck = AnkiTestDataHelper.createTestDeck("Deck with Images");
164+
deckDao.insertDeck(deck);
165+
166+
File testImage = AnkiTestDataHelper.createTestImageFile(tempDir, "test_image.jpg");
167+
String imageName = fileHelper.createCardQuestionImage(testImage, "test_image.jpg").getName();
168+
169+
File testAnswerImage = AnkiTestDataHelper.createTestPngFile(tempDir, "test_answer.png");
170+
String answerImageName = fileHelper.createCardAnswerImage(testAnswerImage, "test_answer.png").getName();
171+
172+
Card card = AnkiTestDataHelper.createTestCardWithImages(
173+
deck.id, 1, "What is this?", "An image",
174+
imageName, answerImageName
175+
);
176+
deckDao.insertCard(card);
177+
178+
AnkiExporter exporter = new AnkiExporter(testProvider);
179+
File apkgFile = exporter.exportApkg(new ArrayList<>(List.of(deck)));
180+
181+
assertNotNull(apkgFile);
182+
assertTrue(apkgFile.exists());
183+
184+
validateApkgStructure(apkgFile);
185+
validateMediaInApkg(apkgFile, 2);
186+
}
187+
188+
/**
189+
* Tests exporting a deck with question voice recording.
190+
*
191+
* <p>This test verifies that:</p>
192+
* <ul>
193+
* <li>Audio files are correctly copied into ZIP</li>
194+
* <li>Audio uses numeric filename</li>
195+
* <li>Media JSON correctly maps numeric ID to audio filename</li>
196+
* <li>Expected number of media files are present</li>
197+
* </ul>
198+
*/
199+
@Test
200+
public void exportDeckWithVoice_includesAudioInApkg() throws IOException {
201+
Deck deck = AnkiTestDataHelper.createTestDeck("Deck with Voice");
202+
deckDao.insertDeck(deck);
203+
204+
File testVoice = AnkiTestDataHelper.createTestAudioFile(tempDir, "test_audio.mp3");
205+
String voiceName = fileHelper.createCardQuestionVoice(testVoice, "test_audio.mp3").getName();
206+
207+
Card card = AnkiTestDataHelper.createTestCardWithVoice(
208+
deck.id, 1, "Say this", "Hello",
209+
voiceName, null
210+
);
211+
deckDao.insertCard(card);
212+
213+
AnkiExporter exporter = new AnkiExporter(testProvider);
214+
File apkgFile = exporter.exportApkg(new ArrayList<>(List.of(deck)));
215+
216+
assertNotNull(apkgFile);
217+
assertTrue(apkgFile.exists());
218+
219+
validateApkgStructure(apkgFile);
220+
validateMediaInApkg(apkgFile, 1);
221+
}
222+
223+
/**
224+
* Validates that APKG ZIP contains required entries.
225+
*
226+
* <p>Checks for presence of:</p>
227+
* <ul>
228+
* <li>collection.anki21 - main SQLite database</li>
229+
* <li>media - JSON mapping file</li>
230+
* </ul>
231+
*
232+
* @param apkgFile the exported .apkg file to validate
233+
* @throws IOException if ZIP file cannot be read
234+
*/
235+
private void validateApkgStructure(File apkgFile) throws IOException {
236+
try (ZipFile zipFile = new ZipFile(apkgFile)) {
237+
assertNotNull(zipFile.getEntry("collection.anki21"));
238+
assertNotNull(zipFile.getEntry("media"));
239+
}
240+
}
241+
242+
/**
243+
* Validates that APKG ZIP contains expected number of media files.
244+
*
245+
* <p>Media files in Anki .apkg format are stored with numeric names
246+
* (0, 1, 2, etc.). This method counts files matching that pattern
247+
* and verifies the count matches expectations.</p>
248+
*
249+
* @param apkgFile the exported .apkg file to validate
250+
* @param expectedMediaCount number of media files expected
251+
* @throws IOException if ZIP file cannot be read
252+
*/
253+
private void validateMediaInApkg(File apkgFile, int expectedMediaCount) throws IOException {
254+
try (ZipFile zipFile = new ZipFile(apkgFile)) {
255+
int mediaFileCount = 0;
256+
java.util.Enumeration<? extends java.util.zip.ZipEntry> entries = zipFile.entries();
257+
while (entries.hasMoreElements()) {
258+
java.util.zip.ZipEntry entry = entries.nextElement();
259+
String name = entry.getName();
260+
if (name.matches("^\\d+$")) {
261+
mediaFileCount++;
262+
}
263+
}
264+
265+
if (mediaFileCount != expectedMediaCount) {
266+
fail("Expected " + expectedMediaCount + " mediaFileCount, found " + mediaFileCount);
267+
}
268+
}
269+
}
270+
271+
private void deleteDirectory(File directory) {
272+
if (directory == null || !directory.exists()) {
273+
return;
274+
}
275+
File[] files = directory.listFiles();
276+
if (files != null) {
277+
for (File file : files) {
278+
if (file.isDirectory()) {
279+
deleteDirectory(file);
280+
} else {
281+
file.delete();
282+
}
283+
}
284+
}
285+
directory.delete();
286+
}
287+
}

0 commit comments

Comments
 (0)