119 Commits
Author SHA1 Message Date
OneWay 731340fd13 Release 1.8.0
- Search loads all prefix matches; broad prefix searches are much faster
- Fixed indexing progress freezing on large dictionaries
- Large articles open faster
- Numbered results sort in numeric order
- UI improvements
- Updated libraries and build tooling
2026-10-14 01:53:38 +08:00
OneWay 3930b7bee9 docs(store): refresh screenshots 2026-10-12 15:41:29 +08:00
OneWay 911e8b61f6 feat(settings): badge dictionaries that have updates
Report which sources have a newer file instead of a single flag, and
show a Material badge at the top-end corner of each such dictionary's
icon. The section icon carries the badge while the section is
collapsed.
2026-10-12 15:38:55 +08:00
OneWay c9d1a3439c feat(search): line up the first result with the article title bar
With the search bar at the bottom, offset the results list so the
first result sits on the article title row and the back button
lands under the finger, in single- and two-pane layouts alike.
2026-10-12 15:36:18 +08:00
OneWay 41bc116c56 feat(download): show progress before a link download starts
The source check, folder access and request ran before the first
byte with no visible feedback. Enter the loading state on tap and
show an indeterminate wavy indicator until download progress is
known. File import is unchanged. An early cancel deletes only files
created by the current download, so installed dictionaries survive.
2026-10-12 15:33:42 +08:00
OneWay 7af9930667 chore(deps): bump material3 to 1.5.0-beta01
Needed for the expressive wavy progress indicator. Move the sheet
states to rememberBottomSheetState, drop opt-ins for APIs that are
now stable, and read the window height from LocalWindowInfo.
2026-10-12 15:31:07 +08:00
OneWay a4197d2970 fix(search): load all prefix matches in one ranked pass
Rows were read via peek(), which gives Paging no access hints, so
prefix matches beyond the first page never loaded. Drop the head/tail
paging: a query reads the whole prefix range of every active
dictionary in one scan, merges and ranks it, and hands Paging a single
page. The append error row and its strings go away; the prefix scan
reuses search() with substring matches disabled. FileSource reads
through a buffer, cutting per-entry syscall overhead.
2026-10-12 02:34:02 +08:00
OneWay 4fc765382e refactor(trace): remove unused flow section constants 2026-10-11 22:53:55 +08:00
OneWay c6ec14da3e fix(indexing): keep parsing progress moving
The parse phase estimated progress from a line-count formula that hit
its ceiling early, so the percentage froze on large dictionaries.
Estimate it from the position in the decompressed stream against the
uncompressed size instead.
2026-10-11 22:25:23 +08:00
OneWay 2fdcae6ae3 fix(notifications): throttle dictionary progress reposts
A fast download reposted the ongoing notification several times per
second. Publish stage changes immediately and rate-limit intermediate
updates to the latest snapshot.
2026-10-11 22:22:08 +08:00
OneWay ae4bffe516 chore(deps): bump Gradle wrapper to 9.8.1
Patch release with Windows wrapper script fixes.
2026-10-10 14:09:00 +08:00
OneWay ff54287916 chore(deps): bump the dependency-versions plugin to 0.65.0 2026-10-10 01:58:43 +08:00
OneWay 318d05350e chore(deps): bump paging to 3.5.2
Patch release fixing an append() call made before the initial refresh.
2026-10-10 01:58:19 +08:00
OneWay 4aeaca4a98 chore(deps): bump Kotlin to 2.4.21 2026-10-10 01:57:43 +08:00
OneWay 01ef1dee2c perf(profile): cover startup, search and article-open paths
Cover the startup path, per-keystroke search with index reads and
ranking, the dictzip article read, block merge and text warm-up,
and dictionary indexing; they previously ran as JIT-warmed or
interpreted code.
2026-10-10 01:13:18 +08:00
OneWay 5ad184ba58 refactor(article): warm up the blocks that are actually rendered
The screen merges small parser blocks into larger render blocks,
while the warm-up measured the unmerged parser blocks. Merge once in
the view model and keep the merged blocks in the article state, so
the screen and the warm-up share exactly what will be drawn.
2026-10-10 01:13:03 +08:00
OneWay 381ce33017 fix(article): warm up large single-block articles
The warm-up ran only above a block-count threshold, so an article
that parses into one large block never qualified. Trigger on the
total character count as well.
2026-10-10 01:12:48 +08:00
OneWay f3ecc3086c fix(search): order numbered headwords by numeric value
The final ranking tie-break compared headwords lexicographically,
so entries numbered with digits or CJK numerals sorted by text
instead of by number. Compare runs of digits or numeral characters
by their numeric value, saturating on very long runs.
2026-10-10 01:12:33 +08:00
OneWay ad3d4f83fb fix(search): pad the bottom of the search history sheet 2026-10-09 13:03:59 +08:00
OneWay 4e75c7d865 Release 1.7.0
- Added search history
- The search bar can be placed at the top or bottom of the screen
- Fixed headwords with stress marks displaying truncated in search results
- Improved download, indexing and navigation stability
- The About screen opens faster
- Updated build tooling and code cleanup
2026-10-07 01:33:27 +08:00
OneWay 5a0426c864 fix(main): populate the search field and results on history click in wide mode
HistoryEntryClicked in wide mode only called SearchAndLoadArticle,
which never updates searchQuery, so the field stayed empty and the
results pager took the query.isBlank() branch with PagingData.empty().
Now dispatch onSearchQueryChanged first, then SearchAndLoadArticle.
2026-10-07 00:43:15 +08:00
OneWay 5b6bb08248 fix(article): align the back arrow with the title in compact mode
In the two-pane compact title bar the arrow sat slightly above
the first title line; offset it down to optically center it.
2026-10-07 00:43:15 +08:00
OneWay cea46c0955 fix(parser): keep the full headword in the display text
The display form was truncated at the first formatting tag, so headwords
with a mid-word stress mark displayed as their prefix only. Build the
display from the full text with tags stripped and render stress marks as
a combining acute accent. Bump the index version so existing indexes
rebuild.
2026-10-06 12:23:50 +08:00
OneWay 37127b7bc9 fix(about): correct the version display
Drop the v prefix so the version shows as a plain version name, and
align it by baseline with the app name instead of the bottom edge of
the headline line box.
2026-10-06 11:10:19 +08:00
OneWay 84fcd77f02 feat(settings): split pasted dictionary URLs into separate fields
Pasting several URLs separated by spaces, newlines or commas into one
field now distributes them across separate fields when every token is a
valid source template. Previously the joined text passed validation as a
single URL and failed at download time.
2026-10-06 11:08:37 +08:00
OneWay 3d83d04cea style: address IDE warnings
Remove unused imports and an unused spacing constant, drop redundant if
checks and redundant opt-in markers, replace loop variables with
destructuring, convert a string concatenation to a template, use
Configuration.screenHeightDp instead of LocalWindowInfo.containerSize, and
opt in to ExperimentalSerializationApi for decodeFromStream.
2026-10-06 09:42:54 +08:00
OneWay 4b627b4d05 fix(search): rebuild the results pager only when the dictionary set changes
Background metadata loading rewrote the dictionaries flow several times
after startup, and every emission recreated the Pager: the results list
visibly collapsed and refilled. Distinct on the index paths that paging
actually uses, and seed the state synchronously instead of an empty list.
2026-10-06 09:42:54 +08:00
OneWay 5c655e769c fix(settings): skip the startup scan when dictionaries are already loaded
A second activity instance (PROCESS_TEXT) ran the full directory scan
again, and the main screen stayed blank until the scan finished. The
repository is a process-wide singleton: when it already holds
dictionaries, mark them up to date and load preferences immediately.
2026-10-06 09:42:54 +08:00
OneWay 0c4ab6dad5 fix(search): keep PROCESS_TEXT on the results screen
handleExternalSearch auto-opened the article on an exact match, so the main
screen with the search bar flashed before the article covered it. Only set
the query; the article now opens from the results list.
2026-10-06 09:42:54 +08:00
OneWay 87dccb4993 fix(article): stabilize baseTextStyle across recompositions
TextStyle.copy() returned a new instance on every recomposition, so the
remember() cache for the rendered blocks never hit. A text selection re-ran
block merging and recreated all article text nodes, rebuilding their
StaticLayouts repeatedly. Hoist the theme values and remember the style.
2026-10-06 09:42:54 +08:00
OneWay 9de47c7f0f perf(about): parse the license catalog with decodeFromStream
Stream the bundled license catalog straight into the JSON decoder
instead of buffering the whole resource as a string first, halving
parse time and peak allocation when the About screen loads.
2026-10-05 05:18:57 +08:00
OneWay 9b6a34b607 fix(settings): write the no-dictionaries seed only after a completed scan
A racing SettingsViewModel whose scan short-circuits with an in-progress
error persisted hadNoDictionaries from a still-empty list, forcing the
Settings screen on the next cold start despite existing dictionaries.
2026-10-05 05:16:21 +08:00
OneWay d2c9dd7701 fix(settings): pin Add Source container to static insets
With many fields, the Add Source sheet could keep re-triggering its
settle animation: animated window insets reached the container while
its height followed the content. Keep only the static status bar
inset on the sheet container; the content already pads itself for
the navigation bar and IME.
2026-10-04 12:36:00 +00:00
OneWay f227546111 fix(search): remove result row fade-in when the query changes
Remove the fade-in animation from result rows so that items render
directly when the query changes.
2026-10-04 12:33:00 +00:00
OneWay cec914ed00 feat(search): search history bottom sheet
Record queries on explicit search and keep up to 100 entries.
Show them in a bottom sheet opened from the search bar, with
per-entry removal and clear-all.
2026-10-04 12:30:00 +00:00
OneWay 73384bc0eb feat(settings): place the search bar at the top or the bottom
Add a search bar position setting and render the bar above the
results or below them, with window insets applied to match.
2026-10-04 12:24:00 +00:00
OneWay c4e4664542 feat(article): adaptive compact top bar
Top-align the title row in compact layouts, align the back button
with the first title line using the shared back button, make the
title start padding depend on whether the back button is shown,
and drop the spacing constant the old arrow buttons used.
2026-10-04 12:21:00 +00:00
OneWay 59dfb53756 fix(ui): align back arrows with the search bar leading icon
Use one shared back button with a matching icon size, touch target
and start padding on the settings and about screens.
2026-10-04 12:18:00 +00:00
OneWay e423bd6c58 refactor(main): expose cold-start preferences as an InitialLaunch flow
Read the theme, locale, empty-library seed and section expansion
once in the application and collect them as state, instead of
blocking the UI thread with synchronous reads before setContent.
2026-10-04 12:15:00 +00:00
OneWay 0bae625c01 feat(settings): enter multiple dictionary source URLs in one sheet
Allow adding and removing URL fields in the Add Source sheet,
validate the whole form at once, and sanitize entered URLs.
2026-10-04 12:12:00 +00:00
OneWay 344852a871 fix(download): verify payload freshness before restoring the update state
On restart the update-available state was restored purely from the
saved date. Also compare the installed payload file names against
dated sources, and re-check when they no longer match.
2026-10-04 12:09:00 +00:00
OneWay 6146c0ebfe fix(article): cancel link press only after touch slop
An already-consumed down change is part of another gesture, not a
reason to cancel the link press. Only treat movement past touch
slop as cancellation.
2026-10-04 12:06:00 +00:00
OneWay 1202662afe fix(storage): retry index file creation while the target is busy
A just-deleted index file can still be held by the system, so
createNewFile() may fail right after deletion. Retry a few times
with a short delay, waiting for the old file to disappear first.
2026-10-04 12:03:00 +00:00
OneWay c90f658bfe fix(settings): accept dictionary source URLs over https only
The platform blocks cleartext traffic by default, so an http:// source
passed validation but could never download and failed with a misleading
network error. The validator now rejects http:// upfront.
2026-09-27 12:30:00 +08:00
OneWay cd589b30a4 fix(download): don't show the update card for current dictionaries
areDictionariesUpToDate() mapped every exception to NeedsUpdate, and
the post-update recheck ran after the first indexed source of a batch,
before later sources finished downloading; the interim NeedsUpdate
persisted for the day. Treat unexpected errors as up to date, rethrow
cancellation, and recheck once when the download batch completes.
2026-09-27 11:00:00 +08:00
OneWay 65684b291d build: bump Gradle wrapper to 9.8.0 2026-09-26 08:27:45 +08:00
OneWay 35f2ded634 refactor: clean up redundant calls and replace deprecated APIs
Unnecessary safe call on a non-null File receiver; unused catch parameters renamed to _; constructor parameter used only in init no longer held as a property; deprecated Configuration.screenHeightDp replaced with LocalWindowInfo.containerSize; redundant filterNotNull on Paging items; deprecated invisibleToUser() replaced with hideFromAccessibility(); redundant alias variable inlined; redundant else in an exhaustive when removed; paging snapshot read moved into the SideEffect that consumes it.
2026-09-26 08:17:09 +08:00
OneWay c7f658dd94 fix(navigation): keep article content during exit animation
On back navigation the article state was cleared before the exit animation ran, so the panel animated out empty except for the back arrow. The overlay now retains the last shown article state until the exit transition completes.
2026-09-26 00:38:57 +08:00
OneWay ed4a4436b8 fix(navigation): keep Home composed while article is open
Article was an AnimatedContent target: opening it disposed Home, and returning from it recreated the whole results list in a single frame. The article screen now renders as a full-screen AnimatedVisibility overlay above Home, which stays composed for the whole article session.
2026-09-25 23:59:45 +08:00
OneWay c7c080939c Release 1.6.0
- The app starts faster
- Smooth screen transition animations
- Articles render DSL line breaks, indentation and named colors correctly
- Tabs and extra spaces inside articles no longer break the layout
- The article title can be copied, tapping the article clears the selection
- Source URLs accept host:port addresses
- Dictionary downloads retry automatically when the network switches
- Download progress notifications are more reliable
- Search results clear instantly when the query is erased
- Leftover files from cancelled downloads are now removed automatically
- Improved app stability
- Faster dictionary indexing
- Updated build tooling and documentation
- Long link-heavy articles no longer freeze while scrolling
- Large search result lists render faster
- Dark article colors are readable in the dark theme
2026-09-24 13:00:00 +08:00
OneWay 2c8668dab3 build: update core to 1.19.1 2026-09-24 12:00:00 +08:00
OneWay e4e50db08e fix(ui): clear article title selection on any tap
The title SelectionContainer covered only the text, so a tap on the empty bar area or the article body never cleared the highlight. A tap anywhere on the article screen now recreates the container and drops the selection.
2026-09-24 10:00:00 +08:00
OneWay 98ec38f272 fix(ui): remap dark article colors in dark theme
Colors set by dictionaries such as navy, dark red and brown are nearly invisible on the dark background. Swap the dark named colors for lighter tones when rendering in the dark theme; the light theme keeps the original colors.
2026-09-24 09:00:00 +08:00
OneWay 6040ca8db5 fix(ui): merge article blocks to fix scroll jank
Link-heavy articles split every line into its own list item, so a
fast fling composed a dozen items in one frame and froze. Merge small
blocks into multi-paragraph items: indent via em space and a uniform
line pitch via LineHeightStyle(Proportional, Trim.Both).
2026-09-23 09:30:00 +08:00
OneWay 71fea4472b fix(ui): read paging snapshot directly
Replace the per-index peek loop with the item snapshot list;
the loop walked the whole result set on every recomposition.
2026-09-23 09:00:00 +08:00
OneWay b5fc7e7246 fix(ui): keep previous search results while reloading
Keep showing the last results while a new search generation loads,
and guard list access: the paging snapshot can shrink mid-layout,
which threw and crashed on an empty result set.
2026-09-22 00:37:48 +08:00
OneWay 447d086cdf fix(dsl): close margin blocks at the closing tag
Blocks stayed open until the next character arrived, so following text
merged into them and trailing escapes dropped. Close each block right
at its tag and append escaped characters unconditionally.
2026-09-21 22:08:45 +08:00
OneWay ac9a22dbe1 fix(dsl): render [p] labels in italics
Style and grammar labels printed upright while sources mark the same
words in different ways, so identical labels rendered inconsistently.

Every [p] block now uses the label color with italic posture, and the
text scan for specific label words is removed.
2026-09-21 21:53:32 +08:00
OneWay cf29f19511 fix(dsl): render tabs as a fixed-width gap
A literal tab advanced text to the next platform tab stop, which pushed
the rest of the line far enough to wrap it mid-phrase.

Replace each tab with an en space so the separator keeps a fixed, modest
width and the line breaks like ordinary text.
2026-09-21 20:11:40 +08:00
OneWay ed1e1db7f2 chore(dsl): drop stale cache comments 2026-09-21 17:43:15 +08:00
OneWay a5b54def51 fix(dsl): keep single spaces after opening tags
Dictionaries use a space right after an opening tag as a visible
separator, and dropping it glued words together.

Drop only space runs at the start of a block and collapse runs to one
space everywhere else; spaces after opening tags are now kept.
2026-09-21 16:35:08 +08:00
OneWay ee19f84f91 fix(ui): align article title with the first search result in two panes
In landscape the detail pane rendered the title inside a fixed 56.dp
bar and centered the text in it, so the headword sat about 14 dp lower
than the first search result next to it. Add a compact variant of the
title bar without the fixed height; the title line now shares the top
offset of the first list row.
2026-09-21 14:39:12 +08:00
OneWay 4e42a269a7 fix(ui): clear text selection on tap anywhere in the article
Every block of the dictionary article was wrapped in its own
SelectionContainer, so a tap in one block never cleared a highlight
held in another. A single container now wraps the article list — a tap
anywhere clears the selection and selection can span adjacent blocks.
2026-09-21 13:25:41 +08:00
OneWay 4caf3a534d fix(ui): make the article title selectable
The title Text sat outside any SelectionContainer, so the headword
could not be copied.
2026-09-21 13:24:41 +08:00
OneWay c0c9211b82 fix(dsl): render each margin on its own line
Each [m] block occupies its own line in the article. Blank paragraphs
and whitespace-only lines are dropped.
2026-09-21 10:27:34 +08:00
OneWay f5880d72ad feat(ui): animate dictionaries section expand and collapse 2026-09-21 09:01:12 +08:00
OneWay 95f4317eaf fix: honest indexing progress and preparing phase label 2026-09-21 09:00:12 +08:00
OneWay df96deb1fc fix: clear stale terminal notification when a new flow starts 2026-09-21 08:59:12 +08:00
OneWay 451267688a fix: clear import progress notification stuck at 100%
The progress notification was detached from the foreground
service on pipeline finish, so a process killed mid-import left an
ongoing notification orphaned in the shade until the next app launch.
Terminal notifications now use their own id, and finishing the
pipeline removes the bound progress notification; swiping the app
from recents cleans it up as well.
2026-09-21 00:05:48 +08:00
OneWay 1d84eaf9c0 fix: reject bare .dz source URLs and make extension validation case-insensitive
A bare .dz file (DictZip without the .dsl prefix) is treated by the
pipeline as an orphaned file and deleted right after the download, so
a .dz source URL passed validation but silently produced no
dictionary. Accept only DictZip-compressed DSL (.dsl.dz; .gz already
covers .dsl.gz) and compare extensions case-insensitively.
2026-09-20 23:14:54 +08:00
OneWay 59115ff9a3 fix: ignore dateless sources when checking dictionary freshness
A source URL without the {DATE} placeholder is date-invariant, so its
HEAD probe always answers 200. Including such sources in the freshness
check made the checker report "server has a newer version" regardless
of the dated sources' state.
2026-09-20 23:11:54 +08:00
OneWay eb918ca09f fix: accept host:port in dictionary source URLs
URL validation rejected any host containing a colon, so sources on
non-standard ports could not be added. Strip the optional :port from
the host before applying the domain checks and validate the port
range (1-65535); the port is kept intact through template
normalization and URL building.
2026-09-20 21:45:07 +08:00
OneWay 638e886498 chore(deps): bump Android Gradle Plugin to 9.4.1 and Kotlin to 2.4.20 2026-09-19 16:21:09 +08:00
OneWay c2b2bf1c8a chore(deps): bump Compose BOM to 2026.09.00
Measure-pass defect in compose ui 1.12.0: layoutChildrenBlock clobbered
the isPlacingForAlignment flag of child nodes during alignment
placement, so sheet anchors were recalculated from a stale content size.

compose ui 1.12.1 preserves and restores child placement flags, so the
anchor is computed from a consistent content size on the first frame.
2026-09-19 15:20:16 +08:00
OneWay 30aa86f39f fix(search): drop the open article when the query is cleared 2026-09-19 15:20:16 +08:00
OneWay 8a506763a5 feat(ui): always use two-pane layout in landscape 2026-09-18 02:15:00 +08:00
OneWay 4ce39d0e4f feat(ui): smooth screen transitions and list entrances 2026-09-18 02:11:00 +08:00
OneWay 058330dd34 fix(ui): align article title bar with first search result 2026-09-18 02:05:00 +08:00
OneWay f39aa2a341 fix(ui): align content start offset between search list and article 2026-09-18 02:02:00 +08:00
OneWay f51684367a fix(dsl): render articles faithfully
Split lines on LF, CRLF and lone CR like the indexer, strip leading whitespace from body lines, break the paragraph after a closing margin tag, and apply named colors from [c] tags.
2026-09-18 01:50:00 +08:00
OneWay bddcb99177 fix: drop "No article selected" flash on back from article
ClearArticle nulls state before exit transition ends, so the outgoing screen briefly rendered the null-branch fallback in the title bar. Render empty title/content for null state; remove article_no_selected.
2026-09-15 23:19:29 +08:00
OneWay 2dadfa11e9 chore: add MIT license
Add the MIT LICENSE file (Copyright (c) 2026 OneWay), link it from the README, and draft the 1.6.0 changelog.
2026-09-15 18:39:00 +08:00
OneWay 2394c7cf5e fix(settings): evaluate update availability on operations and startup
Re-evaluate after indexing completes, persist the last status with its check date, and restore it on startup. When the saved date is older than today the status is re-evaluated once, so new dictionary updates stay visible without a server request on every launch.
2026-09-15 16:55:00 +08:00
OneWay 830f99578a build(release): strip Log warnings/errors from release dex 2026-09-15 14:51:49 +08:00
OneWay 214b9f91e4 perf(about): eliminate all-row recomposition on library expand 2026-09-14 20:04:51 +08:00
OneWay edf6a6074e fix: clean up orphaned and partial files after cancelled downloads
Treat inactive download contexts as cancellation, delete the partial file, and
rethrow the cancellation so the pipeline does not retain an incomplete payload.
Also remove orphaned .dsl/.dz files left behind by cancelled downloads.
Run the partial/orphan deletions on NonCancellable: the cleanup is invoked from
already-cancelled coroutines, where withContext(Dispatchers.IO) aborts before
executing the block and silently leaves the files behind.
2026-09-14 07:50:30 +08:00
OneWay 8319973161 fix: restore boundary progress publishes and animate the bar at constant velocity 2026-09-11 16:37:23 +03:00
OneWay 1631e5384c chore: drop the unused indexing_label string 2026-09-11 16:28:23 +03:00
OneWay 1d4990e5b7 refactor: slim IndexingProgress to a producer-supplied [0,1] progress
perFileProgress was never populated and its progress branch was
unreachable; currentFile, currentIndex, currentFileProgress and label
were write-only (no consumer read them); progressPercent had no
callers; the -1f aggregate sentinel is gone along with the fallback
branches that only fired when the tracker was absent.
2026-09-11 16:28:23 +03:00
OneWay 8c6e43f3db fix: latch terminal notification state and post pipeline progress on one thread
A late ImportState Success snapshot collected after the success
notification overwrote it with an ongoing progress record. Post
progress on Main behind a volatile terminal latch; notifications go
through NotificationPort, the pipeline lives in the coordinator, and
the service finish is injected.
2026-09-06 15:32:35 +08:00
OneWay 2f22144d19 fix: defer import errors until the copy loop finishes
Errors and skips are collected while the copy loop runs and reported
once it ends. The final import state is exhaustive - success, already
added, invalid name, or nothing supported found - so every import run
reaches a terminal state and the pipeline notification always resolves
instead of lingering as an indeterminate progress bar.
2026-09-05 21:55:01 +08:00
OneWay 9807a0cf27 fix: tie back arrow to pipeline idle state
Computed from operation states in SettingsViewModel instead of a fixed
delay; cancel and error release the arrow immediately.
2026-09-05 05:55:56 +08:00
OneWay de27eecd9e fix: key search result rows by dictionary, offset and word
Paging result rows had no keys and were matched by position: appended
pages and query diffs could not reuse row state. Keys derived from
dictionaryPath+offset+word are stable per row, and the paging source
collapses duplicate entries for one dictionary card: case and diacritic
alias variants of a headword share the normalized word, so entries are
deduplicated by dictionaryPath+word+offset before emitting.
2026-09-05 03:00:44 +08:00
OneWay c4b3a57ccb perf(about): parse aboutlibraries.json off the main thread
Bump AboutLibraries plugin to 15.0.4 -> 15.2.0; parse
aboutlibraries.json on Dispatchers.IO via produceState (header renders
immediately, rows fill in); drop animateContentSize from rows.
2026-09-05 00:37:41 +08:00
OneWay d96fad9cab fix: open Settings immediately on cold start when no dictionaries
Seed and synchronously read hadNoDictionaries before setContent so
Settings shows from the first frame until the startup scan confirms.
2026-09-04 19:09:19 +08:00
OneWay e21a134d65 fix: hide dictionary rows from accessibility while mutations blocked
invisibleToUser() removes blocked content from the accessibility tree;
mergeDescendants only regrouped traversal order.
2026-09-04 18:04:32 +08:00
OneWay b9baf61264 fix: re-check mutation-block inside gesture coroutines
Stale drag-tick coroutines launched before the flip no-op at execution
time instead of moving the row after the reset.
2026-09-04 14:59:59 +08:00
OneWay 7b63ee4377 refactor: unify swipe reset to snap before clearing offset
Snap the Animatable first, clear rawOffset after - no frame with the
row still translated while the delete affordance is hidden.
2026-09-04 13:28:27 +08:00
OneWay d892427a3f fix: key dictionary list items by path
Swipe state follows its row instead of migrating to a neighbor on
delete or reorder.
2026-09-04 02:05:59 +08:00
OneWay 628813c3f6 refactor: isolate dictionary rows from mutation-block recomposition
Delete-block flag becomes a () -> Boolean provider read at state/gesture
time, so flipping it no longer recomposes the whole Dictionaries block.
2026-09-04 01:53:06 +08:00
OneWay 4f202f8c10 refactor: replace deprecated currentWindowAdaptiveInfo with V2 2026-09-02 16:18:39 +08:00
OneWay 44ebe68f75 refactor: expose state flows via asStateFlow
Drops the explicit getters and an unused import, and lets an emptyList
type argument be inferred.
2026-09-02 12:55:48 +08:00
OneWay 5de466792a chore: remove unused index comparison and decode helpers
IndexSearcher.compareEntryGroups / IndexComparisonSummary /
IndexComparisonException / readEntryOrNull and AboutLibrariesData.decode
have no call sites left in the app.
2026-09-02 11:30:51 +08:00
OneWay b6e6bd328c fix: reset dictionary progress bar between indexing operations
monotonicTargetProgress is held in remember and only ever grows via
maxOf, so when a new indexing starts after a previous one reached a
higher value the bar is left at the old level while the percent label
starts at 0. Snap it to the current target when the section becomes
visible so the bar follows the new operation from its first frame.
2026-09-02 10:56:32 +08:00
OneWay 5132ae66b9 fix: cancel stale progress notification on process start
Notifications belong to the package and outlive the process: after the
system kills the foreground service mid-download the frozen progress
notification stays in the shade forever. A fresh process means no
service is running, so any leftover notification is stale.
2026-09-02 08:37:43 +08:00
OneWay 5151665216 perf: mark Compose state types as @Immutable
Compose compiler reports showed DownloadState's sealed root as Uncertain
and DownloadProgressState/SettingsUiState as runtime stability, so
skippability checks were deferred to runtime. The annotations make them
compile-time stable and turn any future mutable field into an explicit
contract break.
2026-09-01 17:28:17 +08:00
OneWay 96461bd7cf fix: probe source availability before showing download progress
Probe the source URLs up front so the download flow only enters the
progress state when something is actually downloadable, and report
Idle instead of a fake Success when nothing is: the early return
skipped the terminal-state delay and reset, leaving a phantom 100%
Success (and a stuck "indexing" bar plus a success notification) for
downloads that never happened.
2026-09-01 11:45:41 +08:00
OneWay 98ef99ab53 fix: probe source availability before showing download progress
Instant server failures (404/HTML error page) no longer flash a 0%
progress bar; the check mirrors the downloader's previous-day fallback
and tolerates transient network errors, which still go through the
downloader's retry path.
2026-09-05 10:03:54 +08:00
OneWay 43823e3bfa Fix: clear search results instantly on query clear
Skipped the 300ms debounce for blank queries so the results list clears immediately; typed queries keep the debounce.
2026-08-30 03:36:27 +08:00
OneWay bab03b6c73 fix: retry dictionary downloads on network-switch errors
Downloads killed by WiFi/mobile transitions failed with the generic
"Dictionary download failed" message because UnknownHostException,
ConnectException, unexpected end of stream, and closed-socket errors
were not classified as network errors and were never retried.
Classify them by exception type and message so the retry loop
engages and exhaustion reports the network-specific message.

Bump OkHttp 5.4.0 to 5.5.0: a throwing system proxy selector now
falls back to a direct connection instead of failing the call,
complementing the download resilience fix.
2026-08-30 00:39:04 +08:00
OneWay d4e6302274 docs: update README and Fastlane descriptions 2026-08-25 23:59:58 +08:00
OneWay bc3fa85b9d Release 1.5.0
- The dictionary update card now returns after a failed download
- Fixed headwords and articles with escaped characters being displayed incorrectly
- Fixed slow rendering of articles containing many bracket characters
2026-08-18 00:16:27 +03:00
OneWay 457bbe43b6 Unescape any escaped character in article text
Apply the general DSL rule \X -> X instead of unescaping only brackets
and parentheses, so sequences like \! no longer show the backslash.
2026-08-18 00:15:53 +03:00
OneWay f751ac0d36 Harden DSL parsing against escapes and malformed tags
Fixed escape-aware headword parsing for \{, \}, \[ and trailing backslash.
Fixed O(n^2) tag scan and nested bracket handling in article rendering.
2026-08-16 11:13:46 +03:00
OneWay 910bc8313c Release 1.4.0
- Fixed matching source URLs to installed dictionary files, so deleted dictionaries can be downloaded again from the same URL
2026-08-06 14:26:03 +03:00
OneWay 9d18d0c927 Release 1.3.0
- Blocked swipe-to-delete on dictionary rows while a download, extract,
  import or indexing pass is running. Deleting mid-update removed the
  dictionary's source from preferences while the pipeline kept its own
  snapshot, so the post-update rescan re-created the dictionary with its
  update source permanently lost.
- Fixed the dictionary icon leaving the row while swiping to delete: the
  item now clips to its bounds and the delete button is a static overlay
  instead of a counter-translated child of the sliding row.
2026-08-02 11:09:47 +03:00
OneWay 69bbb97b2a Release 1.2.1
- Fixed the dictionary update card taking too long to reappear after
  cancelling a download (DownloadManager).
- Bump agp 9.3.0 -> 9.3.1; migrate the release build type from
  isMinifyEnabled/isShrinkResources/proguardFiles to
  optimization { enable = true } with keep rules moved to
  app/src/release/keepRules/proguard-rules.keep.
2026-07-31 10:47:29 +03:00
OneWay c8adf537d4 Add app icon to fastlane metadata 2026-07-30 19:46:12 +03:00
OneWay 84612b26f2 Release 1.2.0
- Fixed a crash on broad queries (e.g. a single letter across several
  dictionaries): the results list keyed items by dictionary
  path/offset/word, and duplicate keys are illegal in a Compose list;
  drop the explicit key, let Paging identify items by position instead
  (SearchResultsList).
- Fixed a disabled dictionary reactivating itself on the next app
  launch: the active flag is now persisted through
  PreferencesManager/DataStore and reapplied whenever the dictionary
  list is rebuilt (LocalDictionaryRepository).
- Search results no longer stop at the first ~100-200 matches: the
  ranked head page hands back a cursor per unfinished prefix range,
  and IndexEntryPagingSource pages the rest straight from the index,
  merged alphabetically across dictionaries, until every match is
  surfaced (IndexSearcher, IndexEntryPagingSource).
- Added diacritic-insensitive search: folded index entries are merged
  into the primary index as aliases, so accented and plain spellings
  find each other (DslIndexer, IndexSearcher).
- Fixed indexing failures on dictionaries with dense non-Latin text: a
  truncated multi-byte sequence at a sample boundary was misread as
  invalid UTF-8 and the whole file fell back to UTF-16LE
  (DslCharsetDetector).
2026-07-22 16:29:16 +03:00
118 changed files with 3811 additions and 1589 deletions

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 OneWay
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+23 -32
View File
@@ -1,11 +1,10 @@
<h1 align="center">ReSearch</h1> <h1 align="center">ReSearch</h1>
<h4 align="center">A fast Android dictionary reader for DSL-format dictionaries.</h4> <h4 align="center">An Android dictionary reader for DSL-format dictionaries.</h4>
<p align="center"> <p align="center">
<a href="#screenshots">Screenshots</a> &bull; <a href="#screenshots">Screenshots</a> &bull;
<a href="#features">Features</a> &bull; <a href="#features">Features</a> &bull;
<a href="#architecture">Architecture</a> &bull;
<a href="#dictionary-pipeline">Pipeline</a> &bull; <a href="#dictionary-pipeline">Pipeline</a> &bull;
<a href="#build">Build</a> <a href="#build">Build</a>
</p> </p>
@@ -16,38 +15,30 @@ The app stores dictionaries as DictZip files and builds compact binary indexes f
## Screenshots ## Screenshots
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="160" alt="ReSearch search screen">](fastlane/metadata/android/en-US/images/phoneScreenshots/1.png) [<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="160" alt="ReSearch search results screen">](fastlane/metadata/android/en-US/images/phoneScreenshots/1.png)
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="160" alt="ReSearch article screen">](fastlane/metadata/android/en-US/images/phoneScreenshots/2.png) [<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="160" alt="ReSearch search history sheet">](fastlane/metadata/android/en-US/images/phoneScreenshots/2.png)
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="160" alt="ReSearch settings screen">](fastlane/metadata/android/en-US/images/phoneScreenshots/3.png)
## Features ## Features
- Fast headword search across multiple DSL dictionaries. - Fast headword search across multiple DSL dictionaries.
- Search history in a bottom sheet: recent queries with per-entry removal and clear-all.
- Look up selected text from other apps via the selection menu.
- Import local `.dsl`, `.dsl.gz`, and `.dsl.dz` dictionary files. - Import local `.dsl`, `.dsl.gz`, and `.dsl.dz` dictionary files.
- Download dictionaries from configured URLs. - Download dictionaries from configured URLs, with an optional `{DATE}` placeholder for dated releases.
- Multiple dictionary source URLs can be added in one list.
- Automatic extraction, DictZip compression, and indexing. - Automatic extraction, DictZip compression, and indexing.
- Faithful article rendering with DSL indentation, margins, and named colors.
- Offline search after dictionaries are indexed. - Offline search after dictionaries are indexed.
- Random-access article loading from DictZip dictionaries. - Random-access article loading from DictZip dictionaries.
- Background processing with progress shown in Settings and notifications. - Background processing with progress shown in Settings and notifications; downloads are cancelled with a button on the progress notification.
- Dictionary management with enable, disable, update, and delete actions. - Dictionary management with enable, disable, and update actions; deleting a dictionary is done with a swipe.
- Material 3 interface with light, dark, and system themes. - Search bar placement at the top or bottom of the screen.
- Adaptive two-pane layout for tablets and large screens.
- Material 3 interface with dynamic color, light, dark, and system themes.
- English and Russian localization. - English and Russian localization.
- About screen with open source license information. - About screen with open source license information.
## Architecture
ReSearch is an Android app written in Kotlin and Jetpack Compose.
```text
com.example.research/
├── common/ # shared progress, UI, file, archive, notification utilities
├── core/ # domain models, source validation, operation results
├── data/ # DictZip, DSL parsing, indexing, paging, repositories
├── feature/ # search, dictionary download, local import workflows
└── ui/ # Compose screens, article view, settings, navigation
```
State is exposed through Kotlin Flow and collected by Compose screens. Long-running work uses coroutines and foreground services where Android requires visible progress.
## Dictionary Pipeline ## Dictionary Pipeline
ReSearch supports both downloaded and locally imported dictionaries. ReSearch supports both downloaded and locally imported dictionaries.
@@ -94,16 +85,16 @@ The main search screen remains usable as long as at least one indexed dictionary
## Tech Stack ## Tech Stack
- Kotlin 2.4.0 - Kotlin 2.4.21
- Jetpack Compose (BOM 2026.06.01) - Jetpack Compose (BOM 2026.09.00)
- Material 3 - Material 3 1.5.0-beta01
- Coroutines and Flow 1.11.0 - Coroutines and Flow 1.11.0
- DataStore Preferences 1.2.1 - DataStore Preferences 1.2.1
- Paging 3.5.0 - Paging 3.5.2
- OkHttp 5.4.0 - OkHttp 5.5.0
- kotlinx.serialization 1.11.0 - kotlinx.serialization 1.11.0
- AboutLibraries 15.0.3 metadata generation - AboutLibraries 15.2.0 metadata generation
- Android Gradle Plugin 9.2.1 - Android Gradle Plugin 9.4.1
## Build ## Build
@@ -111,8 +102,8 @@ The main search screen remains usable as long as at least one indexed dictionary
./gradlew :app:assembleRelease ./gradlew :app:assembleRelease
``` ```
The release APK targets `arm64-v8a`. English and Russian resources are packaged together. The release APK targets `arm64-v8a`.
## License ## License
MIT [MIT](LICENSE)
+5 -23
View File
@@ -92,14 +92,8 @@ android {
applicationId = "com.example.research" applicationId = "com.example.research"
minSdk = project.property("minSdk").toString().toInt() minSdk = project.property("minSdk").toString().toInt()
targetSdk = project.property("targetSdk").toString().toInt() targetSdk = project.property("targetSdk").toString().toInt()
versionCode = 3 versionCode = 12
versionName = "1.1.1" versionName = "1.8.0"
val isFdroid = project.hasProperty("fdroid")
if (isFdroid) {
applicationIdSuffix = ".fdroid"
versionNameSuffix = "-fdroid"
}
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
@@ -120,12 +114,9 @@ android {
abiFilters += "arm64-v8a" abiFilters += "arm64-v8a"
} }
isMinifyEnabled = true optimization {
isShrinkResources = true enable = true
proguardFiles( }
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
} }
} }
@@ -173,26 +164,17 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.documentfile) implementation(libs.androidx.documentfile)
val composeBom = platform(libs.androidx.compose.bom) val composeBom = platform(libs.androidx.compose.bom)
implementation(composeBom) implementation(composeBom)
implementation(libs.bundles.compose) implementation(libs.bundles.compose)
implementation(libs.androidx.compose.material3.adaptive.layout) implementation(libs.androidx.compose.material3.adaptive.layout)
debugImplementation(libs.androidx.compose.runtime.tracing) debugImplementation(libs.androidx.compose.runtime.tracing)
implementation(libs.androidx.paging.runtime) implementation(libs.androidx.paging.runtime)
implementation(libs.androidx.paging.compose) implementation(libs.androidx.paging.compose)
implementation(libs.androidx.profileinstaller) implementation(libs.androidx.profileinstaller)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(libs.okhttp) implementation(libs.okhttp)
// Coroutines with Android-optimized dispatchers
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
} }
@@ -13,9 +13,6 @@ object ReSearchTrace {
const val DICTIONARY_EXTRACT = "ReSearch/Dictionary/Extract" const val DICTIONARY_EXTRACT = "ReSearch/Dictionary/Extract"
const val DICTIONARY_INDEX = "ReSearch/Dictionary/Index" const val DICTIONARY_INDEX = "ReSearch/Dictionary/Index"
const val DICTIONARY_IMPORT = "ReSearch/Dictionary/Import" const val DICTIONARY_IMPORT = "ReSearch/Dictionary/Import"
const val FLOW_UPDATE_CARD_APPEAR = "ReSearch/Flow/UpdateCardAppear"
const val FLOW_UPDATING = "ReSearch/Flow/Updating"
const val FLOW_PROGRESS_DISAPPEAR = "ReSearch/Flow/ProgressDisappear"
private val nextCookie = AtomicInteger() private val nextCookie = AtomicInteger()
+36 -44
View File
@@ -1,54 +1,48 @@
# Seeded from the measured ReSearch startup, search, scrolling, and article-opening hot paths. HSPLcom/example/research/ReSearchApplication**->**(**)**
# Keep this profile checked in: release builders, including F-Droid, only compile it. HSPLcom/example/research/MainActivity**->**(**)**
# Updated 2026-06-25: Added optimized cache paths from performance optimization stages 1-4 HSPLcom/example/research/InitialLaunch**->**(**)**
HSPLcom/example/research/DictionaryStatus**->**(**)**
HSPLcom/example/research/core/**->**(**)**
HSPLcom/example/research/common/ui/**->**(**)**
HSPLcom/example/research/ui/theme/**->**(**)**
HSPLcom/example/research/ui/navigation/**->**(**)**
HSPLcom/example/research/data/local/preferences/**->**(**)**
HSPLcom/example/research/data/parser/ResourceManager**->**(**)**
HSPLcom/example/research/common/util/FileStorageManager**->**(**)**
HSPLcom/example/research/common/util/DateUtils**->**(**)**
HSPLcom/example/research/ReSearchApplication;->**(**)** HSPLcom/example/research/ui/main/**->**(**)**
HSPLcom/example/research/MainActivity;->**(**)** HSPLandroidx/paging/**->**(**)**
HSPLcom/example/research/feature/search/**->**(**)**
HSPLcom/example/research/common/ui/AppLocaleProviderKt**->**(**)** HSPLcom/example/research/data/paging/**->**(**)**
HSPLcom/example/research/common/ui/theme/ThemeKt**->**(**)**
HSPLcom/example/research/ui/navigation/AppNavigationKt**->**(**)**
HSPLcom/example/research/ui/main/MainScreenKt**->**(**)**
HSPLcom/example/research/ui/main/AdaptiveMainScreenKt**->**(**)**
HSPLcom/example/research/ui/main/components/MainTopBarKt**->**(**)**
HSPLcom/example/research/ui/main/components/SearchResultsListKt**->**(**)**
HSPLcom/example/research/feature/search/SearchViewModel**->**(**)**
HSPLcom/example/research/data/paging/IndexEntryPagingSource**->**(**)**
HSPLcom/example/research/data/parser/IndexSearcher**->**(**)** HSPLcom/example/research/data/parser/IndexSearcher**->**(**)**
HSPLcom/example/research/data/parser/SearchRankingKt**->**(**)** HSPLcom/example/research/data/parser/FileSource**->**(**)**
HSPLcom/example/research/data/parser/ResultsLruCache**->**(**)**
HSPLcom/example/research/data/parser/MetadataLruCache**->**(**)**
HSPLcom/example/research/data/parser/SearchRanking**->**(**)**
HSPLcom/example/research/data/parser/SortKey**->**(**)**
HSPLcom/example/research/data/repository/LocalDictionaryRepository**->**(**)** HSPLcom/example/research/data/repository/**->**(**)**
HSPLcom/example/research/data/repository/KotlinDictionaryEngine**->**(**)** HSPLcom/example/research/data/dictzip/DictZipInputStream**->**(**)**
HSPLcom/example/research/data/dictzip/DictZipRandomAccessFile**->**(**)**
HSPLcom/example/research/data/dictzip/DictZipHeader**->**(**)**
HSPLcom/example/research/data/parser/DslArticleReader**->**(**)** HSPLcom/example/research/data/parser/DslArticleReader**->**(**)**
HSPLcom/example/research/ui/article/DslAnnotatedParser**->**(**)** HSPLcom/example/research/data/parser/LruCharsetCache**->**(**)**
HSPLcom/example/research/ui/article/ArticleScreenKt**->**(**)** HSPLcom/example/research/ui/article/**->**(**)**
# Hot paths for optimized article rendering (Stage 1) HSPLcom/example/research/data/parser/Dsl**->**(**)**
HSPLcom/example/research/ui/article/ArticleLinkText**->**(**)** HSPLcom/example/research/data/parser/LineKind**->**(**)**
HSPLcom/example/research/ui/article/ArticleState**->**(**)** HSPLcom/example/research/data/parser/ByteTrackingLineReader**->**(**)**
HSPLcom/example/research/data/parser/Utf8Writer**->**(**)**
HSPLcom/example/research/ui/settings/**->**(**)**
HSPLcom/example/research/common/progress/**->**(**)**
HSPLcom/example/research/feature/download/**->**(**)**
# About screen (bonus optimization)
HSPLcom/example/research/ui/about/AboutScreenKt**->**(**)** HSPLcom/example/research/ui/about/AboutScreenKt**->**(**)**
# Updated 2026-07-11: cover the settings/dictionary flow and the Compose framework.
# Settings + dictionary management flow
HSPLcom/example/research/ui/settings/**->**(**)**
Lcom/example/research/ui/settings/**
HSPLcom/example/research/common/ui/components/**->**(**)**
Lcom/example/research/common/ui/components/**
HSPLcom/example/research/common/progress/**->**(**)**
Lcom/example/research/common/progress/**
HSPLcom/example/research/feature/download/**->**(**)**
Lcom/example/research/feature/download/**
# Pre-verify the app classes already covered by method rules above
Lcom/example/research/** Lcom/example/research/**
# Compose framework: the alpha AARs ship near-empty baseline profiles, so first
# compositions run interpreted without these rules.
HSPLandroidx/compose/runtime/**->**(**)** HSPLandroidx/compose/runtime/**->**(**)**
Landroidx/compose/runtime/** Landroidx/compose/runtime/**
HSPLandroidx/compose/ui/**->**(**)** HSPLandroidx/compose/ui/**->**(**)**
@@ -61,12 +55,10 @@ HSPLandroidx/compose/animation/**->**(**)**
Landroidx/compose/animation/** Landroidx/compose/animation/**
HSPLandroidx/compose/material/**->**(**)** HSPLandroidx/compose/material/**->**(**)**
Landroidx/compose/material/** Landroidx/compose/material/**
# Support libraries on the same cold path
HSPLandroidx/lifecycle/**->**(**)** HSPLandroidx/lifecycle/**->**(**)**
Landroidx/lifecycle/** Landroidx/lifecycle/**
HSPLandroidx/activity/compose/**->**(**)** HSPLandroidx/activity/compose/**->**(**)**
Landroidx/activity/compose/** Landroidx/activity/compose/**
HSPLkotlinx/coroutines/**->**(**)** HSPLkotlinx/coroutines/**->**(**)**
Lkotlinx/coroutines/** Lkotlinx/coroutines/**
Landroidx/paging/**
@@ -1,10 +1,13 @@
package com.example.research package com.example.research
import androidx.compose.runtime.Immutable
@Immutable
sealed interface DictionaryStatus { sealed interface DictionaryStatus {
data object Unknown : DictionaryStatus data object Unknown : DictionaryStatus
data object Checking : DictionaryStatus data object Checking : DictionaryStatus
data object UpToDate : DictionaryStatus data object UpToDate : DictionaryStatus
data object NeedsUpdate : DictionaryStatus data class NeedsUpdate(val sourceIds: Set<String>) : DictionaryStatus
data object Empty : DictionaryStatus data object Empty : DictionaryStatus
data class Error(val message: String) : DictionaryStatus data class Error(val message: String) : DictionaryStatus
} }
@@ -26,12 +26,12 @@ import com.example.research.common.ui.AppLocaleProvider
import com.example.research.common.ui.BackgroundTextMeasurementProvider import com.example.research.common.ui.BackgroundTextMeasurementProvider
import com.example.research.common.ui.theme.ReSearchTheme import com.example.research.common.ui.theme.ReSearchTheme
import com.example.research.core.domain.model.AppTheme import com.example.research.core.domain.model.AppTheme
import com.example.research.core.domain.model.SearchBarPosition
import com.example.research.data.local.preferences.SectionExpansionStates
import com.example.research.feature.search.SearchAction import com.example.research.feature.search.SearchAction
import com.example.research.feature.search.SearchViewModel import com.example.research.feature.search.SearchViewModel
import com.example.research.ui.navigation.AppNavigation import com.example.research.ui.navigation.AppNavigation
import com.example.research.ui.settings.SettingsViewModel import com.example.research.ui.settings.SettingsViewModel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
companion object { companion object {
@@ -46,7 +46,7 @@ class MainActivity : ComponentActivity() {
val app = application as ReSearchApplication val app = application as ReSearchApplication
viewModelFactory { viewModelFactory {
initializer { initializer {
SearchViewModel(application, app.localDictionaryRepository, createSavedStateHandle()) SearchViewModel(application, app.localDictionaryRepository, app.preferencesManager, createSavedStateHandle())
} }
} }
} }
@@ -69,6 +69,8 @@ class MainActivity : ComponentActivity() {
app.dictionaryImportManager, app.dictionaryImportManager,
app.dictionaryProgressStateHolder, app.dictionaryProgressStateHolder,
versionName, versionName,
app.initialLaunch.value?.sectionExpansion ?: SectionExpansionStates(),
app.initialLaunch.value?.searchBarPosition ?: SearchBarPosition.TOP,
) )
} }
} }
@@ -79,17 +81,16 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
requestNotificationPermissionIfNeeded() requestNotificationPermissionIfNeeded()
// Load theme and language synchronously before setContent to prevent flashing
val app = application as ReSearchApplication val app = application as ReSearchApplication
val initialTheme = loadInitialTheme(app)
val initialLanguage = loadInitialLanguage(app)
handleIntent(intent) handleIntent(intent)
setContent { setContent {
val initialLaunch by app.initialLaunch.collectAsStateWithLifecycle()
val launch = initialLaunch ?: return@setContent
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle() val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
// Use loaded values initially, then switch to state values when ready // Use loaded values initially, then switch to state values when ready
val theme = settingsState.theme.takeIf { it != AppTheme.SYSTEM } ?: initialTheme val theme = settingsState.theme.takeIf { it != AppTheme.SYSTEM } ?: launch.theme
val language = settingsState.language.takeIf { it != "system" } ?: initialLanguage val language = settingsState.language.takeIf { it != "system" } ?: launch.language
val darkTheme = when (theme) { val darkTheme = when (theme) {
AppTheme.LIGHT -> false AppTheme.LIGHT -> false
AppTheme.DARK -> true AppTheme.DARK -> true
@@ -104,7 +105,8 @@ class MainActivity : ComponentActivity() {
) { ) {
AppNavigation( AppNavigation(
searchViewModel = searchViewModel, searchViewModel = searchViewModel,
settingsViewModel = settingsViewModel settingsViewModel = settingsViewModel,
seedNoDictionaries = launch.seedNoDictionaries
) )
} }
} }
@@ -127,44 +129,6 @@ class MainActivity : ComponentActivity() {
} }
} }
/**
* Load initial theme synchronously from DataStore to prevent theme flashing.
* This is called before setContent to ensure the correct theme is applied immediately.
* Uses runBlocking to synchronously read from DataStore.
*/
private fun loadInitialTheme(app: ReSearchApplication): AppTheme {
return try {
runBlocking {
val themeString = app.preferencesManager.themeMode.first()
try {
AppTheme.valueOf(themeString)
} catch (_: Exception) {
android.util.Log.w("MainActivity", "Invalid theme value: $themeString, using SYSTEM")
AppTheme.SYSTEM
}
}
} catch (e: Exception) {
android.util.Log.e("MainActivity", "Failed to load theme preference: ${e.message}")
AppTheme.SYSTEM
}
}
/**
* Load initial language synchronously from DataStore to prevent language flashing.
* This is called before setContent to ensure the correct language is applied immediately.
* Uses runBlocking to synchronously read from DataStore.
*/
private fun loadInitialLanguage(app: ReSearchApplication): String {
return try {
runBlocking {
app.preferencesManager.language.first()
}
} catch (e: Exception) {
android.util.Log.e("MainActivity", "Failed to load language preference: ${e.message}")
"system"
}
}
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
handleIntent(intent) handleIntent(intent)
@@ -180,7 +144,7 @@ class MainActivity : ComponentActivity() {
val text = intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT) val text = intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)
?: intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT_READONLY) ?: intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT_READONLY)
text?.toString()?.let { text?.toString()?.let {
searchViewModel.onAction(SearchAction.HandleExternalSearch(it)) searchViewModel.onAction(SearchAction.QueryChanged(it))
} }
} }
} }
@@ -2,7 +2,11 @@ package com.example.research
import android.app.Application import android.app.Application
import com.example.research.common.progress.DictionaryProgressStateHolder import com.example.research.common.progress.DictionaryProgressStateHolder
import com.example.research.common.util.NotificationHelper
import com.example.research.core.domain.model.AppTheme
import com.example.research.core.domain.model.SearchBarPosition
import com.example.research.data.local.preferences.PreferencesManager import com.example.research.data.local.preferences.PreferencesManager
import com.example.research.data.local.preferences.SectionExpansionStates
import com.example.research.data.repository.LocalDictionaryRepository import com.example.research.data.repository.LocalDictionaryRepository
import com.example.research.feature.download.DownloadManager import com.example.research.feature.download.DownloadManager
import com.example.research.feature.download.repository.DictionaryRepository import com.example.research.feature.download.repository.DictionaryRepository
@@ -11,10 +15,26 @@ import com.example.research.feature.import.DictionaryImportManager
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Protocol import okhttp3.Protocol
/**
* Values a cold start needs before the first composed frame: the initial
* theme, locale, empty-library navigation seed, and settings expansion state.
*/
data class InitialLaunch(
val theme: AppTheme,
val language: String,
val searchBarPosition: SearchBarPosition,
val seedNoDictionaries: Boolean,
val sectionExpansion: SectionExpansionStates,
)
class ReSearchApplication : Application() { class ReSearchApplication : Application() {
lateinit var preferencesManager: PreferencesManager lateinit var preferencesManager: PreferencesManager
@@ -36,16 +56,29 @@ class ReSearchApplication : Application() {
private set private set
lateinit var dictionaryProgressStateHolder: DictionaryProgressStateHolder lateinit var dictionaryProgressStateHolder: DictionaryProgressStateHolder
private set private set
lateinit var dictionaryProgressPresenter: com.example.research.common.progress.DictionaryProgressPresenter
private set
lateinit var dictionaryPipelineCoordinator: com.example.research.common.progress.DictionaryPipelineCoordinator
private set
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _initialLaunch = MutableStateFlow<InitialLaunch?>(null)
val initialLaunch: StateFlow<InitialLaunch?> = _initialLaunch.asStateFlow()
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
val notificationHelper = NotificationHelper(this)
notificationHelper.cancelProgressNotification()
preferencesManager = PreferencesManager(this) preferencesManager = PreferencesManager(this)
applicationScope.launch(Dispatchers.IO) { applicationScope.launch(Dispatchers.IO) {
preferencesManager.sanitizeDictionarySources() preferencesManager.sanitizeDictionarySources()
} }
localDictionaryRepository = LocalDictionaryRepository(this) applicationScope.launch(Dispatchers.IO) {
_initialLaunch.value = loadInitialLaunch()
}
localDictionaryRepository = LocalDictionaryRepository(this, preferencesManager = preferencesManager)
okHttpClient = OkHttpClient.Builder() okHttpClient = OkHttpClient.Builder()
.protocols(listOf(Protocol.HTTP_1_1)) .protocols(listOf(Protocol.HTTP_1_1))
@@ -62,7 +95,8 @@ class ReSearchApplication : Application() {
downloadManager = DownloadManager( downloadManager = DownloadManager(
dictionaryRepository = downloadDictionaryRepository, dictionaryRepository = downloadDictionaryRepository,
unknownErrorMessage = getString(R.string.unknown_error) unknownErrorMessage = getString(R.string.unknown_error),
sourceUnavailableMessage = getString(R.string.download_file_not_found)
) )
dictionaryImportManager = DictionaryImportManager( dictionaryImportManager = DictionaryImportManager(
@@ -76,6 +110,54 @@ class ReSearchApplication : Application() {
localDictionaryRepository = localDictionaryRepository, localDictionaryRepository = localDictionaryRepository,
) )
dictionaryProgressPresenter = com.example.research.common.progress.DictionaryProgressPresenter(
scope = applicationScope,
progressSnapshot = dictionaryProgressStateHolder.progressSnapshot,
notifications = notificationHelper,
)
dictionaryPipelineCoordinator = com.example.research.common.progress.DictionaryPipelineCoordinator(
scope = applicationScope,
steps = com.example.research.common.progress.DefaultReindexingSteps(
localDictionaryRepository = localDictionaryRepository,
downloadDictionaryRepository = downloadDictionaryRepository,
),
importOperations = dictionaryImportManager,
ui = dictionaryProgressPresenter,
dictionaryPath = { preferencesManager.dictionaryPath },
finishService = {
val intent = android.content.Intent(
this,
com.example.research.feature.download.service.DictionaryForegroundService::class.java
).apply {
action = com.example.research.feature.download.service.DictionaryForegroundService.ACTION_FINISH
}
try {
startService(intent)
} catch (_: Exception) {
}
},
)
dictionaryImportManager.onFlowStarted = { dictionaryProgressPresenter.beginFlow() }
dictionaryImportManager.onTerminal = dictionaryPipelineCoordinator::onImportTerminal
downloadManager.onFlowStarted = { dictionaryProgressPresenter.beginFlow() }
downloadManager.onDownloadSuccess = dictionaryPipelineCoordinator::onDownloadSuccess
downloadManager.onTerminal = dictionaryPipelineCoordinator::onDownloadTerminal
}
private suspend fun loadInitialLaunch(): InitialLaunch {
return InitialLaunch(
theme = runCatching { AppTheme.valueOf(preferencesManager.themeMode.first()) }
.getOrDefault(AppTheme.SYSTEM),
language = runCatching { preferencesManager.language.first() }.getOrDefault("system"),
searchBarPosition = runCatching { preferencesManager.searchBarPosition.first() }
.getOrDefault(SearchBarPosition.TOP),
seedNoDictionaries = runCatching { preferencesManager.hadNoDictionaries.first() }
.getOrDefault(true),
sectionExpansion = runCatching { preferencesManager.sectionExpansionStates() }
.getOrDefault(SectionExpansionStates()),
)
} }
override fun onTrimMemory(level: Int) { override fun onTrimMemory(level: Int) {
@@ -0,0 +1,151 @@
package com.example.research.common.progress
import com.example.research.core.util.OperationResult
import com.example.research.feature.download.model.DownloadState
import com.example.research.ui.settings.ImportState
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import kotlin.time.Duration.Companion.seconds
class DictionaryPipelineCoordinator(
private val scope: CoroutineScope,
private val steps: ReindexingSteps,
private val importOperations: ImportFlowOperations,
private val ui: PipelineTerminalUi,
private val dictionaryPath: () -> String,
private val finishService: () -> Unit,
private val launchDispatcher: CoroutineDispatcher = Dispatchers.Main,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
private var pipelineJob: Job? = null
fun onImportTerminal(state: ImportState) {
when (state) {
is ImportState.Success -> runPipeline(isImport = true)
is ImportState.Error -> {
ui.showError()
finishService()
}
else -> {}
}
}
fun onDownloadSuccess() = runPipeline(isImport = false)
fun onDownloadTerminal(state: DownloadState) {
when (state) {
is DownloadState.Error -> {
ui.showError()
finishService()
}
is DownloadState.Cancelled -> {
ui.cancelProgress()
finishService()
}
else -> {}
}
}
fun cancelPipeline() {
pipelineJob?.cancel()
pipelineJob = null
}
private fun runPipeline(isImport: Boolean) {
pipelineJob?.cancel()
pipelineJob = scope.launch(launchDispatcher) {
val dir = File(dictionaryPath())
val filesBeforeReindex = dir.listFiles()?.map { it.name }?.toSet() ?: emptySet()
try {
triggerReindexing(isImport)
if (isImport) {
importOperations.getAndClearImportedFiles()
importOperations.clearImportState()
ui.showImportSuccess()
} else {
ui.showDownloadSuccess()
}
} catch (e: CancellationException) {
if (isImport) {
val filesToCleanup = importOperations.getAndClearImportedFiles()
filesToCleanup.forEach { file ->
try {
if (file.exists()) file.delete()
} catch (_: Exception) {
}
}
cleanupNewFiles(dir, filesBeforeReindex)
}
cleanupOrphanedFiles(dir)
throw e
} catch (_: Exception) {
ui.showError()
} finally {
finishService()
}
}
}
private suspend fun triggerReindexing(isImport: Boolean) = withContext(ioDispatcher) {
steps.waitForIndexingCompletion()
delay(1.seconds)
val path = dictionaryPath()
if (isImport) {
importOperations.updateExtractionProgress(0f)
}
steps.extractArchives(onProgress = { progress ->
if (isImport) {
importOperations.updateExtractionProgress(progress)
}
})
val result = steps.scanDirectory(path)
if (result is OperationResult.Success && result.data > 0) {
steps.warmupIndexes()
steps.performAllCleanup()
}
if (isImport) {
importOperations.markImportPipelineSuccess()
}
}
private fun cleanupOrphanedFiles(dir: File?) {
try {
dir?.listFiles()?.forEach { file ->
val n = file.name.lowercase()
val isRawDsl = n.endsWith(".dsl") && !n.endsWith(".dsl.dz") && !n.endsWith(".dsl.gz")
val isBareDz = n.endsWith(".dz") && !n.endsWith(".dsl.dz")
val orphaned = when {
isBareDz -> true
isRawDsl -> !File(file.parent, file.name + ".dz").exists()
else -> false
}
if (orphaned) {
try { file.delete() } catch (_: Exception) {}
}
}
} catch (_: Exception) {
}
}
private fun cleanupNewFiles(dir: File?, filesBeforeSnapshot: Set<String>) {
try {
dir?.listFiles()?.forEach { file ->
if (file.name !in filesBeforeSnapshot) {
try {
file.delete()
} catch (_: Exception) {
}
}
}
} catch (_: Exception) {
}
}
}
@@ -21,14 +21,16 @@ object DictionaryProgressModel {
val progress: Float, val progress: Float,
val percent: Int, val percent: Int,
@param:StringRes val titleRes: Int, @param:StringRes val titleRes: Int,
val indeterminate: Boolean = false,
) { ) {
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (other !is Snapshot) return false if (other !is Snapshot) return false
return percent == other.percent && titleRes == other.titleRes return percent == other.percent && titleRes == other.titleRes &&
indeterminate == other.indeterminate
} }
override fun hashCode(): Int { override fun hashCode(): Int {
return 31 * percent + titleRes return 31 * (31 * percent + titleRes) + indeterminate.hashCode()
} }
} }
fun computeProgressSnapshot( fun computeProgressSnapshot(
@@ -92,6 +94,7 @@ object DictionaryProgressModel {
progress = p, progress = p,
percent = pct, percent = pct,
titleRes = R.string.notification_download_title, titleRes = R.string.notification_download_title,
indeterminate = dl <= 0f,
) )
} }
@@ -109,7 +112,7 @@ object DictionaryProgressModel {
return Snapshot( return Snapshot(
progress = p, progress = p,
percent = (p * 100f).toInt().coerceIn(0, 100), percent = (p * 100f).toInt().coerceIn(0, 100),
titleRes = R.string.notification_indexing_title, titleRes = R.string.notification_preparing_indexing_title,
) )
} }
@@ -120,7 +123,7 @@ object DictionaryProgressModel {
} else { } else {
DOWNLOAD_WEIGHT + EXTRACT_WEIGHT + idx * INDEX_WEIGHT DOWNLOAD_WEIGHT + EXTRACT_WEIGHT + idx * INDEX_WEIGHT
} }
val pct = (p * 100f).toInt().coerceIn(0, 100) val pct = (p * 100f).toInt().coerceIn(0, 99)
return Snapshot( return Snapshot(
progress = p, progress = p,
@@ -0,0 +1,107 @@
package com.example.research.common.progress
import android.os.SystemClock
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
class DictionaryProgressPresenter(
private val scope: CoroutineScope,
private val progressSnapshot: StateFlow<DictionaryProgressModel.Snapshot?>,
private val notifications: NotificationPort,
private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
) : PipelineTerminalUi {
private companion object {
val MIN_NOTIFICATION_INTERVAL = 500.milliseconds
}
@Volatile
private var terminalActive = false
private var lastShownTitleRes: Int = 0
private var lastEmitMs: Long = 0L
private var pendingSnapshot: DictionaryProgressModel.Snapshot? = null
private var flushJob: Job? = null
init {
scope.launch(mainDispatcher) {
progressSnapshot.collect { snapshot ->
when {
snapshot == null -> {
resetThrottle()
if (!terminalActive && notifications.hasActiveProgressNotification()) {
notifications.cancelProgressNotification()
}
}
!terminalActive -> emitThrottled(snapshot)
}
}
}
}
private fun emitThrottled(snapshot: DictionaryProgressModel.Snapshot) {
val now = SystemClock.elapsedRealtime()
val stageChanged = snapshot.titleRes != lastShownTitleRes
if (stageChanged || (now - lastEmitMs).milliseconds >= MIN_NOTIFICATION_INTERVAL) {
flushJob?.cancel()
pendingSnapshot = null
publish(snapshot, now)
} else {
pendingSnapshot = snapshot
if (flushJob?.isActive != true) {
flushJob = scope.launch(mainDispatcher) {
delay(MIN_NOTIFICATION_INTERVAL - (now - lastEmitMs).milliseconds)
pendingSnapshot?.let { publish(it, SystemClock.elapsedRealtime()) }
pendingSnapshot = null
}
}
}
}
private fun publish(snapshot: DictionaryProgressModel.Snapshot, now: Long) {
if (terminalActive) return
lastShownTitleRes = snapshot.titleRes
lastEmitMs = now
notifications.showProgress(snapshot.percent, snapshot.titleRes)
}
private fun resetThrottle() {
flushJob?.cancel()
pendingSnapshot = null
lastShownTitleRes = 0
lastEmitMs = 0L
}
fun beginFlow() {
terminalActive = false
resetThrottle()
notifications.cancelTerminalNotification()
}
override fun showImportSuccess() {
notifications.showImportSuccess()
terminalActive = true
}
override fun showDownloadSuccess() {
notifications.showDownloadSuccess()
terminalActive = true
}
override fun showError() {
notifications.showError()
terminalActive = true
}
override fun cancelProgress() {
terminalActive = false
resetThrottle()
notifications.cancelProgressNotification()
}
}
@@ -0,0 +1,11 @@
package com.example.research.common.progress
interface NotificationPort {
fun showProgress(percent: Int, titleRes: Int)
fun showImportSuccess()
fun showDownloadSuccess()
fun showError()
fun cancelProgressNotification()
fun cancelTerminalNotification()
fun hasActiveProgressNotification(): Boolean
}
@@ -0,0 +1,65 @@
package com.example.research.common.progress
import com.example.research.core.util.OperationResult
import com.example.research.data.repository.LocalDictionaryRepository
import com.example.research.feature.download.repository.DictionaryRepository
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import java.io.File
import kotlin.time.Duration.Companion.milliseconds
interface PipelineTerminalUi {
fun showImportSuccess()
fun showDownloadSuccess()
fun showError()
fun cancelProgress()
}
interface ImportFlowOperations {
fun updateExtractionProgress(progress: Float)
fun markImportPipelineSuccess()
fun clearImportState()
fun getAndClearImportedFiles(): List<File>
}
interface ReindexingSteps {
suspend fun waitForIndexingCompletion(): Boolean
suspend fun extractArchives(onProgress: (Float) -> Unit)
suspend fun scanDirectory(path: String): OperationResult<Int>
suspend fun warmupIndexes()
suspend fun performAllCleanup()
}
class DefaultReindexingSteps(
private val localDictionaryRepository: LocalDictionaryRepository,
private val downloadDictionaryRepository: DictionaryRepository,
) : ReindexingSteps {
override suspend fun waitForIndexingCompletion(): Boolean {
if (!localDictionaryRepository.indexingProgress.first().isIndexing) return true
var waitCount = 0
while (localDictionaryRepository.indexingProgress.first().isIndexing && waitCount < MAX_POLL) {
delay(POLL_INTERVAL)
waitCount++
}
return waitCount < MAX_POLL
}
override suspend fun extractArchives(onProgress: (Float) -> Unit) {
downloadDictionaryRepository.extractArchives(onProgress = onProgress)
}
override suspend fun scanDirectory(path: String): OperationResult<Int> =
localDictionaryRepository.scanDirectory(path)
override suspend fun warmupIndexes() = localDictionaryRepository.warmupIndexes()
override suspend fun performAllCleanup() {
downloadDictionaryRepository.performAllCleanup()
}
private companion object {
const val MAX_POLL = 100
val POLL_INTERVAL = 200.milliseconds
}
}
@@ -0,0 +1,25 @@
package com.example.research.common.ui
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.example.research.R
@Composable
fun AppBackButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
IconButton(onClick = onClick, modifier = modifier.size(48.dp)) {
Icon(
painter = painterResource(R.drawable.ic_arrow_back),
contentDescription = stringResource(R.string.action_back),
modifier = Modifier.size(28.dp)
)
}
}
@@ -1,13 +1,17 @@
package com.example.research.common.ui package com.example.research.common.ui
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.unit.dp
fun Modifier.appTestTag(tag: String): Modifier = this fun Modifier.appTestTag(tag: String): Modifier = this
.testTag(tag) .testTag(tag)
@@ -24,3 +28,14 @@ fun Modifier.clickableWithHaptics(
onClick() onClick()
} }
} }
@Composable
fun Modifier.iconClickable(
enabled: Boolean = true,
onClick: () -> Unit
): Modifier = this.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false, radius = 20.dp),
enabled = enabled,
onClick = onClick
)
@@ -0,0 +1,46 @@
package com.example.research.common.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Badge
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
private val BadgeOutset = 4.dp
@Composable
fun BadgedIcon(
painter: Painter,
tint: Color,
size: Dp,
badgeDescription: String?,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.size(size)) {
Icon(
painter = painter,
contentDescription = null,
tint = tint,
modifier = Modifier.size(size)
)
if (badgeDescription != null) {
Badge(
modifier = Modifier
.align(Alignment.TopEnd)
.offset(x = BadgeOutset, y = -BadgeOutset)
.testTag("badge")
.semantics { contentDescription = badgeDescription }
)
}
}
}
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -29,7 +28,8 @@ fun CollapsibleHeader(
onToggle: () -> Unit, onToggle: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
subtitle: String? = null, subtitle: String? = null,
testTag: String? = null testTag: String? = null,
badgeDescription: String? = null,
) { ) {
Row( Row(
modifier = modifier modifier = modifier
@@ -43,11 +43,11 @@ fun CollapsibleHeader(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Icon( BadgedIcon(
painter = painterResource(iconRes), painter = painterResource(iconRes),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp) size = 24.dp,
badgeDescription = badgeDescription,
) )
Column( Column(
@@ -13,7 +13,7 @@ object DateUtils {
return dateFormat.format(calendar.time) return dateFormat.format(calendar.time)
} }
fun extractDateFromFileName(fileName: String): String? { fun extractDateFromFileName(fileName: String): String? {
val regex = Regex("""_(\d{6})\.(gz|dsl|idx|dsl\.dz|dsl\.idx|dsl\.gz)$""") val regex = Regex("""_(\d{6})\.(gz|dsl|idx|idx\.fold|dsl\.dz|dsl\.idx|dsl\.idx\.fold|dsl\.gz)$""")
return regex.find(fileName)?.groupValues?.get(1) return regex.find(fileName)?.groupValues?.get(1)
} }
fun isDateBefore(date1: String, date2: String): Boolean { fun isDateBefore(date1: String, date2: String): Boolean {
@@ -19,6 +19,8 @@ class FileStorageManager {
private const val DELETE_WAIT_INTERVAL = 50L private const val DELETE_WAIT_INTERVAL = 50L
private const val MAX_DELETE_WAIT_COUNT = 10 private const val MAX_DELETE_WAIT_COUNT = 10
private const val DEFAULT_MAX_RETRIES = 5 private const val DEFAULT_MAX_RETRIES = 5
private const val CREATE_RETRY_DELAY = 100L
private const val MAX_CREATE_RETRIES = 3
private const val TAG = "FileStorageManager" private const val TAG = "FileStorageManager"
} }
@@ -87,6 +89,7 @@ class FileStorageManager {
Log.e(TAG, "Could not delete existing file: $fileName") Log.e(TAG, "Could not delete existing file: $fileName")
return@withContext null return@withContext null
} }
awaitGone(targetFile::exists)
} else { } else {
return@withContext Uri.fromFile(targetFile) return@withContext Uri.fromFile(targetFile)
} }
@@ -95,7 +98,18 @@ class FileStorageManager {
if (tempBinFile.exists()) { if (tempBinFile.exists()) {
tempBinFile.delete() tempBinFile.delete()
} }
if (targetFile.createNewFile()) { var created = targetFile.createNewFile()
var createAttempt = 0
while (!created && createAttempt < MAX_CREATE_RETRIES) {
delay(CREATE_RETRY_DELAY.milliseconds)
if (targetFile.exists()) {
targetFile.delete()
awaitGone(targetFile::exists)
}
created = targetFile.createNewFile()
createAttempt++
}
if (created) {
Uri.fromFile(targetFile) Uri.fromFile(targetFile)
} else { } else {
Log.e(TAG, "Failed to create file: $fileName") Log.e(TAG, "Failed to create file: $fileName")
@@ -116,7 +130,7 @@ class FileStorageManager {
for (attempt in 1..maxRetries) { for (attempt in 1..maxRetries) {
try { try {
if (file.delete()) { if (file.delete()) {
waitForFileDeletion(file) awaitGone(file::exists)
if (!file.exists()) { if (!file.exists()) {
return@withContext true return@withContext true
} }
@@ -141,9 +155,10 @@ class FileStorageManager {
} }
} }
private suspend fun waitForFileDeletion(file: DocumentFile) { // FUSE-backed storage keeps a just-deleted file visible to stat/open for a short window
private suspend fun awaitGone(exists: () -> Boolean) {
var waitCount = 0 var waitCount = 0
while (file.exists() && waitCount < MAX_DELETE_WAIT_COUNT) { while (exists() && waitCount < MAX_DELETE_WAIT_COUNT) {
delay(DELETE_WAIT_INTERVAL.milliseconds) delay(DELETE_WAIT_INTERVAL.milliseconds)
waitCount++ waitCount++
} }
@@ -7,11 +7,13 @@ import androidx.core.app.*
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.example.research.MainActivity import com.example.research.MainActivity
import com.example.research.R import com.example.research.R
import com.example.research.common.progress.NotificationPort
import com.example.research.feature.download.receiver.DownloadCancelReceiver import com.example.research.feature.download.receiver.DownloadCancelReceiver
class NotificationHelper(private val context: Context) { class NotificationHelper(private val context: Context) : NotificationPort {
companion object { companion object {
const val CHANNEL_ID = "download_progress_channel" const val CHANNEL_ID = "download_progress_channel"
const val NOTIFICATION_ID = 1 const val NOTIFICATION_ID = 1
const val TERMINAL_NOTIFICATION_ID = 2
} }
private val notificationManager = NotificationManagerCompat.from(context) private val notificationManager = NotificationManagerCompat.from(context)
private var lastNotificationKey: Int = 0 private var lastNotificationKey: Int = 0
@@ -57,13 +59,28 @@ class NotificationHelper(private val context: Context) {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
manager?.createNotificationChannel(channel) manager?.createNotificationChannel(channel)
} }
private fun canShowNotification(): Boolean { private fun canShowNotification(): Boolean =
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) return true android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU ||
return ContextCompat.checkSelfPermission( ContextCompat.checkSelfPermission(
context, context,
android.Manifest.permission.POST_NOTIFICATIONS android.Manifest.permission.POST_NOTIFICATIONS
) == android.content.pm.PackageManager.PERMISSION_GRANTED ) == android.content.pm.PackageManager.PERMISSION_GRANTED
override fun showProgress(percent: Int, titleRes: Int) {
showUnifiedProgressNotification(
title = context.getString(titleRes),
contentText = "$percent%",
progressPercent = percent,
)
} }
override fun showDownloadSuccess() = showSuccessNotification()
override fun showImportSuccess() = showImportSuccessNotification()
override fun showError() = showErrorNotification()
override fun cancelProgressNotification() = cancelNotification()
fun showSuccessNotification() { fun showSuccessNotification() {
if (!canShowNotification()) return if (!canShowNotification()) return
lastNotificationKey = 0 lastNotificationKey = 0
@@ -74,7 +91,7 @@ class NotificationHelper(private val context: Context) {
.setContentIntent(mainActivityPendingIntent) .setContentIntent(mainActivityPendingIntent)
.setAutoCancel(true) .setAutoCancel(true)
.build() .build()
safeNotify(notification) safeNotifyTerminal(notification)
} }
fun showErrorNotification() { fun showErrorNotification() {
if (!canShowNotification()) return if (!canShowNotification()) return
@@ -86,13 +103,19 @@ class NotificationHelper(private val context: Context) {
.setContentIntent(mainActivityPendingIntent) .setContentIntent(mainActivityPendingIntent)
.setAutoCancel(true) .setAutoCancel(true)
.build() .build()
safeNotify(notification) safeNotifyTerminal(notification)
} }
fun cancelNotification() { fun cancelNotification() {
lastNotificationKey = 0 lastNotificationKey = 0
notificationManager.cancel(NOTIFICATION_ID) notificationManager.cancel(NOTIFICATION_ID)
} }
override fun cancelTerminalNotification() {
notificationManager.cancel(TERMINAL_NOTIFICATION_ID)
}
override fun hasActiveProgressNotification(): Boolean = lastNotificationKey != 0
fun showUnifiedProgressNotification( fun showUnifiedProgressNotification(
title: String, title: String,
contentText: String, contentText: String,
@@ -142,6 +165,14 @@ class NotificationHelper(private val context: Context) {
// Notification permission denied or security policy blocked // Notification permission denied or security policy blocked
} }
} }
@SuppressLint("MissingPermission")
private fun safeNotifyTerminal(notification: Notification) {
try {
notificationManager.notify(TERMINAL_NOTIFICATION_ID, notification)
} catch (_: SecurityException) {
}
}
fun showImportSuccessNotification() { fun showImportSuccessNotification() {
if (!canShowNotification()) return if (!canShowNotification()) return
lastNotificationKey = 0 lastNotificationKey = 0
@@ -152,7 +183,7 @@ class NotificationHelper(private val context: Context) {
.setContentIntent(mainActivityPendingIntent) .setContentIntent(mainActivityPendingIntent)
.setAutoCancel(true) .setAutoCancel(true)
.build() .build()
safeNotify(notification) safeNotifyTerminal(notification)
} }
fun createForegroundNotification(): Notification { fun createForegroundNotification(): Notification {
val notification = NotificationCompat.Builder(context, CHANNEL_ID) val notification = NotificationCompat.Builder(context, CHANNEL_ID)
@@ -102,12 +102,15 @@ data class DictionarySource(
fun validateTemplate(template: String): ValidationResult { fun validateTemplate(template: String): ValidationResult {
val trimmed = template.trim() val trimmed = template.trim()
val lowerTemplate = trimmed.lowercase()
return when { return when {
trimmed.isBlank() -> ValidationResult.Error(ValidationError.EMPTY) trimmed.isBlank() -> ValidationResult.Error(ValidationError.EMPTY)
trimmed.length > 2048 -> ValidationResult.Error(ValidationError.TOO_LONG) trimmed.length > 2048 -> ValidationResult.Error(ValidationError.TOO_LONG)
!trimmed.startsWith("http://") && !trimmed.startsWith("https://") -> !trimmed.startsWith("https://") ->
ValidationResult.Error(ValidationError.INVALID_PROTOCOL) ValidationResult.Error(ValidationError.INVALID_PROTOCOL)
!trimmed.endsWith(".gz") && !trimmed.endsWith(".dsl") && !trimmed.endsWith(".dz") -> !lowerTemplate.endsWith(".gz") &&
!lowerTemplate.endsWith(".dsl") &&
!lowerTemplate.endsWith(".dsl.dz") ->
ValidationResult.Error(ValidationError.INVALID_EXTENSION) ValidationResult.Error(ValidationError.INVALID_EXTENSION)
!isValidUrlStructure(trimmed) -> !isValidUrlStructure(trimmed) ->
ValidationResult.Error(ValidationError.INVALID_URL_STRUCTURE) ValidationResult.Error(ValidationError.INVALID_URL_STRUCTURE)
@@ -132,8 +135,15 @@ data class DictionarySource(
val parts = afterProtocol.split("/") val parts = afterProtocol.split("/")
if (parts.size < 2) return false if (parts.size < 2) return false
val domain = parts[0] val hostWithPort = parts[0]
if (domain.isEmpty()) return false if (hostWithPort.isEmpty()) return false
val colonIndex = hostWithPort.lastIndexOf(':')
if (colonIndex >= 0) {
val port = hostWithPort.substring(colonIndex + 1).toIntOrNull()
if (port == null || port !in 1..65535) return false
}
val domain = if (colonIndex >= 0) hostWithPort.substring(0, colonIndex) else hostWithPort
if (domain != "localhost" && !domain.contains(".")) return false if (domain != "localhost" && !domain.contains(".")) return false
val filename = parts.last() val filename = parts.last()
@@ -13,7 +13,8 @@ data class IndexEntry(
val offset: ArticleOffset = ArticleOffset.ZERO, val offset: ArticleOffset = ArticleOffset.ZERO,
val length: ArticleLength = ArticleLength.ZERO, val length: ArticleLength = ArticleLength.ZERO,
val dictionaryName: String = "", val dictionaryName: String = "",
val dictionaryPath: String = "" val dictionaryPath: String = "",
val isAlias: Boolean = false
) : Comparable<IndexEntry> { ) : Comparable<IndexEntry> {
init { init {
require(word.isNotBlank()) { "Word cannot be blank" } require(word.isNotBlank()) { "Word cannot be blank" }
@@ -1,42 +1,16 @@
package com.example.research.core.domain.model package com.example.research.core.domain.model
import kotlin.math.roundToInt
import androidx.compose.runtime.Immutable
/** /**
* Snapshot of an ongoing indexing operation. * Snapshot of an ongoing indexing operation.
* *
* [progress] is the authoritative [0f, 1f] completion value and is O(1) to * [progress] is the authoritative [0f, 1f] completion value: the repository
* compute: the repository's internal `ProgressTracker` maintains a running * aggregates a file-size-weighted sum across files incrementally while
* sum across files incrementally and stores it in [aggregateSum], avoiding * indexing, so reading it is O(1).
* an O(n) walk over [perFileProgress] on every read.
* [perFileProgress] is kept for diagnostics; callers should prefer
* [progress] / [progressPercent].
*/ */
@Immutable
data class IndexingProgress( data class IndexingProgress(
val currentFile: String = "",
val currentIndex: Int = 0,
val totalFiles: Int = 0,
val isIndexing: Boolean = false, val isIndexing: Boolean = false,
val currentFileProgress: Float = 0f, val progress: Float = 0f,
val label: String = "", )
val perFileProgress: Map<String, Float> = emptyMap(),
/** Pre-aggregated sum in [0f, totalFiles] supplied by the producer; -1f = unknown. */
val aggregateSum: Float = -1f,
) {
val progress: Float
get() = if (totalFiles > 0) {
when {
aggregateSum >= 0f -> (aggregateSum / totalFiles).coerceIn(0f, 1f)
perFileProgress.isNotEmpty() -> {
val totalProgress = perFileProgress.values.sum()
(totalProgress / totalFiles).coerceIn(0f, 1f)
}
else -> {
val completedFiles = (currentIndex - 1).coerceAtLeast(0)
((completedFiles + currentFileProgress) / totalFiles).coerceIn(0f, 1f)
}
}
} else 0f
val progressPercent: Int
get() = (progress * 100).roundToInt().coerceIn(0, 100)
}
@@ -0,0 +1,11 @@
package com.example.research.core.domain.model
enum class SearchBarPosition {
TOP,
BOTTOM;
companion object {
fun fromStorage(value: String?): SearchBarPosition =
entries.firstOrNull { it.name == value } ?: TOP
}
}
@@ -0,0 +1,27 @@
package com.example.research.core.domain.usecase
import com.example.research.core.domain.model.Dictionary
import com.example.research.core.domain.model.DictionarySource
import java.io.File
object DictionarySourceFileMatcher {
fun matches(source: DictionarySource, fileName: String): Boolean =
DictionarySource.matchesDictionaryFile(source.urlTemplate, fileName)
fun matches(source: DictionarySource, dictionary: Dictionary): Boolean =
matches(source, File(dictionary.path).name)
fun installedSources(
sources: List<DictionarySource>,
dictionaries: List<Dictionary>,
): List<DictionarySource> = sources.filter { source ->
dictionaries.any { dictionary -> matches(source, dictionary) }
}
fun installedSourcesForFileNames(
sources: List<DictionarySource>,
fileNames: Collection<String>,
): List<DictionarySource> = sources.filter { source ->
fileNames.any { fileName -> matches(source, fileName) }
}
}
@@ -43,12 +43,4 @@ class DictionarySourceValidator {
return ValidationResult.Valid return ValidationResult.Valid
} }
fun findMatchingSourceWithPrecomputedPrefixes(
dictionaryPrefix: String,
sourcesWithPrefixes: List<Pair<DictionarySource, String?>>
): DictionarySource? {
return sourcesWithPrefixes.find { (_, sourcePrefix) ->
sourcePrefix != null && dictionaryPrefix.equals(sourcePrefix, ignoreCase = true)
}?.first
}
} }
@@ -30,18 +30,24 @@ class ManageDictionarySourcesUseCase(
preferencesManager.removeDictionarySource(sourceId) preferencesManager.removeDictionarySource(sourceId)
} }
suspend fun removeSourceForDictionary(dictionary: Dictionary) { suspend fun removeSourceForDictionary(
val dictionaryPrefix = dictionary.name dictionary: Dictionary,
val sources = preferencesManager.dictionarySources.first() remainingDictionaryFileNames: Collection<String>?,
val sourcesWithPrefixes = sources.map { source -> ) {
source to DictionarySource.extractPrefix(source.urlTemplate) if (remainingDictionaryFileNames == null) return
}
val matchingSource = validator.findMatchingSourceWithPrecomputedPrefixes(dictionaryPrefix, sourcesWithPrefixes) val sources = preferencesManager.dictionarySources.first()
if (matchingSource != null) { val sourceIdsToRemove = sources
preferencesManager.removeDictionarySource(matchingSource.id) .filter { source ->
DictionarySourceFileMatcher.matches(source, dictionary) &&
remainingDictionaryFileNames.none { fileName ->
DictionarySourceFileMatcher.matches(source, fileName)
} }
} }
.map(DictionarySource::id)
preferencesManager.removeDictionarySources(sourceIdsToRemove)
}
sealed class AddSourceResult { sealed class AddSourceResult {
object Success : AddSourceResult() object Success : AddSourceResult()
@@ -0,0 +1,128 @@
package com.example.research.core.util
val NaturalStringComparator: Comparator<String> = Comparator { a, b -> compareNatural(a, b) }
private const val NUMERAL_CAP = 1_000_000_000_000_000_000L
private fun digitOf(c: Char): Int = when (c) {
'〇', '零' -> 0
'一', '壹', '壱', '弌' -> 1
'二', '貳', '贰', '弐', '弍', '兩', '两' -> 2
'三', '叁', '參', '参', '弎' -> 3
'四', '肆' -> 4
'五', '伍' -> 5
'六', '陸', '陆' -> 6
'七', '柒' -> 7
'八', '捌' -> 8
'九', '玖' -> 9
else -> Character.digit(c, 10)
}
private fun smallUnitOf(c: Char): Long = when (c) {
'十', '拾' -> 10L
'百', '佰' -> 100L
'千', '仟' -> 1000L
else -> 0L
}
private fun fixedTensOf(c: Char): Long = when (c) {
'廿' -> 20L
'卅' -> 30L
'卌' -> 40L
else -> 0L
}
private fun bigUnitOf(c: Char): Long = when (c) {
'万', '萬' -> 10_000L
'億', '亿' -> 100_000_000L
'兆' -> 1_000_000_000_000L
'京' -> 10_000_000_000_000_000L
else -> 0L
}
private fun isNumeralChar(c: Char): Boolean =
digitOf(c) >= 0 || smallUnitOf(c) > 0L || fixedTensOf(c) > 0L || bigUnitOf(c) > 0L
private fun satMul(a: Long, b: Long): Long =
if (a != 0L && b > NUMERAL_CAP / a) NUMERAL_CAP else minOf(a * b, NUMERAL_CAP)
private fun satAdd(a: Long, b: Long): Long = minOf(a + b, NUMERAL_CAP)
private fun numeralValue(s: String, start: Int, end: Int): Long {
var total = 0L
var section = 0L
var pending = -1L
for (k in start until end) {
val c = s[k]
val digit = digitOf(c)
if (digit >= 0) {
pending = if (pending < 0) digit.toLong() else satAdd(satMul(pending, 10L), digit.toLong())
continue
}
val small = smallUnitOf(c)
if (small > 0L) {
section = satAdd(section, satMul(if (pending < 0) 1L else pending, small))
pending = -1
continue
}
val fixed = fixedTensOf(c)
if (fixed > 0L) {
section = satAdd(section, fixed)
pending = -1
continue
}
val base = satAdd(section, maxOf(pending, 0L))
total = satAdd(total, satMul(if (base == 0L) 1L else base, bigUnitOf(c)))
section = 0L
pending = -1
}
return satAdd(total, satAdd(section, maxOf(pending, 0L)))
}
private fun compareNatural(a: String, b: String): Int {
var i = 0
var j = 0
while (i < a.length && j < b.length) {
val ca = a[i]
val cb = b[j]
val asciiA = ca in '0'..'9'
val asciiB = cb in '0'..'9'
when {
!asciiA && !asciiB && isNumeralChar(ca) && isNumeralChar(cb) -> {
var endA = i
while (endA < a.length && isNumeralChar(a[endA])) endA++
var endB = j
while (endB < b.length && isNumeralChar(b[endB])) endB++
val diff = numeralValue(a, i, endA).compareTo(numeralValue(b, j, endB))
if (diff != 0) return diff
i = endA
j = endB
}
asciiA && asciiB -> {
var endA = i
while (endA < a.length && a[endA] in '0'..'9') endA++
var endB = j
while (endB < b.length && b[endB] in '0'..'9') endB++
var startA = i
while (startA < endA - 1 && a[startA] == '0') startA++
var startB = j
while (startB < endB - 1 && b[startB] == '0') startB++
val lengthA = endA - startA
val lengthB = endB - startB
if (lengthA != lengthB) return lengthA.compareTo(lengthB)
for (k in 0 until lengthA) {
val diff = a[startA + k].compareTo(b[startB + k])
if (diff != 0) return diff
}
i = endA
j = endB
}
else -> {
if (ca != cb) return ca.compareTo(cb)
i++
j++
}
}
}
return (a.length - i).compareTo(b.length - j)
}
@@ -40,6 +40,10 @@ fun Throwable.isNetworkError(): Boolean {
if (isSslHandshakeError()) return true if (isSslHandshakeError()) return true
return when (this) { return when (this) {
is java.net.UnknownHostException,
is java.net.ConnectException,
is java.net.NoRouteToHostException,
is java.net.BindException -> true
is java.io.IOException -> { is java.io.IOException -> {
val message = message ?: "" val message = message ?: ""
message.contains("connection", ignoreCase = true) || message.contains("connection", ignoreCase = true) ||
@@ -49,9 +53,14 @@ fun Throwable.isNetworkError(): Boolean {
message.contains("unreachable", ignoreCase = true) || message.contains("unreachable", ignoreCase = true) ||
message.contains("no route", ignoreCase = true) || message.contains("no route", ignoreCase = true) ||
message.contains("broken pipe", ignoreCase = true) || message.contains("broken pipe", ignoreCase = true) ||
message.contains("resolve host", ignoreCase = true) ||
message.contains("unexpected end of stream", ignoreCase = true) ||
message.contains("socket closed", ignoreCase = true) ||
message.contains("ECONNRESET", ignoreCase = true) || message.contains("ECONNRESET", ignoreCase = true) ||
message.contains("ECONNREFUSED", ignoreCase = true) || message.contains("ECONNREFUSED", ignoreCase = true) ||
message.contains("ENETUNREACH", ignoreCase = true) message.contains("ECONNABORTED", ignoreCase = true) ||
message.contains("ENETUNREACH", ignoreCase = true) ||
message.contains("EHOSTUNREACH", ignoreCase = true)
} }
else -> false else -> false
} }
@@ -9,6 +9,32 @@ fun String.sanitizeQuery(): String {
.take(MAX_QUERY_LENGTH) .take(MAX_QUERY_LENGTH)
} }
fun String.foldLatinDiacritics(): String {
val decomposed = java.text.Normalizer.normalize(this, java.text.Normalizer.Form.NFD)
val folded = StringBuilder(decomposed.length)
var followsLatinBase = false
var index = 0
while (index < decomposed.length) {
val codePoint = decomposed.codePointAt(index)
val type = Character.getType(codePoint)
val isMark = type == Character.NON_SPACING_MARK.toInt() ||
type == Character.COMBINING_SPACING_MARK.toInt() ||
type == Character.ENCLOSING_MARK.toInt()
if (!(isMark && followsLatinBase)) {
folded.appendCodePoint(codePoint)
}
if (!isMark) {
followsLatinBase = Character.UnicodeScript.of(codePoint) == Character.UnicodeScript.LATIN
}
index += Character.charCount(codePoint)
}
return java.text.Normalizer.normalize(folded, java.text.Normalizer.Form.NFC)
}
fun String.sanitizeCacheKey(): String { fun String.sanitizeCacheKey(): String {
return this.replace(":", "_").replace("\n", "_").replace("\t", "_") return this.replace(":", "_").replace("\n", "_").replace("\t", "_")
} }
@@ -34,7 +60,10 @@ fun String.extractDictionaryPrefix(): String {
} }
fun String.removeAccentTagsForIndexing(): String { fun String.removeAccentTagsForIndexing(): String =
rewriteAccentTags { builder, from, to -> builder.append(this, from, to) }
internal inline fun String.rewriteAccentTags(appendInner: (StringBuilder, Int, Int) -> Unit): String {
val firstOpen = indexOf("[']") val firstOpen = indexOf("[']")
if (firstOpen < 0) return this if (firstOpen < 0) return this
@@ -54,7 +83,7 @@ fun String.removeAccentTagsForIndexing(): String {
builder = StringBuilder(length) builder = StringBuilder(length)
} }
builder.append(this, appendStart, index) builder.append(this, appendStart, index)
builder.append(this, index + 3, scan) appendInner(builder, index + 3, scan)
index = scan + 4 index = scan + 4
appendStart = index appendStart = index
continue continue
@@ -68,7 +97,7 @@ fun String.removeAccentTagsForIndexing(): String {
}?.toString() ?: this }?.toString() ?: this
} }
private fun String.startsWithAt(start: Int, literal: String): Boolean { internal fun String.startsWithAt(start: Int, literal: String): Boolean {
if (start < 0 || start + literal.length > length) return false if (start < 0 || start + literal.length > length) return false
for (i in literal.indices) { for (i in literal.indices) {
if (this[start + i] != literal[i]) return false if (this[start + i] != literal[i]) return false
@@ -6,23 +6,44 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.example.research.core.domain.model.DictionarySource import com.example.research.core.domain.model.DictionarySource
import com.example.research.core.domain.model.SearchBarPosition
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.File import java.io.File
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings") private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
data class SectionExpansionStates(
val isThemeExpanded: Boolean = false,
val isLanguageExpanded: Boolean = false,
val isSearchBarExpanded: Boolean = false,
val isDictionariesExpanded: Boolean = true,
)
class PreferencesManager(private val context: Context) { class PreferencesManager(private val context: Context) {
private object PreferencesKeys { private object PreferencesKeys {
val THEME_MODE = stringPreferencesKey("theme_mode") val THEME_MODE = stringPreferencesKey("theme_mode")
val DICTIONARY_SOURCES = stringPreferencesKey("dictionary_sources") val DICTIONARY_SOURCES = stringPreferencesKey("dictionary_sources")
val LANGUAGE = stringPreferencesKey("language") val LANGUAGE = stringPreferencesKey("language")
val SEARCH_BAR_POSITION = stringPreferencesKey("search_bar_position")
val IS_THEME_EXPANDED = booleanPreferencesKey("is_theme_expanded") val IS_THEME_EXPANDED = booleanPreferencesKey("is_theme_expanded")
val IS_LANGUAGE_EXPANDED = booleanPreferencesKey("is_language_expanded") val IS_LANGUAGE_EXPANDED = booleanPreferencesKey("is_language_expanded")
val IS_SEARCH_BAR_EXPANDED = booleanPreferencesKey("is_search_bar_expanded")
val IS_DICTIONARIES_EXPANDED = booleanPreferencesKey("is_dictionaries_expanded") val IS_DICTIONARIES_EXPANDED = booleanPreferencesKey("is_dictionaries_expanded")
val DISABLED_DICTIONARY_PATHS = stringSetPreferencesKey("disabled_dictionary_paths")
val HAD_NO_DICTIONARIES = booleanPreferencesKey("had_no_dictionaries")
val DICTIONARY_STATUS = stringPreferencesKey("dictionary_status")
val SEARCH_HISTORY = stringPreferencesKey("search_history")
}
private companion object {
const val MAX_SEARCH_HISTORY_ENTRIES = 100
} }
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
@@ -46,6 +67,17 @@ class PreferencesManager(private val context: Context) {
} }
} }
val searchBarPosition: Flow<SearchBarPosition> = context.dataStore.data
.map { preferences ->
SearchBarPosition.fromStorage(preferences[PreferencesKeys.SEARCH_BAR_POSITION])
}
suspend fun saveSearchBarPosition(position: SearchBarPosition) {
context.dataStore.edit { preferences ->
preferences[PreferencesKeys.SEARCH_BAR_POSITION] = position.name
}
}
val dictionarySources: Flow<List<DictionarySource>> = context.dataStore.data val dictionarySources: Flow<List<DictionarySource>> = context.dataStore.data
.map { preferences -> .map { preferences ->
val sourcesJson = preferences[PreferencesKeys.DICTIONARY_SOURCES] val sourcesJson = preferences[PreferencesKeys.DICTIONARY_SOURCES]
@@ -60,6 +92,63 @@ class PreferencesManager(private val context: Context) {
} }
} }
val disabledDictionaryPaths: Flow<Set<String>> = context.dataStore.data
.map { preferences ->
preferences[PreferencesKeys.DISABLED_DICTIONARY_PATHS] ?: emptySet()
}
val hadNoDictionaries: Flow<Boolean> = context.dataStore.data
.map { preferences ->
preferences[PreferencesKeys.HAD_NO_DICTIONARIES] ?: true
}
suspend fun setHadNoDictionaries(value: Boolean) {
context.dataStore.edit { preferences ->
preferences[PreferencesKeys.HAD_NO_DICTIONARIES] = value
}
}
suspend fun dictionaryStatus(): String? =
context.dataStore.data.first()[PreferencesKeys.DICTIONARY_STATUS]
suspend fun saveDictionaryStatus(status: String) {
context.dataStore.edit { preferences ->
preferences[PreferencesKeys.DICTIONARY_STATUS] = status
}
}
suspend fun sectionExpansionStates(): SectionExpansionStates =
context.dataStore.data.first().let { preferences ->
SectionExpansionStates(
isThemeExpanded = preferences[PreferencesKeys.IS_THEME_EXPANDED] ?: false,
isLanguageExpanded = preferences[PreferencesKeys.IS_LANGUAGE_EXPANDED] ?: false,
isSearchBarExpanded = preferences[PreferencesKeys.IS_SEARCH_BAR_EXPANDED] ?: false,
isDictionariesExpanded = preferences[PreferencesKeys.IS_DICTIONARIES_EXPANDED] ?: true,
)
}
suspend fun setDictionaryActive(path: String, isActive: Boolean) {
context.dataStore.edit { preferences ->
val disabledPaths = preferences[PreferencesKeys.DISABLED_DICTIONARY_PATHS]
.orEmpty()
.toMutableSet()
if (isActive) {
disabledPaths.remove(path)
} else {
disabledPaths.add(path)
}
if (disabledPaths.isEmpty()) {
preferences.remove(PreferencesKeys.DISABLED_DICTIONARY_PATHS)
} else {
preferences[PreferencesKeys.DISABLED_DICTIONARY_PATHS] = disabledPaths
}
}
}
suspend fun removeDictionaryActiveState(path: String) {
setDictionaryActive(path, true)
}
suspend fun saveThemeMode(theme: String) { suspend fun saveThemeMode(theme: String) {
context.dataStore.edit { preferences -> context.dataStore.edit { preferences ->
preferences[PreferencesKeys.THEME_MODE] = theme preferences[PreferencesKeys.THEME_MODE] = theme
@@ -76,6 +165,11 @@ class PreferencesManager(private val context: Context) {
preferences[PreferencesKeys.IS_LANGUAGE_EXPANDED] ?: false preferences[PreferencesKeys.IS_LANGUAGE_EXPANDED] ?: false
} }
val isSearchBarExpanded: Flow<Boolean> = context.dataStore.data
.map { preferences ->
preferences[PreferencesKeys.IS_SEARCH_BAR_EXPANDED] ?: false
}
val isDictionariesExpanded: Flow<Boolean> = context.dataStore.data val isDictionariesExpanded: Flow<Boolean> = context.dataStore.data
.map { preferences -> .map { preferences ->
preferences[PreferencesKeys.IS_DICTIONARIES_EXPANDED] ?: true preferences[PreferencesKeys.IS_DICTIONARIES_EXPANDED] ?: true
@@ -86,6 +180,7 @@ class PreferencesManager(private val context: Context) {
when (key) { when (key) {
"theme" -> preferences[PreferencesKeys.IS_THEME_EXPANDED] = isExpanded "theme" -> preferences[PreferencesKeys.IS_THEME_EXPANDED] = isExpanded
"language" -> preferences[PreferencesKeys.IS_LANGUAGE_EXPANDED] = isExpanded "language" -> preferences[PreferencesKeys.IS_LANGUAGE_EXPANDED] = isExpanded
"search_bar" -> preferences[PreferencesKeys.IS_SEARCH_BAR_EXPANDED] = isExpanded
"dictionaries" -> preferences[PreferencesKeys.IS_DICTIONARIES_EXPANDED] = isExpanded "dictionaries" -> preferences[PreferencesKeys.IS_DICTIONARIES_EXPANDED] = isExpanded
} }
} }
@@ -132,6 +227,51 @@ class PreferencesManager(private val context: Context) {
} }
} }
val searchHistory: Flow<List<String>> = context.dataStore.data
.map { preferences -> preferences[PreferencesKeys.SEARCH_HISTORY] }
.distinctUntilChanged()
.map(::decodeSearchHistory)
suspend fun addSearchHistoryEntry(query: String) {
context.dataStore.edit { preferences ->
val current = decodeSearchHistory(preferences[PreferencesKeys.SEARCH_HISTORY])
.toMutableList()
current.removeAll { it.equals(query, ignoreCase = true) }
current.add(0, query)
preferences[PreferencesKeys.SEARCH_HISTORY] =
json.encodeToString(current.take(MAX_SEARCH_HISTORY_ENTRIES))
}
}
suspend fun removeSearchHistoryEntry(query: String) {
context.dataStore.edit { preferences ->
val current = decodeSearchHistory(preferences[PreferencesKeys.SEARCH_HISTORY])
.toMutableList()
if (current.removeAll { it == query }) {
if (current.isEmpty()) {
preferences.remove(PreferencesKeys.SEARCH_HISTORY)
} else {
preferences[PreferencesKeys.SEARCH_HISTORY] = json.encodeToString(current)
}
}
}
}
suspend fun clearSearchHistory() {
context.dataStore.edit { preferences ->
preferences.remove(PreferencesKeys.SEARCH_HISTORY)
}
}
private fun decodeSearchHistory(value: String?): List<String> {
if (value == null) return emptyList()
return try {
json.decodeFromString<List<String>>(value)
} catch (_: Exception) {
emptyList()
}
}
suspend fun sanitizeDictionarySources() { suspend fun sanitizeDictionarySources() {
context.dataStore.edit { preferences -> context.dataStore.edit { preferences ->
val currentSources = decodeSources( val currentSources = decodeSources(
@@ -17,72 +17,15 @@ import java.io.File
class IndexEntryPagingSource( class IndexEntryPagingSource(
private val indexSearcher: IndexSearcher, private val indexSearcher: IndexSearcher,
private val dictionaries: List<Dictionary>, dictionaries: List<Dictionary>,
private val query: String private val query: String
) : PagingSource<Int, IndexEntry>() { ) : PagingSource<Int, IndexEntry>() {
private var searchResults: List<IndexEntry>? = null private val activeDictionaries = dictionaries.filter { it.isActive }
override val jumpingSupported: Boolean = true
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, IndexEntry> { override suspend fun load(params: LoadParams<Int>): LoadResult<Int, IndexEntry> {
val offset = params.key ?: 0
return try { return try {
val allResults = searchResults ?: ReSearchTrace.asyncSection(ReSearchTrace.SEARCH_PAGING) { loadAll()
coroutineScope {
val rawResults = dictionaries
.filter { it.isActive }
.map { dict ->
async {
val indexFile = File(dict.indexPath)
if (!indexFile.exists()) return@async emptyList()
try {
val results = indexSearcher.search(
pathOrUri = indexFile.absolutePath,
query = query,
includeSubstringMatches = false
)
results.map {
it.withDictionary(
dict.metadata.name.ifBlank { dict.name },
dict.path
)
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
emptyList()
}
}
}
.awaitAll()
.flatten()
// Precompute sorting keys to optimize comparator performance.
// entry.word is already lowercase from indexing.
val normalizedQuery = query.sanitizeQuery().lowercase().trim()
rawResults.rankBySearchRelevance(
SearchRankingContext(normalizedQuery)
)
}
}.also { searchResults = it }
val totalSize = allResults.size
val startIndex = offset.coerceAtLeast(0).coerceAtMost(totalSize)
val endIndex = (startIndex + params.loadSize).coerceAtMost(totalSize)
val pageItems = allResults.subList(startIndex, endIndex)
val hasMore = endIndex < totalSize
val nextKey = if (hasMore) endIndex else null
val prevKey = if (offset == 0) null else (offset - params.loadSize).coerceAtLeast(0)
LoadResult.Page(
data = pageItems,
prevKey = prevKey,
nextKey = nextKey
)
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
@@ -90,10 +33,55 @@ class IndexEntryPagingSource(
} }
} }
override fun getRefreshKey(state: PagingState<Int, IndexEntry>): Int? { private suspend fun loadAll(): LoadResult<Int, IndexEntry> =
return state.anchorPosition?.let { anchorPosition -> ReSearchTrace.asyncSection(ReSearchTrace.SEARCH_PAGING) {
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(state.config.pageSize) coroutineScope {
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(state.config.pageSize) val perDictionary = activeDictionaries
.map { dict ->
async {
val indexFile = File(dict.indexPath)
if (!indexFile.exists()) return@async null
try {
val entries = indexSearcher.search(
pathOrUri = indexFile.absolutePath,
query = query,
includeSubstringMatches = false
)
dict to entries
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
null
} }
} }
}
.awaitAll()
.filterNotNull()
val rawResults = perDictionary.flatMap { (dict, entries) ->
entries.map { entry ->
entry.withDictionary(
dict.metadata.name.ifBlank { dict.name },
dict.path
)
}
}
val normalizedQuery = query.sanitizeQuery().lowercase().trim()
val ranked = rawResults
.rankBySearchRelevance(SearchRankingContext(normalizedQuery))
.distinctBy { Triple(it.dictionaryPath, it.word, it.offset.value) }
LoadResult.Page(
data = ranked,
prevKey = null,
nextKey = null
)
}
}
override fun getRefreshKey(state: PagingState<Int, IndexEntry>): Int? {
return null
}
} }
@@ -180,14 +180,19 @@ internal fun parseDslArticleContent(rawContent: String): String {
while (i < len) { while (i < len) {
val lineStart = i val lineStart = i
while (i < len && rawContent[i] != '\n') i++ while (i < len && rawContent[i] != '\n' && rawContent[i] != '\r') i++
val lineEnd = i val lineEnd = i
if (i < len && rawContent[i] == '\r') {
i++
if (i < len && rawContent[i] == '\n') i++ if (i < len && rawContent[i] == '\n') i++
} else if (i < len && rawContent[i] == '\n') {
i++
}
if (lineStart == lineEnd && i >= len) break if (lineStart == lineEnd && i >= len) break
val line = rawContent.substring(lineStart, lineEnd) val line = rawContent.substring(lineStart, lineEnd)
val trimmed = line.trimEnd() val trimmed = line.trim()
if (trimmed.isEmpty()) continue if (trimmed.isEmpty()) continue
if (skippingHeadwords) { if (skippingHeadwords) {
@@ -58,17 +58,23 @@ object DslCharsetDetector {
private fun looksLikeUtf8(inputStream: java.io.InputStream): Boolean { private fun looksLikeUtf8(inputStream: java.io.InputStream): Boolean {
if (!inputStream.markSupported()) return false if (!inputStream.markSupported()) return false
inputStream.mark(8192) inputStream.mark(8192)
return try { try {
val sample = ByteArray(8192) val sample = ByteArray(8192)
val read = inputStream.read(sample) val read = inputStream.read(sample)
if (read <= 0) return false if (read <= 0) return false
val decoder = Charsets.UTF_8.newDecoder() val decoder = Charsets.UTF_8.newDecoder()
.onMalformedInput(java.nio.charset.CodingErrorAction.REPORT) .onMalformedInput(java.nio.charset.CodingErrorAction.REPORT)
.onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT) .onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT)
decoder.decode(java.nio.ByteBuffer.wrap(sample, 0, read)) val byteBuffer = java.nio.ByteBuffer.wrap(sample, 0, read)
true val charBuffer = java.nio.CharBuffer.allocate(8192)
while (true) {
val result = decoder.decode(byteBuffer, charBuffer, false)
if (result.isError) return false
if (result.isUnderflow) return true
charBuffer.clear()
}
} catch (_: Exception) { } catch (_: Exception) {
false return false
} finally { } finally {
inputStream.reset() inputStream.reset()
} }
@@ -1,5 +1,8 @@
package com.example.research.data.parser package com.example.research.data.parser
import com.example.research.core.util.rewriteAccentTags
import com.example.research.core.util.startsWithAt
object DslHeadwordParser { object DslHeadwordParser {
private val FORMATTING_LITERAL_TAGS = arrayOf( private val FORMATTING_LITERAL_TAGS = arrayOf(
@@ -24,10 +27,11 @@ object DslHeadwordParser {
} }
if (trimmed.indexOf('{') == -1 && trimmed.indexOf('[') == -1) { if (trimmed.indexOf('{') == -1 && trimmed.indexOf('[') == -1) {
val unescaped = unescape(trimmed)
return ParsedHeadword( return ParsedHeadword(
simplified = trimmed, simplified = unescaped,
displayText = trimmed, displayText = unescaped,
searchableText = trimmed.lowercase() searchableText = unescaped.lowercase()
) )
} }
@@ -60,9 +64,9 @@ object DslHeadwordParser {
val searchableText = createSearchableText(trimmed) val searchableText = createSearchableText(trimmed)
return ParsedHeadword( return ParsedHeadword(
simplified = simplified, simplified = unescape(simplified),
displayText = displayText, displayText = unescape(displayText),
searchableText = searchableText searchableText = unescape(searchableText)
) )
} }
@@ -116,12 +120,12 @@ object DslHeadwordParser {
} }
private fun reconstructDisplayPrefix(value: String): String { private fun reconstructDisplayPrefix(value: String): String {
val reconstructed = removeCurlyBraceDelimiters(value) return removeFormattingTags(removeCurlyBraceDelimiters(value).applyAccentMarks()).trim()
val formattingTagIndex = firstFormattingTagIndex(reconstructed) }
return if (formattingTagIndex >= 0) {
reconstructed.substring(0, formattingTagIndex).trim() private fun String.applyAccentMarks(): String = rewriteAccentTags { builder, from, to ->
} else { if (to > from) {
reconstructed.trim() builder.append(this[from]).append('\u0301').append(this, from + 1, to)
} }
} }
@@ -129,28 +133,30 @@ object DslHeadwordParser {
if (value.indexOf('{') == -1 && value.indexOf('}') == -1) return value if (value.indexOf('{') == -1 && value.indexOf('}') == -1) return value
return buildString(value.length) { return buildString(value.length) {
value.forEach { char ->
if (char != '{' && char != '}') append(char)
}
}
}
private fun firstFormattingTagIndex(value: String): Int {
var index = 0 var index = 0
while (index < value.length) { while (index < value.length) {
if (value[index] == '[' && formattingTagEnd(value, index) > index) { val char = value[index]
return index if (char == '\\' && index + 1 < value.length) {
} append(char)
append(value[index + 1])
index += 2
} else {
if (char != '{' && char != '}') append(char)
index++ index++
} }
return -1 }
}
} }
private fun firstCurlyContent(value: String): String? { private fun firstCurlyContent(value: String): String? {
var index = 0 var index = 0
while (index < value.length) { while (index < value.length) {
if (value[index] == '\\' && index + 1 < value.length) {
index += 2
continue
}
if (value[index] == '{') { if (value[index] == '{') {
val end = value.indexOf('}', startIndex = index + 1) val end = matchingCurlyEnd(value, index + 1)
if (end > index + 1) { if (end > index + 1) {
return value.substring(index + 1, end) return value.substring(index + 1, end)
} }
@@ -161,7 +167,7 @@ object DslHeadwordParser {
} }
private fun substringBeforeFirstBracket(value: String): String { private fun substringBeforeFirstBracket(value: String): String {
val bracketIndex = value.indexOf('[') val bracketIndex = indexOfUnescaped(value, '[')
return if (bracketIndex >= 0) value.substring(0, bracketIndex) else value return if (bracketIndex >= 0) value.substring(0, bracketIndex) else value
} }
@@ -171,8 +177,12 @@ object DslHeadwordParser {
var index = 0 var index = 0
while (index < value.length) { while (index < value.length) {
if (value[index] == '\\' && index + 1 < value.length) {
index += 2
continue
}
if (value[index] == '{') { if (value[index] == '{') {
val end = value.indexOf('}', startIndex = index + 1) val end = matchingCurlyEnd(value, index + 1)
if (end >= 0 && (removeEmpty || end > index + 1)) { if (end >= 0 && (removeEmpty || end > index + 1)) {
if (builder == null) { if (builder == null) {
builder = StringBuilder(value.length) builder = StringBuilder(value.length)
@@ -197,6 +207,10 @@ object DslHeadwordParser {
var index = 0 var index = 0
while (index < value.length) { while (index < value.length) {
if (value[index] == '\\' && index + 1 < value.length) {
index += 2
continue
}
if (value[index] == '[') { if (value[index] == '[') {
val tagEnd = formattingTagEnd(value, index) val tagEnd = formattingTagEnd(value, index)
if (tagEnd > index) { if (tagEnd > index) {
@@ -217,6 +231,56 @@ object DslHeadwordParser {
}?.toString() ?: value }?.toString() ?: value
} }
private fun matchingCurlyEnd(value: String, startIndex: Int): Int {
var index = startIndex
while (index < value.length) {
when {
value[index] == '\\' && index + 1 < value.length -> index += 2
value[index] == '{' -> return -1
value[index] == '}' -> return index
else -> index++
}
}
return -1
}
private fun indexOfUnescaped(
value: String,
target: Char,
startIndex: Int = 0,
): Int {
var index = startIndex.coerceAtLeast(0)
while (index < value.length) {
if (value[index] == '\\' && index + 1 < value.length) {
index += 2
} else if (value[index] == target) {
return index
} else {
index++
}
}
return -1
}
private fun unescape(value: String): String {
val firstEscape = value.indexOf('\\')
if (firstEscape < 0) return value
return buildString(value.length) {
append(value, 0, firstEscape)
var index = firstEscape
while (index < value.length) {
if (value[index] == '\\' && index + 1 < value.length) {
append(value[index + 1])
index += 2
} else {
append(value[index])
index++
}
}
}
}
private fun formattingTagEnd(value: String, openBracketIndex: Int): Int { private fun formattingTagEnd(value: String, openBracketIndex: Int): Int {
val tokenStart = openBracketIndex + 1 val tokenStart = openBracketIndex + 1
if (tokenStart >= value.length) return -1 if (tokenStart >= value.length) return -1
@@ -270,14 +334,6 @@ object DslHeadwordParser {
return if (index < value.length && value[index] == ']') index + 1 else -1 return if (index < value.length && value[index] == ']') index + 1 else -1
} }
private fun String.startsWithAt(start: Int, literal: String): Boolean {
if (start < 0 || start + literal.length > length) return false
for (i in literal.indices) {
if (this[start + i] != literal[i]) return false
}
return true
}
private fun Char.isTagWhitespace(): Boolean { private fun Char.isTagWhitespace(): Boolean {
return this == ' ' || return this == ' ' ||
this == '\t' || this == '\t' ||
@@ -4,6 +4,7 @@ import android.util.Log
import com.example.research.core.domain.model.ArticleLength import com.example.research.core.domain.model.ArticleLength
import com.example.research.core.domain.model.ArticleOffset import com.example.research.core.domain.model.ArticleOffset
import com.example.research.core.domain.model.IndexEntry import com.example.research.core.domain.model.IndexEntry
import com.example.research.core.util.foldLatinDiacritics
import com.example.research.core.util.removeAccentTagsForIndexing import com.example.research.core.util.removeAccentTagsForIndexing
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -23,7 +24,7 @@ class DslIndexer(context: Context? = null) {
companion object { companion object {
private const val TAG = "DslIndexer" private const val TAG = "DslIndexer"
const val INDEX_VERSION = 4 const val INDEX_VERSION = 7
const val BUFFER_SIZE = 1048576 const val BUFFER_SIZE = 1048576
const val BATCH_BUFFER_SIZE = 524288 const val BATCH_BUFFER_SIZE = 524288
private const val INDEX_SORT_PARALLELISM = 1 private const val INDEX_SORT_PARALLELISM = 1
@@ -40,11 +41,15 @@ class DslIndexer(context: Context? = null) {
private const val IN_MEMORY_SORT_MAX_ENTRIES = 140_000 private const val IN_MEMORY_SORT_MAX_ENTRIES = 140_000
private const val ESTIMATED_IN_MEMORY_ENTRY_BYTES = 768L private const val ESTIMATED_IN_MEMORY_ENTRY_BYTES = 768L
private const val IN_MEMORY_SORT_HEAP_RESERVE_BYTES = 96L * 1024L * 1024L private const val IN_MEMORY_SORT_HEAP_RESERVE_BYTES = 96L * 1024L * 1024L
fun foldedIndexFile(indexFile: File): File =
File(indexFile.parent ?: ".", "${indexFile.name}.fold")
} }
suspend fun createIndex( suspend fun createIndex(
dslInputStream: java.io.InputStream, dslInputStream: java.io.InputStream,
indexFile: File, indexFile: File,
totalInputBytes: Long = -1L,
onProgress: (Int) -> Unit = {}, onProgress: (Int) -> Unit = {},
onOperationChange: (String) -> Unit = {}, onOperationChange: (String) -> Unit = {},
onFileProgress: (Float) -> Unit = {}, onFileProgress: (Float) -> Unit = {},
@@ -56,21 +61,25 @@ class DslIndexer(context: Context? = null) {
java.io.BufferedInputStream(dslInputStream) java.io.BufferedInputStream(dslInputStream)
} }
val tempFile = File(indexFile.parent, "${indexFile.name}.tmp") val tempFile = File(indexFile.parent, "${indexFile.name}.tmp")
val entryCount: Int val counts: ParsedCounts
try { try {
try { foldedIndexFile(indexFile).delete() } catch (_: Throwable) { /* Legacy v5 leftover */ }
onOperationChange("Parsing file") onOperationChange("Parsing file")
onFileProgress(0.0f) onFileProgress(0.0f)
entryCount = parseDslToTempFile(bufferedStream, tempFile, counts = parseDslToTempFile(bufferedStream, tempFile, totalInputBytes,
onProgress, onFileProgress, onCharsetDetected) onProgress, onFileProgress, onCharsetDetected)
onFileProgress(0.5f) onFileProgress(0.5f)
onOperationChange("Sorting index") onOperationChange("Sorting index")
sortAndWriteIndexFile(tempFile, indexFile, entryCount, onOperationChange, onFileProgress) sortAndWriteIndexFile(tempFile, indexFile, counts, onOperationChange, onFileProgress)
onFileProgress(1f)
} finally { } finally {
try { tempFile.delete() } catch (_: Throwable) { /* Ignore cleanup failure */ } try { tempFile.delete() } catch (_: Throwable) { /* Ignore cleanup failure */ }
} }
try { onProgress(entryCount) } catch (_: Throwable) { /* Ignore progress callback failure */ } try { onProgress(counts.articleCount) } catch (_: Throwable) { /* Ignore progress callback failure */ }
return entryCount return counts.articleCount
} }
private data class ParsedCounts(val articleCount: Int, val totalEntries: Int)
// Blocking file I/O is safe across this file: every suspend fun here is reached only // Blocking file I/O is safe across this file: every suspend fun here is reached only
// from createIndex(), which the sole caller (KotlinDictionaryEngine) runs inside // from createIndex(), which the sole caller (KotlinDictionaryEngine) runs inside
// withContext(Dispatchers.IO). The dispatcher is invisible across the suspend-call // withContext(Dispatchers.IO). The dispatcher is invisible across the suspend-call
@@ -80,10 +89,11 @@ class DslIndexer(context: Context? = null) {
private suspend fun parseDslToTempFile( private suspend fun parseDslToTempFile(
inputStream: java.io.InputStream, inputStream: java.io.InputStream,
tempFile: File, tempFile: File,
totalInputBytes: Long,
onProgress: (Int) -> Unit, onProgress: (Int) -> Unit,
onFileProgress: (Float) -> Unit = {}, onFileProgress: (Float) -> Unit = {},
onCharsetDetected: (DslCharsetDetector.DetectedCharset) -> Unit = {} onCharsetDetected: (DslCharsetDetector.DetectedCharset) -> Unit = {}
): Int { ): ParsedCounts {
val detectedCharset = DslCharsetDetector.detect(inputStream) val detectedCharset = DslCharsetDetector.detect(inputStream)
onCharsetDetected(detectedCharset) onCharsetDetected(detectedCharset)
@@ -98,6 +108,7 @@ class DslIndexer(context: Context? = null) {
) )
var entryCount = 0 var entryCount = 0
var aliasCount = 0
var lineCount = 0 var lineCount = 0
var headwordLineCount = 0 var headwordLineCount = 0
var bodyLineCount = 0 var bodyLineCount = 0
@@ -127,9 +138,10 @@ class DslIndexer(context: Context? = null) {
fun normalizeSearchableWord(searchableText: String, fallback: String): String { fun normalizeSearchableWord(searchableText: String, fallback: String): String {
// Parser already lowercases and strips accent tags for non-empty results; // Parser already lowercases and strips accent tags for non-empty results;
// only the raw-headword fallback still needs normalization. // only the raw-headword fallback still needs normalization.
return searchableText.ifEmpty { val normalized = searchableText.ifEmpty {
fallback.removeAccentTagsForIndexing().lowercase() fallback.removeAccentTagsForIndexing().lowercase()
} }
return java.text.Normalizer.normalize(normalized, java.text.Normalizer.Form.NFC)
} }
DataOutputStream(BufferedOutputStream(FileOutputStream(tempFile), BUFFER_SIZE)).use { output -> DataOutputStream(BufferedOutputStream(FileOutputStream(tempFile), BUFFER_SIZE)).use { output ->
@@ -144,12 +156,23 @@ class DslIndexer(context: Context? = null) {
return return
} }
val length = (groupEndOffset - groupStartOffset).coerceAtLeast(0L).toInt() val length = (groupEndOffset - groupStartOffset).coerceAtLeast(0L).toInt()
for (idx in groupSearchable.indices) { fun writeTempEntry(word: String, display: String, isAlias: Boolean) {
writer.write(groupSearchable[idx]) writer.write(word)
writer.write(groupDisplay[idx]) writer.write(display)
output.writeLong(groupStartOffset) output.writeLong(groupStartOffset)
output.writeInt(length) output.writeInt(length)
output.writeByte(if (isAlias) ENTRY_FLAG_ALIAS else 0)
}
for (idx in groupSearchable.indices) {
val searchableWord = groupSearchable[idx]
val displayWord = groupDisplay[idx]
writeTempEntry(searchableWord, displayWord, isAlias = false)
entryCount++ entryCount++
val foldedWord = searchableWord.foldLatinDiacritics()
if (foldedWord != searchableWord) {
writeTempEntry(foldedWord, displayWord, isAlias = true)
aliasCount++
}
} }
groupSearchable.clear() groupSearchable.clear()
groupDisplay.clear() groupDisplay.clear()
@@ -209,8 +232,12 @@ class DslIndexer(context: Context? = null) {
val yieldThreshold = adaptiveConfig.yieldInterval val yieldThreshold = adaptiveConfig.yieldInterval
if (yieldCounter >= yieldThreshold) { if (yieldCounter >= yieldThreshold) {
onProgress(entryCount) onProgress(entryCount)
val estimatedProgress = (lineCount * 10).toFloat() / (Int.MAX_VALUE / 1000) val parseProgress = if (totalInputBytes > 0L) {
onFileProgress(estimatedProgress.coerceIn(0f, 0.4f)) (reader.position.toFloat() / totalInputBytes.toFloat())
} else {
(lineCount * 10).toFloat() / (Int.MAX_VALUE / 1000)
}
onFileProgress(parseProgress.coerceIn(0f, 0.4f))
if (resourceManager != null) { if (resourceManager != null) {
if (resourceManager.shouldYield(adaptiveConfig)) { if (resourceManager.shouldYield(adaptiveConfig)) {
@@ -230,26 +257,28 @@ class DslIndexer(context: Context? = null) {
try { onProgress(entryCount) } catch (_: Throwable) { /* Ignore progress callback failure */ } try { onProgress(entryCount) } catch (_: Throwable) { /* Ignore progress callback failure */ }
} }
onFileProgress(0.5f) onFileProgress(0.5f)
return entryCount return ParsedCounts(articleCount = entryCount, totalEntries = entryCount + aliasCount)
} }
// Blocking file I/O safe: reached only from createIndex() (runs on Dispatchers.IO). See parseDslToTempFile(). // Blocking file I/O safe: reached only from createIndex() (runs on Dispatchers.IO). See parseDslToTempFile().
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
private suspend fun sortAndWriteIndexFile(tempFile: File, indexFile: File, entryCount: Int, onOperationChange: (String) -> Unit = {}, onFileProgress: (Float) -> Unit = {}) { private suspend fun sortAndWriteIndexFile(tempFile: File, indexFile: File, counts: ParsedCounts, onOperationChange: (String) -> Unit = {}, onFileProgress: (Float) -> Unit = {}) {
if (entryCount == 0) { if (counts.totalEntries == 0) {
DataOutputStream(BufferedOutputStream(FileOutputStream(indexFile))).use { output -> DataOutputStream(BufferedOutputStream(FileOutputStream(indexFile))).use { output ->
output.writeInt(INDEX_VERSION) output.writeInt(INDEX_VERSION)
output.writeInt(0) output.writeInt(0)
output.writeInt(0) output.writeInt(0)
output.writeInt(0)
} }
return return
} }
if (shouldUseInMemorySort(entryCount)) { if (shouldUseInMemorySort(counts.totalEntries)) {
inMemorySortAndWrite(tempFile, indexFile, entryCount) inMemorySortAndWrite(tempFile, indexFile, counts)
onFileProgress(FINAL_INDEX_DONE_PROGRESS) onFileProgress(FINAL_INDEX_DONE_PROGRESS)
} else { } else {
externalSortAndWrite(tempFile, indexFile, entryCount, onOperationChange, onFileProgress) externalSortAndWrite(tempFile, indexFile, counts, onOperationChange, onFileProgress)
} }
} }
private fun shouldUseInMemorySort(entryCount: Int): Boolean { private fun shouldUseInMemorySort(entryCount: Int): Boolean {
if (entryCount <= IN_MEMORY_SORT_ALWAYS_ENTRIES) return true if (entryCount <= IN_MEMORY_SORT_ALWAYS_ENTRIES) return true
if (entryCount > IN_MEMORY_SORT_MAX_ENTRIES) return false if (entryCount > IN_MEMORY_SORT_MAX_ENTRIES) return false
@@ -264,30 +293,22 @@ class DslIndexer(context: Context? = null) {
} }
// Blocking file I/O safe: reached only from createIndex() (runs on Dispatchers.IO). See parseDslToTempFile(). // Blocking file I/O safe: reached only from createIndex() (runs on Dispatchers.IO). See parseDslToTempFile().
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
private suspend fun inMemorySortAndWrite(tempFile: File, indexFile: File, entryCount: Int) { private suspend fun inMemorySortAndWrite(tempFile: File, indexFile: File, counts: ParsedCounts) {
val entries = ArrayList<IndexEntry>(entryCount) val entries = ArrayList<IndexEntry>(counts.totalEntries)
java.io.DataInputStream(java.io.BufferedInputStream(java.io.FileInputStream(tempFile), BATCH_BUFFER_SIZE)).use { input -> java.io.DataInputStream(java.io.BufferedInputStream(java.io.FileInputStream(tempFile), BATCH_BUFFER_SIZE)).use { input ->
repeat(entryCount) { repeat(counts.totalEntries) {
val word = input.readUtf8String() entries.add(input.readTempEntry())
val originalWord = input.readUtf8String()
val offset = input.readLong()
val length = input.readInt()
entries.add(IndexEntry(
word = word,
originalWord = originalWord,
offset = ArticleOffset(offset),
length = ArticleLength(length)
))
if (entries.size % 5000 == 0) { if (entries.size % 5000 == 0) {
try { yield() } catch (_: Throwable) { /* Ignore yield failure */ } try { yield() } catch (_: Throwable) { /* Ignore yield failure */ }
} }
} }
} }
entries.sort() entries.sort()
writeIndexFile(indexFile, entries) writeIndexFile(indexFile, entries, counts.articleCount)
entries.clear() entries.clear()
} }
private suspend fun externalSortAndWrite(tempFile: File, indexFile: File, entryCount: Int, onOperationChange: (String) -> Unit, onFileProgress: (Float) -> Unit) { private suspend fun externalSortAndWrite(tempFile: File, indexFile: File, counts: ParsedCounts, onOperationChange: (String) -> Unit, onFileProgress: (Float) -> Unit) {
val entryCount = counts.totalEntries
val adaptiveConfig = resourceManager?.calculateAdaptiveConfig(estimatedEntryCount = entryCount.toLong()) val adaptiveConfig = resourceManager?.calculateAdaptiveConfig(estimatedEntryCount = entryCount.toLong())
?: ResourceManager.AdaptiveConfig( ?: ResourceManager.AdaptiveConfig(
batchSize = 100_000, batchSize = 100_000,
@@ -317,16 +338,7 @@ class DslIndexer(context: Context? = null) {
val batch = ArrayList<IndexEntry>(currentBatchSize) val batch = ArrayList<IndexEntry>(currentBatchSize)
val batchEnd = minOf(processed + currentBatchSize, entryCount) val batchEnd = minOf(processed + currentBatchSize, entryCount)
while (processed < batchEnd) { while (processed < batchEnd) {
val word = input.readUtf8String() batch.add(input.readTempEntry())
val originalWord = input.readUtf8String()
val offset = input.readLong()
val length = input.readInt()
batch.add(IndexEntry(
word = word,
originalWord = originalWord,
offset = ArticleOffset(offset),
length = ArticleLength(length)
))
processed++ processed++
} }
@@ -422,7 +434,7 @@ class DslIndexer(context: Context? = null) {
onFileProgress(MERGE_START_PROGRESS) onFileProgress(MERGE_START_PROGRESS)
onOperationChange("Merging ${sortedBatches.size} batches") onOperationChange("Merging ${sortedBatches.size} batches")
mergeSortedBatches(sortedBatches, indexFile, entryCount, onOperationChange, onFileProgress) mergeSortedBatches(sortedBatches, indexFile, counts, onOperationChange, onFileProgress)
onFileProgress(FINAL_INDEX_DONE_PROGRESS) onFileProgress(FINAL_INDEX_DONE_PROGRESS)
} finally { } finally {
sortedBatches.forEach { try { it.delete() } catch (_: Throwable) { /* Ignore cleanup failure */ } } sortedBatches.forEach { try { it.delete() } catch (_: Throwable) { /* Ignore cleanup failure */ } }
@@ -443,6 +455,7 @@ class DslIndexer(context: Context? = null) {
writer.write(entry.originalWord) writer.write(entry.originalWord)
output.writeLong(entry.offset.value) output.writeLong(entry.offset.value)
output.writeInt(entry.length.value) output.writeInt(entry.length.value)
output.writeByte(if (entry.isAlias) ENTRY_FLAG_ALIAS else 0)
} }
} }
} }
@@ -453,15 +466,16 @@ class DslIndexer(context: Context? = null) {
private suspend fun mergeSortedBatches( private suspend fun mergeSortedBatches(
batchFiles: List<File>, batchFiles: List<File>,
indexFile: File, indexFile: File,
totalEntries: Int, counts: ParsedCounts,
onOperationChange: (String) -> Unit = {}, onOperationChange: (String) -> Unit = {},
onFileProgress: (Float) -> Unit, onFileProgress: (Float) -> Unit,
) { ) {
if (batchFiles.isEmpty()) return if (batchFiles.isEmpty()) return
val totalEntries = counts.totalEntries
val sparsePoints = mutableListOf<SparsePoint>() val sparsePoints = mutableListOf<SparsePoint>()
val prefixCounts = HashMap<String, Int>(minOf(totalEntries / 10, 100_000)) val prefixCounts = HashMap<String, Int>(minOf(totalEntries / 10, 100_000))
val headerSize = 4 + 4 + 4 val headerSize = 4 + 4 + 4 + 4
val dataScratchFile = File(indexFile.parent, "${indexFile.name}.data") val dataScratchFile = File(indexFile.parent, "${indexFile.name}.data")
val pq = PriorityQueue<BatchReader>() val pq = PriorityQueue<BatchReader>()
@@ -508,7 +522,8 @@ class DslIndexer(context: Context? = null) {
val origSize = writer.write(entry.originalWord) val origSize = writer.write(entry.originalWord)
dataOutput.writeLong(entry.offset.value) dataOutput.writeLong(entry.offset.value)
dataOutput.writeInt(entry.length.value) dataOutput.writeInt(entry.length.value)
currentByteOffset += 2 + wordSize + 2 + origSize + 8 + 4 dataOutput.writeByte(if (entry.isAlias) ENTRY_FLAG_ALIAS else 0)
currentByteOffset += 2 + wordSize + 2 + origSize + 8 + 4 + 1
entryIndex++ entryIndex++
@@ -542,6 +557,7 @@ class DslIndexer(context: Context? = null) {
DataOutputStream(BufferedOutputStream(FileOutputStream(indexFile), BUFFER_SIZE)).use { output -> DataOutputStream(BufferedOutputStream(FileOutputStream(indexFile), BUFFER_SIZE)).use { output ->
output.writeInt(INDEX_VERSION) output.writeInt(INDEX_VERSION)
output.writeInt(totalEntries) output.writeInt(totalEntries)
output.writeInt(counts.articleCount)
output.writeInt(sparsePoints.size) output.writeInt(sparsePoints.size)
sparsePoints.forEachIndexed { idx, point -> sparsePoints.forEachIndexed { idx, point ->
@@ -632,18 +648,14 @@ class DslIndexer(context: Context? = null) {
} }
private fun readNextEntry(input: java.io.DataInputStream): IndexEntry? { private fun readNextEntry(input: java.io.DataInputStream): IndexEntry? {
return try { return try {
val word = input.readUtf8String() input.readTempEntry()
val originalWord = input.readUtf8String()
val offset = ArticleOffset(input.readLong())
val length = ArticleLength(input.readInt())
IndexEntry(word, originalWord, offset, length)
} catch (_: java.io.EOFException) { } catch (_: java.io.EOFException) {
null null
} }
} }
private fun writeIndexFile(indexFile: File, entries: List<IndexEntry>) { private fun writeIndexFile(indexFile: File, entries: List<IndexEntry>, articleCount: Int) {
val sparseData = buildAdaptiveSparseIndex(entries) val sparseData = buildAdaptiveSparseIndex(entries)
val headerSize = 4 + 4 + 4 val headerSize = 4 + 4 + 4 + 4
// Cache sparse table bytes to avoid redundant conversions // Cache sparse table bytes to avoid redundant conversions
val sparseTableBytes = sparseData.map { CachedBytes.from(it.word) } val sparseTableBytes = sparseData.map { CachedBytes.from(it.word) }
@@ -657,13 +669,14 @@ class DslIndexer(context: Context? = null) {
// Calculate sizes without allocating byte arrays // Calculate sizes without allocating byte arrays
val wordSize = utf8ByteSize(entry.word) val wordSize = utf8ByteSize(entry.word)
val origSize = utf8ByteSize(entry.originalWord) val origSize = utf8ByteSize(entry.originalWord)
currentByteOffset += 2 + wordSize + 2 + origSize + 8 + 4 currentByteOffset += 2 + wordSize + 2 + origSize + 8 + 4 + 1
} }
DataOutputStream(BufferedOutputStream(FileOutputStream(indexFile))).use { output -> DataOutputStream(BufferedOutputStream(FileOutputStream(indexFile))).use { output ->
val writer = Utf8Writer(output) val writer = Utf8Writer(output)
output.writeInt(INDEX_VERSION) output.writeInt(INDEX_VERSION)
output.writeInt(entries.size) output.writeInt(entries.size)
output.writeInt(articleCount)
output.writeInt(sparseData.size) output.writeInt(sparseData.size)
sparseData.forEach { point -> sparseData.forEach { point ->
@@ -677,6 +690,7 @@ class DslIndexer(context: Context? = null) {
writer.write(entry.originalWord) writer.write(entry.originalWord)
output.writeLong(entry.offset.value) output.writeLong(entry.offset.value)
output.writeInt(entry.length.value) output.writeInt(entry.length.value)
output.writeByte(if (entry.isAlias) ENTRY_FLAG_ALIAS else 0)
} }
} }
} }
@@ -707,7 +721,7 @@ class DslIndexer(context: Context? = null) {
// Calculate sizes without allocating byte arrays // Calculate sizes without allocating byte arrays
val wordSize = utf8ByteSize(entry.word) val wordSize = utf8ByteSize(entry.word)
val origSize = utf8ByteSize(entry.originalWord) val origSize = utf8ByteSize(entry.originalWord)
currentByteOffset += 2 + wordSize + 2 + origSize + 8 + 4 currentByteOffset += 2 + wordSize + 2 + origSize + 8 + 4 + 1
} }
return sparsePoints return sparsePoints
@@ -824,6 +838,24 @@ private fun java.io.DataInputStream.readUtf8String(): String {
readFully(bytes) readFully(bytes)
return String(bytes, Charsets.UTF_8) return String(bytes, Charsets.UTF_8)
} }
internal const val ENTRY_FLAG_ALIAS = 1
private fun java.io.DataInputStream.readTempEntry(): IndexEntry {
val word = readUtf8String()
val originalWord = readUtf8String()
val offset = readLong()
val length = readInt()
val flags = readUnsignedByte()
return IndexEntry(
word = word,
originalWord = originalWord,
offset = ArticleOffset(offset),
length = ArticleLength(length),
isAlias = flags and ENTRY_FLAG_ALIAS != 0
)
}
private enum class LineKind { private enum class LineKind {
HEADWORD, HEADWORD,
BODY, BODY,
@@ -5,6 +5,7 @@ import com.example.research.R
import com.example.research.core.domain.model.ArticleLength import com.example.research.core.domain.model.ArticleLength
import com.example.research.core.domain.model.ArticleOffset import com.example.research.core.domain.model.ArticleOffset
import com.example.research.core.domain.model.IndexEntry import com.example.research.core.domain.model.IndexEntry
import com.example.research.core.util.foldLatinDiacritics
import com.example.research.core.util.DictionaryError import com.example.research.core.util.DictionaryError
import com.example.research.core.util.sanitizeCacheKey import com.example.research.core.util.sanitizeCacheKey
import com.example.research.core.util.sanitizeQuery import com.example.research.core.util.sanitizeQuery
@@ -17,13 +18,14 @@ import kotlinx.coroutines.yield
import java.io.BufferedInputStream import java.io.BufferedInputStream
import java.io.Closeable import java.io.Closeable
import java.io.DataInputStream import java.io.DataInputStream
import java.io.EOFException
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.RandomAccessFile import java.io.RandomAccessFile
class IndexSearcher(private val context: Context) { class IndexSearcher(private val context: Context) {
private val metadataCache = MetadataLruCache(maxBytes = 64L * 1024L * 1024L) private val metadataCache = MetadataLruCache(maxBytes = 64L * 1024L * 1024L)
private val resultsCache = ResultsLruCache(maxEntries = 20) private val resultsCache = ResultsLruCache<List<IndexEntry>>(maxEntries = 20)
/** /**
* Pre-allocated reusable buffer for readEntry() to reduce allocations. * Pre-allocated reusable buffer for readEntry() to reduce allocations.
@@ -43,14 +45,6 @@ class IndexSearcher(private val context: Context) {
val dataStartOffset: Long val dataStartOffset: Long
) )
data class IndexComparisonSummary(
val expectedCount: Int,
val actualCount: Int,
val comparedCount: Int
)
class IndexComparisonException(message: String) : IllegalStateException(message)
suspend fun findFirstEntry(pathOrUri: String, query: String): IndexEntry? = withContext(Dispatchers.Default) { suspend fun findFirstEntry(pathOrUri: String, query: String): IndexEntry? = withContext(Dispatchers.Default) {
val normalizedQuery = query.sanitizeQuery().lowercase().trim() val normalizedQuery = query.sanitizeQuery().lowercase().trim()
try { try {
@@ -66,7 +60,7 @@ class IndexSearcher(private val context: Context) {
var iterationCount = 0 var iterationCount = 0
while (currentIdx < count) { while (currentIdx < count) {
if (iterationCount % 1000 == 0) yield() if (iterationCount % 1000 == 0) yield()
val nextEntry = readEntry(source) val nextEntry = readEntry(source, metadata)
val nextWordLower = nextEntry.word.lowercase() val nextWordLower = nextEntry.word.lowercase()
if (!nextWordLower.startsWith(normalizedQuery)) break if (!nextWordLower.startsWith(normalizedQuery)) break
if (nextWordLower == normalizedQuery) return@withContext nextEntry.copy(word = nextWordLower) if (nextWordLower == normalizedQuery) return@withContext nextEntry.copy(word = nextWordLower)
@@ -93,7 +87,8 @@ class IndexSearcher(private val context: Context) {
return@withContext emptyList() return@withContext emptyList()
} }
val normalizedQuery = query.sanitizeQuery().lowercase().trim() val normalizedQuery = query.sanitizeQuery().lowercase().trim()
val cacheKey = "$pathOrUri:${normalizedQuery.sanitizeCacheKey()}" val foldedQuery = normalizedQuery.foldLatinDiacritics()
val cacheKey = "$pathOrUri:$includeSubstringMatches:${normalizedQuery.sanitizeCacheKey()}"
resultsCache.get(cacheKey)?.let { resultsCache.get(cacheKey)?.let {
return@withContext it return@withContext it
} }
@@ -102,33 +97,34 @@ class IndexSearcher(private val context: Context) {
val count = metadata.totalCount val count = metadata.totalCount
if (count == 0) return@withContext emptyList() if (count == 0) return@withContext emptyList()
val results = openSource(pathOrUri).use { source -> val results = openSource(pathOrUri).use { source ->
val prefixMatches = mutableListOf<IndexEntry>() val prefixMatches = readPrefixMatches(source, metadata, normalizedQuery).toMutableList()
val substringMatches = mutableListOf<IndexEntry>() if (foldedQuery != normalizedQuery) {
val firstMatch = findFirstMatch(source, metadata, normalizedQuery) prefixMatches.addAll(readPrefixMatches(source, metadata, foldedQuery))
if (firstMatch != null) {
prefixMatches.addAll(readMatchingEntries(source, count, firstMatch, normalizedQuery))
} }
val substringMatches = mutableListOf<IndexEntry>()
val seenArticles = prefixMatches.mapTo(HashSet()) { it.articleKey() }
if (includeSubstringMatches && if (includeSubstringMatches &&
count <= SUBSTRING_SCAN_MAX_ENTRIES && count <= SUBSTRING_SCAN_MAX_ENTRIES &&
prefixMatches.size < 100 && seenArticles.size < MAX_SUBSTRING_RESULTS &&
normalizedQuery.length >= 2 normalizedQuery.length >= 2
) { ) {
val prefixMatchWords = prefixMatches.map { it.word }.toSet()
source.seek(metadata.dataStartOffset) source.seek(metadata.dataStartOffset)
for (i in 0 until count) { for (i in 0 until count) {
if (i % 1000 == 0) yield() if (i % 1000 == 0) yield()
if (substringMatches.size >= 100) break if (substringMatches.size >= MAX_SUBSTRING_RESULTS) break
val entry = readEntry(source) val entry = readEntry(source, metadata)
val entryWordLower = entry.word.lowercase() val entryWordLower = entry.word.lowercase()
if (entryWordLower.contains(normalizedQuery) && entryWordLower !in prefixMatchWords) { if (entryWordLower.contains(normalizedQuery) &&
seenArticles.add(entry.articleKey())
) {
substringMatches.add(entry.copy(word = entryWordLower)) substringMatches.add(entry.copy(word = entryWordLower))
} }
} }
} }
val combinedResults = prefixMatches + substringMatches (prefixMatches + substringMatches)
combinedResults.rankBySearchRelevance( .rankBySearchRelevance(SearchRankingContext(normalizedQuery, foldedQuery))
SearchRankingContext(normalizedQuery) .distinctBy { it.articleKey() }
)
} }
resultsCache.put(cacheKey, results) resultsCache.put(cacheKey, results)
results results
@@ -148,94 +144,6 @@ class IndexSearcher(private val context: Context) {
metadataCache.getOrPut(pathOrUri) { loadMetadata(pathOrUri) } metadataCache.getOrPut(pathOrUri) { loadMetadata(pathOrUri) }
} }
suspend fun compareEntryGroups(
expectedIndexPath: String,
actualIndexPath: String,
onGroup: suspend (word: String, expected: List<IndexEntry>, actual: List<IndexEntry>) -> Unit
): IndexComparisonSummary = withContext(Dispatchers.IO) {
val expectedMetadata = loadMetadata(expectedIndexPath)
val actualMetadata = loadMetadata(actualIndexPath)
if (expectedMetadata.totalCount != actualMetadata.totalCount) {
throw IndexComparisonException(
"Index entry count differs: expected=${expectedMetadata.totalCount}, " +
"actual=${actualMetadata.totalCount}"
)
}
openSource(expectedIndexPath).use { expectedSource ->
openSource(actualIndexPath).use { actualSource ->
expectedSource.seek(expectedMetadata.dataStartOffset)
actualSource.seek(actualMetadata.dataStartOffset)
var expectedNext = readEntryOrNull(expectedSource, expectedMetadata.totalCount > 0)
var actualNext = readEntryOrNull(actualSource, actualMetadata.totalCount > 0)
var comparedCount = 0
while (expectedNext != null && actualNext != null) {
if (expectedNext.word != actualNext.word) {
throw IndexComparisonException(
"Index words differ at entry $comparedCount: " +
"expected=${expectedNext.word}, actual=${actualNext.word}"
)
}
val word = expectedNext.word
val expectedGroup = mutableListOf<IndexEntry>()
val actualGroup = mutableListOf<IndexEntry>()
while (true) {
val current = expectedNext ?: break
if (current.word != word) break
expectedGroup.add(current)
comparedCount++
expectedNext = readEntryOrNull(
expectedSource,
comparedCount < expectedMetadata.totalCount
)
}
var actualReadCount = comparedCount - expectedGroup.size
while (true) {
val current = actualNext ?: break
if (current.word != word) break
actualGroup.add(current)
actualReadCount++
actualNext = readEntryOrNull(
actualSource,
actualReadCount < actualMetadata.totalCount
)
}
if (expectedGroup.size != actualGroup.size) {
throw IndexComparisonException(
"Headword multiplicity differs for '$word': " +
"expected=${expectedGroup.size}, actual=${actualGroup.size}"
)
}
onGroup(word, expectedGroup, actualGroup)
if (comparedCount % 10_000 == 0) yield()
}
if (expectedNext != null || actualNext != null) {
throw IndexComparisonException(
"One index ended before the other at entry $comparedCount"
)
}
IndexComparisonSummary(
expectedCount = expectedMetadata.totalCount,
actualCount = actualMetadata.totalCount,
comparedCount = comparedCount
)
}
}
}
private fun readEntryOrNull(source: RandomAccessSource, shouldRead: Boolean): IndexEntry? =
if (shouldRead) readEntry(source) else null
fun trimMemory(level: Int) { fun trimMemory(level: Int) {
metadataCache.trim(level) metadataCache.trim(level)
resultsCache.trim(level) resultsCache.trim(level)
@@ -252,7 +160,13 @@ class IndexSearcher(private val context: Context) {
val count = input.readInt() val count = input.readInt()
return@withContext if (version >= 2) { return@withContext if (version >= 2) {
loadMetadataV2(input, version, count) val headerBase = if (version >= 6) {
input.readInt()
12L
} else {
8L
}
loadMetadataV2(input, version, count, headerBase)
} else { } else {
loadMetadataV1(input, version, count) loadMetadataV1(input, version, count)
} }
@@ -272,13 +186,13 @@ class IndexSearcher(private val context: Context) {
// runs inside withContext(Dispatchers.IO). The dispatcher is not visible across the // runs inside withContext(Dispatchers.IO). The dispatcher is not visible across the
// suspend-call boundary, so the inspection is suppressed deliberately. // suspend-call boundary, so the inspection is suppressed deliberately.
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
private suspend fun loadMetadataV2(input: DataInputStream, version: Int, count: Int): IndexMetadata { private suspend fun loadMetadataV2(input: DataInputStream, version: Int, count: Int, headerBase: Long): IndexMetadata {
val sparseCount = input.readInt() val sparseCount = input.readInt()
val sparseIndices = IntArray(sparseCount) val sparseIndices = IntArray(sparseCount)
val sparseOffsets = LongArray(sparseCount) val sparseOffsets = LongArray(sparseCount)
val sparseWords = Array(sparseCount) { "" } val sparseWords = Array(sparseCount) { "" }
var headerSize = 4 + 4 + 4L var headerSize = headerBase + 4L
for (i in 0 until sparseCount) { for (i in 0 until sparseCount) {
if (i % 1000 == 0) yield() if (i % 1000 == 0) yield()
@@ -337,6 +251,17 @@ class IndexSearcher(private val context: Context) {
dataStartOffset = 8L dataStartOffset = 8L
) )
} }
private suspend fun readPrefixMatches(
source: RandomAccessSource,
metadata: IndexMetadata,
query: String
): List<IndexEntry> {
val firstMatch = findFirstMatch(source, metadata, query)
?: return emptyList()
return readMatchingEntries(source, metadata, firstMatch, query)
}
private suspend fun findFirstMatch(source: RandomAccessSource, metadata: IndexMetadata, query: String): Pair<Int, IndexEntry>? { private suspend fun findFirstMatch(source: RandomAccessSource, metadata: IndexMetadata, query: String): Pair<Int, IndexEntry>? {
val sparseIndices = metadata.sparseIndices val sparseIndices = metadata.sparseIndices
val sparseOffsets = metadata.sparseOffsets val sparseOffsets = metadata.sparseOffsets
@@ -359,7 +284,7 @@ class IndexSearcher(private val context: Context) {
while (currentAbsoluteIdx < metadata.totalCount) { while (currentAbsoluteIdx < metadata.totalCount) {
if (iterationCount % 1000 == 0) yield() if (iterationCount % 1000 == 0) yield()
val entry = readEntry(source) val entry = readEntry(source, metadata)
val entryWordLower = entry.word.lowercase() val entryWordLower = entry.word.lowercase()
if (entryWordLower >= query) { if (entryWordLower >= query) {
return if (entryWordLower.startsWith(query)) Pair(currentAbsoluteIdx, entry.copy(word = entryWordLower)) else null return if (entryWordLower.startsWith(query)) Pair(currentAbsoluteIdx, entry.copy(word = entryWordLower)) else null
@@ -394,7 +319,7 @@ class IndexSearcher(private val context: Context) {
} }
private suspend fun readMatchingEntries( private suspend fun readMatchingEntries(
source: RandomAccessSource, source: RandomAccessSource,
totalCount: Int, metadata: IndexMetadata,
firstMatch: Pair<Int, IndexEntry>, firstMatch: Pair<Int, IndexEntry>,
query: String query: String
): List<IndexEntry> { ): List<IndexEntry> {
@@ -402,9 +327,9 @@ class IndexSearcher(private val context: Context) {
results.add(firstMatch.second) results.add(firstMatch.second)
var currentIdx = firstMatch.first + 1 var currentIdx = firstMatch.first + 1
var iterationCount = 0 var iterationCount = 0
while (currentIdx < totalCount && results.size < 100) { while (currentIdx < metadata.totalCount) {
if (iterationCount % 1000 == 0) yield() if (iterationCount % 1000 == 0) yield()
val entry = readEntry(source) val entry = readEntry(source, metadata)
val entryWordLower = entry.word.lowercase() val entryWordLower = entry.word.lowercase()
if (!entryWordLower.startsWith(query)) break if (!entryWordLower.startsWith(query)) break
results.add(entry.copy(word = entryWordLower)) results.add(entry.copy(word = entryWordLower))
@@ -414,37 +339,42 @@ class IndexSearcher(private val context: Context) {
return results return results
} }
private fun readEntry(source: RandomAccessSource): IndexEntry { private fun readPooledString(source: RandomAccessSource, length: Int): String {
val wordLen = source.readUnsignedShort() return if (length <= 4096) {
val word = if (wordLen <= 4096) {
val buffer = entryBuffer.get() ?: throw IllegalStateException("Entry buffer pool exhausted") val buffer = entryBuffer.get() ?: throw IllegalStateException("Entry buffer pool exhausted")
source.readFully(buffer, 0, wordLen) source.readFully(buffer, 0, length)
String(buffer, 0, wordLen, Charsets.UTF_8) String(buffer, 0, length, Charsets.UTF_8)
} else { } else {
val bytes = ByteArray(wordLen) val bytes = ByteArray(length)
source.readFully(bytes) source.readFully(bytes)
String(bytes, Charsets.UTF_8) String(bytes, Charsets.UTF_8)
} }
val origLen = source.readUnsignedShort()
val originalWord = if (origLen <= 4096) {
val buffer = entryBuffer.get() ?: throw IllegalStateException("Entry buffer pool exhausted")
source.readFully(buffer, 0, origLen)
String(buffer, 0, origLen, Charsets.UTF_8)
} else {
val bytes = ByteArray(origLen)
source.readFully(bytes)
String(bytes, Charsets.UTF_8)
} }
private fun readEntry(source: RandomAccessSource, metadata: IndexMetadata): IndexEntry {
val word = readPooledString(source, source.readUnsignedShort())
val originalWord = readPooledString(source, source.readUnsignedShort())
val offset = ArticleOffset(source.readLong()) val offset = ArticleOffset(source.readLong())
val length = ArticleLength(source.readInt()) val length = ArticleLength(source.readInt())
return IndexEntry(word, originalWord, offset, length) val isAlias = metadata.version >= 6 && source.readUnsignedByte() and ENTRY_FLAG_ALIAS != 0
return IndexEntry(word, originalWord, offset, length, isAlias = isAlias)
} }
private data class ArticleKey(
val originalWord: String,
val offset: Long,
val length: Int
)
private fun IndexEntry.articleKey() = ArticleKey(originalWord, offset.value, length.value)
private fun openSource(pathOrUri: String): RandomAccessSource { private fun openSource(pathOrUri: String): RandomAccessSource {
val file = File(pathOrUri) val file = File(pathOrUri)
if (!file.exists()) throw DictionaryError.FileSystemError.NotFound(pathOrUri) return if (file.exists()) {
return FileSource(file) FileSource(file)
} else {
throw DictionaryError.FileSystemError.NotFound(pathOrUri)
}
} }
private fun openInputStream(pathOrUri: String): java.io.InputStream { private fun openInputStream(pathOrUri: String): java.io.InputStream {
val file = File(pathOrUri) val file = File(pathOrUri)
@@ -455,20 +385,21 @@ class IndexSearcher(private val context: Context) {
private const val TAG = "IndexSearcher" private const val TAG = "IndexSearcher"
private const val SUBSTRING_SCAN_MAX_ENTRIES = 200_000 private const val SUBSTRING_SCAN_MAX_ENTRIES = 200_000
private const val SPARSE_INTERVAL_V1 = 128 private const val SPARSE_INTERVAL_V1 = 128
private const val MAX_SUBSTRING_RESULTS = 100
} }
} }
private class ResultsLruCache(private val maxEntries: Int) { private class ResultsLruCache<V : Any>(private val maxEntries: Int) {
private val mutex = Mutex() private val mutex = Mutex()
private val map = object : LinkedHashMap<String, List<IndexEntry>>(maxEntries, 0.75f, true) { private val map = object : LinkedHashMap<String, V>(maxEntries, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, List<IndexEntry>>): Boolean { override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, V>): Boolean {
return size > maxEntries return size > maxEntries
} }
} }
suspend fun get(key: String): List<IndexEntry>? = mutex.withLock { suspend fun get(key: String): V? = mutex.withLock {
map[key] map[key]
} }
suspend fun put(key: String, value: List<IndexEntry>) = mutex.withLock { suspend fun put(key: String, value: V) = mutex.withLock {
map[key] = value map[key] = value
} }
fun trim(level: Int) { fun trim(level: Int) {
@@ -548,6 +479,8 @@ private class MetadataLruCache(private val maxBytes: Long) {
} }
interface RandomAccessSource : Closeable { interface RandomAccessSource : Closeable {
fun seek(pos: Long) fun seek(pos: Long)
fun position(): Long
fun readUnsignedByte(): Int
fun readUnsignedShort(): Int fun readUnsignedShort(): Int
fun readInt(): Int fun readInt(): Int
fun readLong(): Long fun readLong(): Long
@@ -556,26 +489,89 @@ interface RandomAccessSource : Closeable {
} }
class FileSource(file: File) : RandomAccessSource { class FileSource(file: File) : RandomAccessSource {
private val raf = RandomAccessFile(file, "r") private val raf = RandomAccessFile(file, "r")
private val buffer = ByteArray(BUFFER_SIZE)
private var bufferStart = 0L
private var bufferEnd = 0L
private var pos = 0L
override fun seek(pos: Long) { override fun seek(pos: Long) {
raf.seek(pos) this.pos = pos
} }
override fun readUnsignedShort(): Int = raf.readUnsignedShort() override fun position(): Long = pos
override fun readInt(): Int = raf.readInt() private fun refill(minNeeded: Int): Boolean {
if (bufferEnd - pos >= minNeeded) return true
bufferStart = pos
raf.seek(pos)
val read = raf.read(buffer)
bufferEnd = bufferStart + maxOf(read, 0)
return bufferEnd - pos >= minNeeded
}
override fun readLong(): Long = raf.readLong() override fun readUnsignedByte(): Int {
if (!refill(1)) throw EOFException()
return buffer[(pos++ - bufferStart).toInt()].toInt() and 0xFF
}
override fun readUnsignedShort(): Int {
if (!refill(2)) throw EOFException()
val i = (pos - bufferStart).toInt()
pos += 2
return (buffer[i].toInt() and 0xFF shl 8) or (buffer[i + 1].toInt() and 0xFF)
}
override fun readInt(): Int {
if (!refill(4)) throw EOFException()
val i = (pos - bufferStart).toInt()
pos += 4
return (buffer[i].toInt() shl 24) or
(buffer[i + 1].toInt() and 0xFF shl 16) or
(buffer[i + 2].toInt() and 0xFF shl 8) or
(buffer[i + 3].toInt() and 0xFF)
}
override fun readLong(): Long {
if (!refill(8)) throw EOFException()
val i = (pos - bufferStart).toInt()
pos += 8
var value = 0L
for (k in 0 until 8) {
value = (value shl 8) or (buffer[i + k].toLong() and 0xFF)
}
return value
}
override fun readFully(b: ByteArray) { override fun readFully(b: ByteArray) {
raf.readFully(b) readFully(b, 0, b.size)
} }
override fun readFully(b: ByteArray, off: Int, len: Int) { override fun readFully(b: ByteArray, off: Int, len: Int) {
if (len > BUFFER_SIZE / 2) {
raf.seek(pos)
raf.readFully(b, off, len) raf.readFully(b, off, len)
pos += len
bufferEnd = bufferStart
return
}
var remaining = len
var dst = off
while (remaining > 0) {
if (!refill(1)) throw EOFException()
val i = (pos - bufferStart).toInt()
val chunk = minOf(remaining, (bufferEnd - pos).toInt())
System.arraycopy(buffer, i, b, dst, chunk)
pos += chunk
dst += chunk
remaining -= chunk
}
} }
override fun close() { override fun close() {
raf.close() raf.close()
} }
private companion object {
private const val BUFFER_SIZE = 64 * 1024
}
} }
@@ -1,15 +1,18 @@
package com.example.research.data.parser package com.example.research.data.parser
import com.example.research.core.domain.model.IndexEntry import com.example.research.core.domain.model.IndexEntry
import com.example.research.core.util.NaturalStringComparator
import com.example.research.core.util.foldLatinDiacritics
internal data class SearchRankingContext( internal data class SearchRankingContext(
val normalizedQuery: String, val normalizedQuery: String,
val foldedQuery: String = normalizedQuery.foldLatinDiacritics(),
) )
private data class SortKey( private data class SortKey(
val charCount: Int, val matchTier: Int,
val exactMatch: Int, val exactMatch: Int,
val startsWithMatch: Int, val charCount: Int,
val word: String, val word: String,
val entry: IndexEntry, val entry: IndexEntry,
) )
@@ -26,13 +29,23 @@ internal fun List<IndexEntry>.rankBySearchRelevance(
.ifEmpty { entry.word.trim() } .ifEmpty { entry.word.trim() }
SortKey( SortKey(
charCount = displayKey.codePointCount(0, displayKey.length), matchTier = when {
!entry.isAlias && word.startsWith(ranking.normalizedQuery) -> 0
entry.isAlias && word.startsWith(ranking.foldedQuery) -> 1
!entry.isAlias && word.foldLatinDiacritics().startsWith(ranking.foldedQuery) -> 1
else -> 2
},
exactMatch = if (word == ranking.normalizedQuery) 0 else 1, exactMatch = if (word == ranking.normalizedQuery) 0 else 1,
startsWithMatch = if (word.startsWith(ranking.normalizedQuery)) 0 else 1, charCount = displayKey.codePointCount(0, displayKey.length),
word = word, word = word,
entry = entry, entry = entry,
) )
} }
.sortedWith(compareBy({ it.charCount }, { it.exactMatch }, { it.startsWithMatch }, { it.word })) .sortedWith(
compareBy<SortKey> { it.matchTier }
.thenBy { it.exactMatch }
.thenBy { it.charCount }
.thenBy(NaturalStringComparator) { it.word }
)
.map { it.entry } .map { it.entry }
} }
@@ -22,6 +22,7 @@ import java.io.BufferedInputStream
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.InputStream import java.io.InputStream
import java.io.RandomAccessFile
import java.util.concurrent.CancellationException import java.util.concurrent.CancellationException
import java.util.zip.GZIPInputStream import java.util.zip.GZIPInputStream
@@ -51,12 +52,14 @@ class KotlinDictionaryEngine(private val context: Context) : ProgressIndexingEng
val indexFile = File(indexPath) val indexFile = File(indexPath)
val metadata = readMetadata(dictPath) val metadata = readMetadata(dictPath)
val totalInputBytes = uncompressedInputLength(dictFile)
val articleCount = withContext(Dispatchers.IO) { val articleCount = withContext(Dispatchers.IO) {
openDslInputStream(dictPath).use { inputStream -> openDslInputStream(dictPath).use { inputStream ->
dslIndexer.createIndex( dslIndexer.createIndex(
dslInputStream = inputStream, dslInputStream = inputStream,
indexFile = indexFile, indexFile = indexFile,
totalInputBytes = totalInputBytes,
onProgress = { count -> onProgress = { count ->
onArticlesIndexed(count) onArticlesIndexed(count)
}, },
@@ -112,20 +115,20 @@ class KotlinDictionaryEngine(private val context: Context) : ProgressIndexingEng
maxResults: Int maxResults: Int
): List<IndexEntry> = withContext(Dispatchers.IO) { ): List<IndexEntry> = withContext(Dispatchers.IO) {
val allResults = mutableListOf<IndexEntry>() val allResults = mutableListOf<IndexEntry>()
for (dict in dictionaries) { for ((name, path, indexPath, _, _, metadata) in dictionaries) {
try { try {
val indexPath = resolveIndexPath(dict.indexPath) val resolvedIndexPath = resolveIndexPath(indexPath)
val entry = indexSearcher.findFirstEntry(indexPath, query) val entry = indexSearcher.findFirstEntry(resolvedIndexPath, query)
if (entry != null) { if (entry != null) {
allResults.add(entry.withDictionary(dict.metadata.name.ifBlank { dict.name }, dict.path)) allResults.add(entry.withDictionary(metadata.name.ifBlank { name }, path))
} }
if (allResults.size >= maxResults) break if (allResults.size >= maxResults) break
} catch (e: OutOfMemoryError) { } catch (e: OutOfMemoryError) {
Log.e(TAG, "search() - OOM for ${dict.indexPath}", e) Log.e(TAG, "search() - OOM for $indexPath", e)
trimMemory(80) trimMemory(80)
break break
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "search() - error for ${dict.indexPath}: ${e.message}") Log.w(TAG, "search() - error for $indexPath: ${e.message}")
} }
} }
allResults allResults
@@ -207,6 +210,30 @@ class KotlinDictionaryEngine(private val context: Context) : ProgressIndexingEng
} }
} }
private fun uncompressedInputLength(file: File): Long {
val name = file.name.lowercase()
if (!name.endsWith(".dz") && !name.endsWith(".gz")) return file.length()
return readGzipUncompressedLength(file).takeIf { it > 0L } ?: file.length()
}
private fun readGzipUncompressedLength(file: File): Long = try {
RandomAccessFile(file, "r").use { raf ->
val fileLength = raf.length()
if (fileLength < 18L) {
-1L
} else {
raf.seek(fileLength - 4L)
val b0 = raf.readUnsignedByte()
val b1 = raf.readUnsignedByte()
val b2 = raf.readUnsignedByte()
val b3 = raf.readUnsignedByte()
(b0 or (b1 shl 8) or (b2 shl 16) or (b3 shl 24)).toLong() and 0xFFFFFFFFL
}
}
} catch (_: Exception) {
-1L
}
private fun resolveIndexPath(indexArg: String): String { private fun resolveIndexPath(indexArg: String): String {
val candidate = File(indexArg) val candidate = File(indexArg)
if (candidate.exists()) return candidate.absolutePath if (candidate.exists()) return candidate.absolutePath
@@ -14,6 +14,7 @@ import com.example.research.core.util.extractDictionaryPrefix
import com.example.research.core.util.sanitizeQuery import com.example.research.core.util.sanitizeQuery
import com.example.research.core.util.toDictionaryError import com.example.research.core.util.toDictionaryError
import com.example.research.data.dictzip.DictZipRandomAccessFile import com.example.research.data.dictzip.DictZipRandomAccessFile
import com.example.research.data.local.preferences.PreferencesManager
import com.example.research.data.parser.DslCharsetDetector import com.example.research.data.parser.DslCharsetDetector
import com.example.research.data.parser.DslHeaderParser import com.example.research.data.parser.DslHeaderParser
import com.example.research.data.parser.DslIndexer import com.example.research.data.parser.DslIndexer
@@ -26,8 +27,9 @@ import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
@@ -68,13 +70,9 @@ private class ProgressTracker(files: List<LocalDictionaryRepository.DiscoveredFi
aggregateWeightedMicros.addAndGet(newMicros - prevMicros) aggregateWeightedMicros.addAndGet(newMicros - prevMicros)
} }
/** fun progress(): Float {
* Current weighted sum mapped back to [0f, totalFiles] so the existing
* IndexingProgress model can keep deriving progress as aggregate/total.
*/
fun aggregateSum(): Float {
val weightedProgress = aggregateWeightedMicros.get().toDouble() / (totalWeight.toDouble() * 1_000_000.0) val weightedProgress = aggregateWeightedMicros.get().toDouble() / (totalWeight.toDouble() * 1_000_000.0)
return (weightedProgress.coerceIn(0.0, 1.0) * totalFiles).toFloat() return weightedProgress.coerceIn(0.0, 1.0).toFloat()
} }
fun reset() { fun reset() {
@@ -85,28 +83,23 @@ private class ProgressTracker(files: List<LocalDictionaryRepository.DiscoveredFi
class LocalDictionaryRepository( class LocalDictionaryRepository(
private val context: Context, private val context: Context,
private val engine: KotlinDictionaryEngine, private val engine: KotlinDictionaryEngine = KotlinDictionaryEngine(context),
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default, private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
private val preferencesManager: PreferencesManager = PreferencesManager(context),
) : CoroutineScope { ) : CoroutineScope {
constructor(context: Context) : this(
context,
KotlinDictionaryEngine(context)
)
override val coroutineContext = Job() + ioDispatcher override val coroutineContext = Job() + ioDispatcher
private val mutableDictionaries = MutableStateFlow<List<Dictionary>>(emptyList()) private val mutableDictionaries = MutableStateFlow<List<Dictionary>>(emptyList())
val dictionaries: StateFlow<List<Dictionary>> val dictionaries: StateFlow<List<Dictionary>> = mutableDictionaries.asStateFlow()
get() = mutableDictionaries
private val mutableIndexingProgress = MutableStateFlow(IndexingProgress()) private val mutableIndexingProgress = MutableStateFlow(IndexingProgress())
val indexingProgress: StateFlow<IndexingProgress> val indexingProgress: StateFlow<IndexingProgress> = mutableIndexingProgress.asStateFlow()
get() = mutableIndexingProgress
private val indexingMutex = Mutex() private val indexingMutex = Mutex()
private val indexingSemaphore = Semaphore(1) private val indexingSemaphore = Semaphore(1)
private val warmupMutex = Mutex() private val warmupMutex = Mutex()
private val dictionaryStateMutex = Mutex()
@Volatile @Volatile
private var currentIndexingJob: Job? = null private var currentIndexingJob: Job? = null
@@ -124,6 +117,24 @@ class LocalDictionaryRepository(
fun isIndexingInProgress(): Boolean = currentIndexingJob?.isActive == true fun isIndexingInProgress(): Boolean = currentIndexingJob?.isActive == true
suspend fun listDictionaryPayloadFileNames(path: String): List<String>? =
withContext(ioDispatcher) {
try {
val directory = File(path)
when {
!directory.exists() -> {
if (directory.parentFile?.isDirectory == true) emptyList() else null
}
!directory.isDirectory -> null
else -> directory.listFiles { file -> isDictionaryPayloadFile(file) }
?.map(File::getName)
}
} catch (e: SecurityException) {
Log.w(TAG, "Failed to list dictionary files: ${e.message}")
null
}
}
suspend fun scanDirectory(pathOrUri: String): OperationResult<Int> = withContext(ioDispatcher) { suspend fun scanDirectory(pathOrUri: String): OperationResult<Int> = withContext(ioDispatcher) {
if (isIndexingInProgress()) { if (isIndexingInProgress()) {
return@withContext OperationResult.Error( return@withContext OperationResult.Error(
@@ -140,7 +151,7 @@ class LocalDictionaryRepository(
val filesToScan = scanLocalDirectory(pathOrUri) val filesToScan = scanLocalDirectory(pathOrUri)
if (filesToScan.isEmpty()) { if (filesToScan.isEmpty()) {
mutableDictionaries.value = emptyList() replaceDictionaries(emptyList())
mutableIndexingProgress.value = IndexingProgress(isIndexing = false) mutableIndexingProgress.value = IndexingProgress(isIndexing = false)
return@withContext OperationResult.Success(0) return@withContext OperationResult.Success(0)
} }
@@ -159,86 +170,81 @@ class LocalDictionaryRepository(
} }
if (filesToIndex.isEmpty()) { if (filesToIndex.isEmpty()) {
mutableDictionaries.value = existingDictionaries.sortedBy { it.name } replaceDictionaries(existingDictionaries)
engine.getIndexSearcher().trimMemory(80) engine.getIndexSearcher().trimMemory(80)
return@withContext OperationResult.Success(existingDictionaries.size) return@withContext OperationResult.Success(existingDictionaries.size)
} }
mutableDictionaries.value = existingDictionaries.sortedBy { it.name } replaceDictionaries(existingDictionaries)
val totalArticlesIndexed = AtomicInteger(existingDictionaries.sumOf { it.articleCount }) val totalArticlesIndexed = AtomicInteger(existingDictionaries.sumOf { it.articleCount })
progressTracker = ProgressTracker(filesToScan) progressTracker = ProgressTracker(filesToScan)
existingDictionaries.forEach { dict -> existingDictionaries.forEach { dict ->
progressTracker?.updateFileProgress(dict.name, 1.0f) progressTracker?.updateFileProgress(File(dict.path).name, 1.0f)
} }
mutableIndexingProgress.emit( mutableIndexingProgress.emit(
IndexingProgress( IndexingProgress(
isIndexing = true, isIndexing = true,
currentFile = context.getString(R.string.indexing_label), progress = progressTracker?.progress() ?: 0f,
totalFiles = filesToScan.size,
currentIndex = filesToScan.size - filesToIndex.size,
aggregateSum = progressTracker?.aggregateSum() ?: -1f,
) )
) )
val lastEmittedPercent = AtomicInteger(-1) val lastEmittedPercent = AtomicInteger(-1)
val lastProgressEmitMs = AtomicLong(0L) val lastProgressEmitMs = AtomicLong(0L)
val publishCurrentProgress = {
val tracker = progressTracker
if (tracker != null) {
val progress = tracker.progress()
lastEmittedPercent.set((progress * 100f).toInt().coerceIn(0, 100))
lastProgressEmitMs.set(SystemClock.elapsedRealtime())
mutableIndexingProgress.update { p ->
p.copy(progress = progress)
}
}
}
supervisorScope { supervisorScope {
filesToIndex.mapIndexed { index, file -> filesToIndex.map { file ->
async { async {
indexingSemaphore.withPermit { indexingSemaphore.withPermit {
coroutineContext.ensureActive() coroutineContext.ensureActive()
yield() yield()
val res = indexDictionary(file) { _, op, prog -> val res = indexDictionary(file) { _, _, prog ->
if (prog >= 0f) { if (prog >= 0f) {
progressTracker?.updateFileProgress(file.name, prog) progressTracker?.updateFileProgress(file.name, prog)
} }
val tracker = progressTracker val progress = progressTracker?.progress() ?: 0f
val aggregate = tracker?.aggregateSum() ?: -1f val newPercent = (progress * 100f).toInt().coerceIn(0, 100)
val newPercent = if (filesToScan.isNotEmpty() && aggregate >= 0f) {
((aggregate / filesToScan.size) * 100f).toInt().coerceIn(0, 100)
} else -1
val currentIdx = filesToScan.size - filesToIndex.size + index + 1
val previous = mutableIndexingProgress.value
val now = SystemClock.elapsedRealtime() val now = SystemClock.elapsedRealtime()
val percentChanged = newPercent >= 0 && newPercent != lastEmittedPercent.get() val percentChanged = newPercent != lastEmittedPercent.get()
val fileChanged = previous.currentFile != file.name
val completed = newPercent >= 100 || prog >= 1f val completed = newPercent >= 100 || prog >= 1f
val intervalElapsed = val intervalElapsed =
now - lastProgressEmitMs.get() >= MIN_PROGRESS_EMIT_INTERVAL_MS now - lastProgressEmitMs.get() >= MIN_PROGRESS_EMIT_INTERVAL_MS
val shouldEmit = fileChanged || completed || (percentChanged && intervalElapsed) val shouldEmit = completed || (percentChanged && intervalElapsed)
if (shouldEmit) { if (shouldEmit) {
if (newPercent >= 0) lastEmittedPercent.set(newPercent) lastEmittedPercent.set(newPercent)
lastProgressEmitMs.set(now) lastProgressEmitMs.set(now)
mutableIndexingProgress.update { p -> mutableIndexingProgress.update { p ->
p.copy( p.copy(progress = progress)
currentFile = file.name,
currentIndex = currentIdx,
currentFileProgress = prog,
label = op.ifEmpty { p.label },
aggregateSum = aggregate,
)
} }
} }
} }
if (res is OperationResult.Success) { if (res is OperationResult.Success) {
progressTracker?.updateFileProgress(file.name, 1.0f) progressTracker?.updateFileProgress(file.name, 1.0f)
publishCurrentProgress()
totalArticlesIndexed.addAndGet(res.data.articleCount) totalArticlesIndexed.addAndGet(res.data.articleCount)
mutableDictionaries.update { current -> val dictionary = res.data
(current + res.data) addDictionary(dictionary)
.distinctBy { it.path } dictionary
.sortedBy { it.name }
}
res.data
} else null } else null
} }
} }
@@ -266,17 +272,37 @@ class LocalDictionaryRepository(
} }
} }
private suspend fun replaceDictionaries(dictionaries: List<Dictionary>) {
dictionaryStateMutex.withLock { applyDictionaries(dictionaries) }
}
private suspend fun addDictionary(dictionary: Dictionary) {
dictionaryStateMutex.withLock {
applyDictionaries((mutableDictionaries.value + dictionary).distinctBy { it.path })
}
}
private suspend fun applyDictionaries(dictionaries: List<Dictionary>) {
val disabledPaths = preferencesManager.disabledDictionaryPaths.first()
mutableDictionaries.value = dictionaries
.map { it.withActiveStatus(it.path !in disabledPaths) }
.sortedBy { it.name }
}
private suspend fun tryLoadExistingIndex(file: DiscoveredFile): Dictionary? { private suspend fun tryLoadExistingIndex(file: DiscoveredFile): Dictionary? {
val dictFile = File(file.localPath) val dictFile = File(file.localPath)
val indexFile = File(file.indexPath) val indexFile = File(file.indexPath)
if (!indexFile.exists() || indexFile.length() == 0L) return null if (!indexFile.exists() || indexFile.length() == 0L) {
deleteIndexFiles(indexFile)
return null
}
if (!dictFile.exists()) return null if (!dictFile.exists()) return null
val header = readIndexHeader(indexFile) val header = readIndexHeader(indexFile)
if (header == null || header.version != DslIndexer.INDEX_VERSION) { if (header == null || header.version != DslIndexer.INDEX_VERSION) {
try { indexFile.delete() } catch (_: Exception) {} deleteIndexFiles(indexFile)
return null return null
} }
@@ -326,23 +352,36 @@ class LocalDictionaryRepository(
) )
OperationResult.Success(dict) OperationResult.Success(dict)
} else { } else {
indexFile.delete() deleteIndexFiles(indexFile)
OperationResult.Error(result.errorMsg, null) OperationResult.Error(result.errorMsg, null)
} }
} catch(e: Exception) { } catch(e: Exception) {
indexFile.delete() deleteIndexFiles(indexFile)
if (e is CancellationException) throw e if (e is CancellationException) throw e
OperationResult.Error(e.message ?: context.getString(R.string.error_indexing), e) OperationResult.Error(e.message ?: context.getString(R.string.error_indexing), e)
} }
} }
private fun deleteIndexFiles(indexFile: File, errors: MutableList<String>? = null) {
deleteFileTracked(indexFile, "index file", errors)
deleteFileTracked(DslIndexer.foldedIndexFile(indexFile), "legacy folded index file", errors)
}
private fun deleteFileTracked(file: File, label: String, errors: MutableList<String>?) {
try {
if (file.exists() && !file.delete()) {
errors?.add("Failed to delete $label: ${file.absolutePath}")
}
} catch (e: Exception) {
errors?.add("Failed to delete $label: ${file.absolutePath} (${e.message})")
}
}
private fun scanLocalDirectory(path: String): List<DiscoveredFile> { private fun scanLocalDirectory(path: String): List<DiscoveredFile> {
val dir = File(path) val dir = File(path)
if (!dir.exists() || !dir.isDirectory) return emptyList() if (!dir.exists() || !dir.isDirectory) return emptyList()
return dir.listFiles { f -> return dir.listFiles { file -> isDictionaryPayloadFile(file) }?.map { file ->
f.isFile && (f.name.endsWith(".dsl") || f.name.endsWith(".dsl.dz") || f.name.endsWith(".dsl.gz"))
}?.map { file ->
DiscoveredFile( DiscoveredFile(
name = file.name, name = file.name,
localPath = file.absolutePath, localPath = file.absolutePath,
@@ -351,10 +390,20 @@ class LocalDictionaryRepository(
} ?: emptyList() } ?: emptyList()
} }
private fun isDictionaryPayloadFile(file: File): Boolean {
if (!file.isFile) return false
val n = file.name.lowercase()
if (n.endsWith(".dsl.dz") || n.endsWith(".dsl.gz")) return true
if (n.endsWith(".dsl")) {
return File(file.parent, file.name + ".dz").exists()
}
return false
}
suspend fun search(query: String): OperationResult<List<IndexEntry>> = withContext(defaultDispatcher) { suspend fun search(query: String): OperationResult<List<IndexEntry>> = withContext(defaultDispatcher) {
if (query.isBlank()) return@withContext OperationResult.Success(emptyList()) if (query.isBlank()) return@withContext OperationResult.Success(emptyList())
val activeDicts = mutableDictionaries.value val activeDicts = mutableDictionaries.value.filter { it.isActive }
val sanitizedQuery = query.sanitizeQuery() val sanitizedQuery = query.sanitizeQuery()
ReSearchTrace.asyncSection(ReSearchTrace.SEARCH_DIRECT) { ReSearchTrace.asyncSection(ReSearchTrace.SEARCH_DIRECT) {
@@ -404,45 +453,63 @@ class LocalDictionaryRepository(
return engine.getIndexSearcher() return engine.getIndexSearcher()
} }
fun toggleDictionaryActive(dictionaryPath: String) { suspend fun toggleDictionaryActive(dictionaryPath: String) {
dictionaryStateMutex.withLock {
val isActive = mutableDictionaries.value
.firstOrNull { it.path == dictionaryPath }
?.isActive
?.not()
?: return
preferencesManager.setDictionaryActive(dictionaryPath, isActive)
mutableDictionaries.update { currentList -> mutableDictionaries.update { currentList ->
currentList.map { dict -> currentList.map { dict ->
if (dict.path == dictionaryPath) { if (dict.path == dictionaryPath) {
dict.withActiveStatus(!dict.isActive) dict.withActiveStatus(isActive)
} else { } else {
dict dict
} }
} }
} }
} }
fun deleteDictionary(dictionary: Dictionary): OperationResult<Unit> {
try {
mutableDictionaries.update { current ->
current.filter { it.path != dictionary.path }
} }
launch(ioDispatcher) { private fun removeDictionaryFromState(path: String) {
try { mutableDictionaries.update { current -> current.filter { it.path != path } }
val deleteErrors = mutableListOf<String>() }
suspend fun deleteDictionary(dictionary: Dictionary): OperationResult<Unit> {
return try {
val (failure, cleanupErrors) = withContext(ioDispatcher) {
val dictFile = File(dictionary.path) val dictFile = File(dictionary.path)
if (dictFile.exists()) { if (dictFile.exists() && !dictFile.delete()) {
if (!dictFile.delete()) { return@withContext Pair(
deleteErrors.add("Failed to delete dictionary file: ${dictFile.absolutePath}") "Failed to delete dictionary file: ${dictFile.absolutePath}",
} emptyList(),
)
} }
val deleteErrors = mutableListOf<String>()
val indexFile = if (dictionary.indexPath.endsWith(".idx", ignoreCase = true)) { val indexFile = if (dictionary.indexPath.endsWith(".idx", ignoreCase = true)) {
File(dictionary.indexPath) File(dictionary.indexPath)
} else { } else {
File("${dictionary.indexPath}.idx") File("${dictionary.indexPath}.idx")
} }
if (indexFile.exists()) { deleteIndexFiles(indexFile, deleteErrors)
if (!indexFile.delete()) { Pair<String?, List<String>>(null, deleteErrors)
deleteErrors.add("Failed to delete index file: ${indexFile.absolutePath}")
} }
if (failure != null) {
return OperationResult.Error(failure)
}
dictionaryStateMutex.withLock {
try {
preferencesManager.removeDictionaryActiveState(dictionary.path)
} catch (e: Exception) {
Log.w(TAG, "Failed to clear dictionary active state: ${e.message}")
}
removeDictionaryFromState(dictionary.path)
} }
try { try {
@@ -451,18 +518,16 @@ class LocalDictionaryRepository(
Log.w(TAG, "Failed to trim memory after deletion: ${e.message}") Log.w(TAG, "Failed to trim memory after deletion: ${e.message}")
} }
if (deleteErrors.isNotEmpty()) { if (cleanupErrors.isNotEmpty()) {
val message = deleteErrors.joinToString("; ") Log.w(
Log.w(TAG, "Dictionary deletion completed with errors: $message") TAG,
} "Dictionary deletion completed with errors: ${cleanupErrors.joinToString("; ")}"
} catch (e: Exception) { )
Log.e(TAG, "Background deletion failed: ${e.message}", e)
}
} }
return OperationResult.Success(Unit) OperationResult.Success(Unit)
} catch (e: Exception) { } catch (e: Exception) {
return OperationResult.Error("Failed to delete dictionary", e) OperationResult.Error("Failed to delete dictionary", e)
} }
} }
@@ -473,12 +538,12 @@ class LocalDictionaryRepository(
val dictionaries = mutableDictionaries.value val dictionaries = mutableDictionaries.value
if (dictionaries.isEmpty()) return@withContext if (dictionaries.isEmpty()) return@withContext
for (dict in dictionaries) { for ((_, _, indexPath) in dictionaries) {
try { try {
val indexPath = resolveIndexPath(dict.indexPath) val resolvedIndexPath = resolveIndexPath(indexPath)
val indexSearcher = engine.getIndexSearcher() val indexSearcher = engine.getIndexSearcher()
if (!indexSearcher.isMetadataLoaded(indexPath)) { if (!indexSearcher.isMetadataLoaded(resolvedIndexPath)) {
engine.preloadIndexMetadata(indexPath) engine.preloadIndexMetadata(resolvedIndexPath)
} }
yield() yield()
} catch (_: Exception) { /* Ignore preload failure */ } } catch (_: Exception) { /* Ignore preload failure */ }
@@ -507,13 +572,22 @@ class LocalDictionaryRepository(
if (count < 0) return null if (count < 0) return null
var articleCount = count
if (version >= 6) {
if (indexFile.length() < INDEX_V6_HEADER_BYTES) return null
articleCount = dataInput.readInt()
if (articleCount < 0) return null
}
if (version >= 2) { if (version >= 2) {
if (indexFile.length() < INDEX_V2_HEADER_BYTES) return null val fixedHeaderBytes =
if (version >= 6) INDEX_V6_HEADER_BYTES else INDEX_V2_HEADER_BYTES
if (indexFile.length() < fixedHeaderBytes) return null
val sparseCount = dataInput.readInt() val sparseCount = dataInput.readInt()
if (sparseCount < 0) return null if (sparseCount < 0) return null
var headerBytes = INDEX_V2_HEADER_BYTES var headerBytes = fixedHeaderBytes
repeat(sparseCount) { repeat(sparseCount) {
dataInput.readInt() dataInput.readInt()
dataInput.readLong() dataInput.readLong()
@@ -524,7 +598,7 @@ class LocalDictionaryRepository(
} }
} }
IndexHeader(version, count) IndexHeader(version, articleCount)
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -602,6 +676,7 @@ class LocalDictionaryRepository(
const val MIN_PROGRESS_EMIT_INTERVAL_MS = 200L const val MIN_PROGRESS_EMIT_INTERVAL_MS = 200L
const val LEGACY_INDEX_HEADER_BYTES = 8L const val LEGACY_INDEX_HEADER_BYTES = 8L
const val INDEX_V2_HEADER_BYTES = 12 const val INDEX_V2_HEADER_BYTES = 12
const val INDEX_V6_HEADER_BYTES = 16
const val SPARSE_ENTRY_FIXED_BYTES = 14 const val SPARSE_ENTRY_FIXED_BYTES = 14
} }
} }
@@ -12,6 +12,7 @@ import kotlin.time.Duration.Companion.seconds
class DownloadManager( class DownloadManager(
private val dictionaryRepository: DictionaryRepository, private val dictionaryRepository: DictionaryRepository,
private val unknownErrorMessage: String, private val unknownErrorMessage: String,
private val sourceUnavailableMessage: String,
dispatcher: CoroutineDispatcher = Dispatchers.IO, dispatcher: CoroutineDispatcher = Dispatchers.IO,
externalScope: CoroutineScope? = null externalScope: CoroutineScope? = null
) { ) {
@@ -24,6 +25,7 @@ class DownloadManager(
mutableDownloadProgressState.asStateFlow() mutableDownloadProgressState.asStateFlow()
private val mutex = Mutex() private val mutex = Mutex()
private var downloadJob: Job? = null private var downloadJob: Job? = null
private var cancelCleanupJob: Job? = null
companion object { companion object {
private const val TAG = "DownloadManager" private const val TAG = "DownloadManager"
@@ -39,30 +41,50 @@ class DownloadManager(
val clampedProgress = progress.coerceIn(0f, 1f) val clampedProgress = progress.coerceIn(0f, 1f)
mutableDownloadProgressState.value = DownloadProgressState(state, clampedProgress) mutableDownloadProgressState.value = DownloadProgressState(state, clampedProgress)
mutableDownloadState.value = state mutableDownloadState.value = state
if (state is DownloadState.Success) {
onDownloadSuccess?.invoke()
} else if (state is DownloadState.Error || state is DownloadState.Cancelled) {
onTerminal?.invoke(state)
} }
}
var onFlowStarted: (() -> Unit)? = null
var onDownloadSuccess: (() -> Unit)? = null
var onTerminal: ((DownloadState) -> Unit)? = null
fun startDownload() { fun startDownload() {
if (downloadState.value is DownloadState.Loading) return if (downloadState.value is DownloadState.Loading) return
if (!downloadScope.isActive) return if (!downloadScope.isActive) return
onFlowStarted?.invoke()
cancelCleanupJob = null
updateDownloadProgressState(DownloadState.Loading, 0f)
downloadJob = downloadScope.launch { downloadJob = downloadScope.launch {
mutex.withLock { mutex.withLock {
try { try {
updateDownloadProgressState(DownloadState.Loading, 0f) val enabledSources = try {
dictionaryRepository.getEnabledSources()
val hasEnabledSources = try {
dictionaryRepository.hasEnabledSources()
} catch (_: Exception) { } catch (_: Exception) {
false emptyList()
} }
if (!hasEnabledSources) { if (enabledSources.isEmpty()) {
dictionaryRepository.performAllCleanup() dictionaryRepository.performAllCleanup()
updateDownloadProgressState(DownloadState.Success, 1f) updateDownloadProgressState(DownloadState.Idle, 0f)
return@withLock return@withLock
} }
val downloadableSources =
dictionaryRepository.filterServerAvailableSources(enabledSources)
if (downloadableSources.isEmpty()) {
updateDownloadProgressState(
DownloadState.Error(sourceUnavailableMessage), 0f
)
return@withLock
}
var lastReportedProgressBucket = -1 var lastReportedProgressBucket = -1
val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) { val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) {
dictionaryRepository.downloadDictionaries { progress -> dictionaryRepository.downloadSources(downloadableSources) { progress ->
val progressBucket = progressBucket(progress, DOWNLOAD_PROGRESS_STEP_PERCENT) val progressBucket = progressBucket(progress, DOWNLOAD_PROGRESS_STEP_PERCENT)
if (progressBucket != lastReportedProgressBucket) { if (progressBucket != lastReportedProgressBucket) {
lastReportedProgressBucket = progressBucket lastReportedProgressBucket = progressBucket
@@ -94,7 +116,7 @@ class DownloadManager(
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) { if (e is CancellationException) {
updateDownloadProgressState(DownloadState.Cancelled, 0f) updateDownloadProgressState(DownloadState.Cancelled, 0f)
downloadScope.launch { cancelCleanupJob = downloadScope.launch {
try { try {
dictionaryRepository.deleteUnprocessedFiles() dictionaryRepository.deleteUnprocessedFiles()
} catch (cleanupException: Exception) { } catch (cleanupException: Exception) {
@@ -124,19 +146,40 @@ class DownloadManager(
fun startDownloadForSources(sourceUrls: List<String>) { fun startDownloadForSources(sourceUrls: List<String>) {
if (downloadState.value is DownloadState.Loading) return if (downloadState.value is DownloadState.Loading) return
if (!downloadScope.isActive) return if (!downloadScope.isActive) return
onFlowStarted?.invoke()
cancelCleanupJob = null
updateDownloadProgressState(DownloadState.Loading, 0f)
downloadJob = downloadScope.launch { downloadJob = downloadScope.launch {
mutex.withLock { mutex.withLock {
try { try {
updateDownloadProgressState(DownloadState.Loading, 0f)
if (sourceUrls.isEmpty()) { if (sourceUrls.isEmpty()) {
updateDownloadProgressState(DownloadState.Success, 1f) updateDownloadProgressState(DownloadState.Idle, 0f)
return@withLock
}
val sources = try {
dictionaryRepository.getEnabledSources(sourceUrls)
} catch (_: Exception) {
emptyList()
}
if (sources.isEmpty()) {
updateDownloadProgressState(DownloadState.Idle, 0f)
return@withLock
}
val downloadableSources =
dictionaryRepository.filterServerAvailableSources(sources)
if (downloadableSources.isEmpty()) {
updateDownloadProgressState(
DownloadState.Error(sourceUnavailableMessage), 0f
)
return@withLock return@withLock
} }
var lastReportedProgressBucket = -1 var lastReportedProgressBucket = -1
val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) { val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) {
dictionaryRepository.downloadSpecificSources(sourceUrls) { progress -> dictionaryRepository.downloadSources(downloadableSources) { progress ->
val progressBucket = progressBucket(progress, DOWNLOAD_PROGRESS_STEP_PERCENT) val progressBucket = progressBucket(progress, DOWNLOAD_PROGRESS_STEP_PERCENT)
if (progressBucket != lastReportedProgressBucket) { if (progressBucket != lastReportedProgressBucket) {
lastReportedProgressBucket = progressBucket lastReportedProgressBucket = progressBucket
@@ -168,7 +211,7 @@ class DownloadManager(
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) { if (e is CancellationException) {
updateDownloadProgressState(DownloadState.Cancelled, 0f) updateDownloadProgressState(DownloadState.Cancelled, 0f)
downloadScope.launch { cancelCleanupJob = downloadScope.launch {
try { try {
dictionaryRepository.deleteUnprocessedFiles() dictionaryRepository.deleteUnprocessedFiles()
} catch (cleanupException: Exception) { } catch (cleanupException: Exception) {
@@ -195,10 +238,12 @@ class DownloadManager(
} }
} }
fun cancelDownload() { fun cancelDownload() {
downloadJob?.cancel() val jobToCancel = downloadJob
jobToCancel?.cancel()
updateDownloadProgressState(DownloadState.Cancelled, 0f) updateDownloadProgressState(DownloadState.Cancelled, 0f)
downloadScope.launch { downloadScope.launch {
delay(TERMINAL_STATE_DURATION) jobToCancel?.join()
cancelCleanupJob?.join()
if (downloadState.value == DownloadState.Cancelled) { if (downloadState.value == DownloadState.Cancelled) {
updateDownloadProgressState(DownloadState.Idle, 0f) updateDownloadProgressState(DownloadState.Idle, 0f)
} }
@@ -1,4 +1,8 @@
package com.example.research.feature.download.model package com.example.research.feature.download.model
import androidx.compose.runtime.Immutable
@Immutable
sealed class DownloadState { sealed class DownloadState {
data object Idle : DownloadState() data object Idle : DownloadState()
data object Loading : DownloadState() data object Loading : DownloadState()
@@ -7,3 +11,6 @@ sealed class DownloadState {
data class Error(val message: String) : DownloadState() data class Error(val message: String) : DownloadState()
data object Cancelled : DownloadState() data object Cancelled : DownloadState()
} }
val DownloadState.isActive: Boolean
get() = this is DownloadState.Loading || this is DownloadState.Extracting || this is DownloadState.Success
@@ -7,11 +7,18 @@ import com.example.research.common.util.FileStorageManager
import com.example.research.common.util.DocumentFileInfo import com.example.research.common.util.DocumentFileInfo
import com.example.research.core.domain.model.DictionarySource import com.example.research.core.domain.model.DictionarySource
import com.example.research.feature.download.config.DictionaryConfig import com.example.research.feature.download.config.DictionaryConfig
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
enum class SourceAvailability {
AVAILABLE,
NOT_FOUND,
UNKNOWN
}
class DictionaryChecker( class DictionaryChecker(
private val fileStorageManager: FileStorageManager, private val fileStorageManager: FileStorageManager,
private val client: OkHttpClient private val client: OkHttpClient
@@ -43,49 +50,59 @@ class DictionaryChecker(
} }
} }
suspend fun areDictionariesUpToDate( suspend fun sourcesWithUpdates(
folderUri: Uri, folderUri: Uri,
sources: List<DictionarySource>, sources: List<DictionarySource>,
files: List<DocumentFileInfo>? = null files: List<DocumentFileInfo>? = null
): Boolean = withContext(Dispatchers.IO) { ): Set<String> = withContext(Dispatchers.IO) {
try { try {
val enabledSources = sources.filter { it.isEnabled }
if (enabledSources.isEmpty()) {
return@withContext true
}
val currentDate = DateUtils.getCurrentDateString() val currentDate = DateUtils.getCurrentDateString()
val previousDate = DateUtils.getPreviousDateString() val previousDate = DateUtils.getPreviousDateString()
val dictFiles = getDictFiles(folderUri, files) val dictFiles = getDictFiles(folderUri, files)
sources
val allHaveToday = enabledSources.all { source -> .filter { it.isEnabled && DictionarySource.hasDatePlaceholder(it.urlTemplate) }
dictFiles.hasFileForSource(source, currentDate) .filter { source ->
} when {
dictFiles.hasFileForSource(source, currentDate) -> false
if (allHaveToday) { dictFiles.hasFileForSource(source, previousDate) ->
return@withContext true
}
val allHaveYesterday = enabledSources.all { source ->
dictFiles.hasFileForSource(source, previousDate)
}
if (allHaveYesterday) {
val serverHasToday = enabledSources.any { source ->
isSourceAvailableOnServer(source, currentDate) isSourceAvailableOnServer(source, currentDate)
else -> when (probeSource(source, currentDate)) {
SourceAvailability.AVAILABLE -> true
SourceAvailability.NOT_FOUND ->
probeSource(source, previousDate) == SourceAvailability.AVAILABLE
SourceAvailability.UNKNOWN -> false
} }
return@withContext !serverHasToday
} }
}
false .mapTo(mutableSetOf(), DictionarySource::id)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error checking currency: ${e.message}", e) Log.e(TAG, "Error checking currency: ${e.message}", e)
false emptySet()
} }
} }
private fun isSourceAvailableOnServer(source: DictionarySource, date: String): Boolean { suspend fun checkSourceAvailability(source: DictionarySource): SourceAvailability =
withContext(Dispatchers.IO) {
val currentDate = DateUtils.getCurrentDateString()
when (probeSource(source, currentDate)) {
SourceAvailability.AVAILABLE -> SourceAvailability.AVAILABLE
SourceAvailability.UNKNOWN -> SourceAvailability.UNKNOWN
SourceAvailability.NOT_FOUND -> {
if (DictionarySource.hasDatePlaceholder(source.urlTemplate)) {
probeSource(source, DateUtils.getPreviousDateString())
} else {
SourceAvailability.NOT_FOUND
}
}
}
}
private fun isSourceAvailableOnServer(source: DictionarySource, date: String): Boolean =
probeSource(source, date) == SourceAvailability.AVAILABLE
private fun probeSource(source: DictionarySource, date: String): SourceAvailability {
return try { return try {
val url = DictionarySource.buildUrl(source.urlTemplate, date) val url = DictionarySource.buildUrl(source.urlTemplate, date)
val request = Request.Builder() val request = Request.Builder()
@@ -94,12 +111,17 @@ class DictionaryChecker(
.build() .build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
response.isSuccessful && if (response.isSuccessful &&
response.header("Content-Type")?.startsWith("text/html") != true response.header("Content-Type")?.startsWith("text/html") != true
) {
SourceAvailability.AVAILABLE
} else {
SourceAvailability.NOT_FOUND
}
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Failed to check source availability for ${source.urlTemplate}: ${e.message}") Log.w(TAG, "Failed to check source availability for ${source.urlTemplate}: ${e.message}")
false SourceAvailability.UNKNOWN
} }
} }
@@ -54,17 +54,17 @@ class DictionaryCleaner(
for ((prefix, items) in parsedByPrefix) { for ((prefix, items) in parsedByPrefix) {
val latestDsl = latestDslDateByPrefix[prefix] ?: continue val latestDsl = latestDslDateByPrefix[prefix] ?: continue
val dslDates = dslDatesByPrefix[prefix].orEmpty() val dslDates = dslDatesByPrefix[prefix].orEmpty()
for (item in items) { for ((name, _, date, ext) in items) {
if (DateUtils.isDateBefore(item.date, latestDsl)) { if (DateUtils.isDateBefore(date, latestDsl)) {
toDelete.add(item.name) toDelete.add(name)
continue continue
} }
if ((item.ext == "idx" || item.ext == "dsl.idx") && item.date != latestDsl) { if (ext in indexExtensions && date != latestDsl) {
toDelete.add(item.name) toDelete.add(name)
continue continue
} }
if ((item.ext == "idx" || item.ext == "dsl.idx") && item.date !in dslDates) { if (ext in indexExtensions && date !in dslDates) {
toDelete.add(item.name) toDelete.add(name)
} }
} }
} }
@@ -76,10 +76,13 @@ class DictionaryCleaner(
val date: String, val date: String,
val ext: String, val ext: String,
) )
private val validExtensions = setOf("dsl", "dsl.dz", "dsl.gz", "gz", "idx", "dsl.idx") private val indexExtensions = setOf("idx", "dsl.idx", "idx.fold", "dsl.idx.fold")
private val validExtensions = setOf("dsl", "dsl.dz", "dsl.gz", "gz") + indexExtensions
private fun parseManagedName(name: String, prefixes: Set<String>): ManagedFileName? { private fun parseManagedName(name: String, prefixes: Set<String>): ManagedFileName? {
val ext = when { val ext = when {
name.endsWith(".dsl.idx.fold") -> "dsl.idx.fold"
name.endsWith(".idx.fold") -> "idx.fold"
name.endsWith(".dsl.dz") -> "dsl.dz" name.endsWith(".dsl.dz") -> "dsl.dz"
name.endsWith(".dsl.gz") -> "dsl.gz" name.endsWith(".dsl.gz") -> "dsl.gz"
name.endsWith(".dsl.idx") -> "dsl.idx" name.endsWith(".dsl.idx") -> "dsl.idx"
@@ -15,8 +15,11 @@ import com.example.research.feature.download.config.DictionaryConfig
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.isActive
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
@@ -58,6 +61,7 @@ class DictionaryDownloader(
) { ) {
if (fileUri == null) return if (fileUri == null) return
withContext(NonCancellable) {
runCatching { runCatching {
val fileToDelete = fileStorageManager.getDocumentFile(fileUri) val fileToDelete = fileStorageManager.getDocumentFile(fileUri)
if (fileToDelete != null && fileToDelete.exists()) { if (fileToDelete != null && fileToDelete.exists()) {
@@ -75,6 +79,7 @@ class DictionaryDownloader(
} }
} }
} }
}
suspend fun downloadDictionaries( suspend fun downloadDictionaries(
folderUri: Uri, folderUri: Uri,
sources: List<DictionarySource>, sources: List<DictionarySource>,
@@ -169,7 +174,7 @@ class DictionaryDownloader(
} }
private suspend fun deletePartialFileIfExists(folderUri: Uri, fileName: String) { private suspend fun deletePartialFileIfExists(folderUri: Uri, fileName: String) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO + NonCancellable) {
runCatching { runCatching {
val existingFile = fileStorageManager.findFile(folderUri, fileName) val existingFile = fileStorageManager.findFile(folderUri, fileName)
if (existingFile != null && existingFile.exists()) { if (existingFile != null && existingFile.exists()) {
@@ -313,7 +318,7 @@ class DictionaryDownloader(
call?.cancel() call?.cancel()
response?.close() response?.close()
outputStream?.close() outputStream?.close()
if (e is CancellationException) { if (e is CancellationException || !currentCoroutineContext().isActive) {
deletePartialFileIfExists(folderUri, fileName) deletePartialFileIfExists(folderUri, fileName)
deletePartialFileSafely(fileUri, folderUri, fileName) deletePartialFileSafely(fileUri, folderUri, fileName)
throw e throw e
@@ -343,6 +348,8 @@ class DictionaryDownloader(
context.getString(R.string.download_connection_reset) context.getString(R.string.download_connection_reset)
e is SecurityException -> e is SecurityException ->
context.getString(R.string.download_no_write_permission) context.getString(R.string.download_no_write_permission)
e.isNetworkError() ->
context.getString(R.string.download_network_failed)
else -> else ->
context.getString(R.string.download_error) context.getString(R.string.download_error)
} }
@@ -9,7 +9,9 @@ import com.example.research.common.util.FileStorageManager
import com.example.research.data.dictzip.DictZipRandomAccessFile import com.example.research.data.dictzip.DictZipRandomAccessFile
import com.example.research.data.dictzip.DictZipWriter import com.example.research.data.dictzip.DictZipWriter
import com.example.research.feature.download.config.DictionaryConfig import com.example.research.feature.download.config.DictionaryConfig
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class DictionaryExtractor( class DictionaryExtractor(
@@ -44,14 +46,13 @@ class DictionaryExtractor(
reportProgress(processedIndex.toFloat() / totalFiles) reportProgress(processedIndex.toFloat() / totalFiles)
} }
for (file in gzFiles) { for ((fileName, uri) in gzFiles) {
val fileName = file.name
val baseName = fileName.dropLast(DictionaryConfig.GZ_EXTENSION.length) val baseName = fileName.dropLast(DictionaryConfig.GZ_EXTENSION.length)
val lowerBaseName = baseName.lowercase() val lowerBaseName = baseName.lowercase()
if (baseName.isBlank() || if (baseName.isBlank() ||
lowerBaseName.endsWith(DictionaryConfig.GZ_EXTENSION) || lowerBaseName.endsWith(DictionaryConfig.GZ_EXTENSION) ||
lowerBaseName.endsWith(".txt")) { lowerBaseName.endsWith(".txt")) {
ArchiveUtils.deleteFile(context, file.uri) ArchiveUtils.deleteFile(context, uri)
markFileProcessed() markFileProcessed()
continue continue
} }
@@ -63,19 +64,19 @@ class DictionaryExtractor(
val dslFileName = "$dslBaseName${DictionaryConfig.DSL_EXTENSION}" val dslFileName = "$dslBaseName${DictionaryConfig.DSL_EXTENSION}"
val existingDslFile = fileStorageManager.findFile(folderUri, dslFileName) val existingDslFile = fileStorageManager.findFile(folderUri, dslFileName)
if (existingDslFile != null && existingDslFile.exists()) { if (existingDslFile != null && existingDslFile.exists()) {
ArchiveUtils.deleteFile(context, file.uri) ArchiveUtils.deleteFile(context, uri)
markFileProcessed() markFileProcessed()
continue continue
} }
val dslUri = fileStorageManager.createFileInFolder(folderUri, dslFileName, overwrite = false) val dslUri = fileStorageManager.createFileInFolder(folderUri, dslFileName, overwrite = false)
?: continue ?: continue
val extractResult = ArchiveUtils.extractGzWithBom(context, file.uri, dslUri) val extractResult = ArchiveUtils.extractGzWithBom(context, uri, dslUri)
if (extractResult.isSuccess) { if (extractResult.isSuccess) {
val extractedFile = fileStorageManager.getDocumentFile(dslUri) val extractedFile = fileStorageManager.getDocumentFile(dslUri)
if (extractedFile != null && extractedFile.exists()) { if (extractedFile != null && extractedFile.exists()) {
if (extractedFile.length() == 0L) { if (extractedFile.length() == 0L) {
ArchiveUtils.deleteFile(context, dslUri) ArchiveUtils.deleteFile(context, dslUri)
ArchiveUtils.deleteFile(context, file.uri) ArchiveUtils.deleteFile(context, uri)
markFileProcessed() markFileProcessed()
continue continue
} }
@@ -90,7 +91,7 @@ class DictionaryExtractor(
ArchiveUtils.deleteFile(context, dslUri) ArchiveUtils.deleteFile(context, dslUri)
} }
} }
ArchiveUtils.deleteFile(context, file.uri) ArchiveUtils.deleteFile(context, uri)
} else { } else {
Log.e("DictionaryExtractor", "File extraction error: $fileName") Log.e("DictionaryExtractor", "File extraction error: $fileName")
ArchiveUtils.deleteFile(context, dslUri) ArchiveUtils.deleteFile(context, dslUri)
@@ -98,12 +99,11 @@ class DictionaryExtractor(
markFileProcessed() markFileProcessed()
} }
for (file in rawDslFiles) { for ((dslFileName, uri) in rawDslFiles) {
val dslFileName = file.name
val dzFileName = "$dslFileName.dz" val dzFileName = "$dslFileName.dz"
val existingDz = fileStorageManager.findFile(folderUri, dzFileName) val existingDz = fileStorageManager.findFile(folderUri, dzFileName)
if (existingDz != null && existingDz.exists()) { if (existingDz != null && existingDz.exists()) {
ArchiveUtils.deleteFile(context, file.uri) ArchiveUtils.deleteFile(context, uri)
markFileProcessed() markFileProcessed()
continue continue
} }
@@ -111,11 +111,11 @@ class DictionaryExtractor(
val fileBaseProgress = processedIndex.toFloat() / totalFiles val fileBaseProgress = processedIndex.toFloat() / totalFiles
val fileProgressWeight = 1f / totalFiles val fileProgressWeight = 1f / totalFiles
reportProgress(fileBaseProgress) reportProgress(fileBaseProgress)
val compressResult = ensureDslDictZip(file.uri, dslFileName, folderUri) { compressProgress -> val compressResult = ensureDslDictZip(uri, dslFileName, folderUri) { compressProgress ->
reportProgress(fileBaseProgress + (compressProgress * fileProgressWeight)) reportProgress(fileBaseProgress + (compressProgress * fileProgressWeight))
} }
if (compressResult.isSuccess) { if (compressResult.isSuccess) {
ArchiveUtils.deleteFile(context, file.uri) ArchiveUtils.deleteFile(context, uri)
} }
markFileProcessed() markFileProcessed()
} }
@@ -128,21 +128,48 @@ class DictionaryExtractor(
!lowerName.endsWith(DictionaryConfig.DICTZIP_EXTENSION) && !lowerName.endsWith(DictionaryConfig.DICTZIP_EXTENSION) &&
!lowerName.endsWith(".dsl.gz") !lowerName.endsWith(".dsl.gz")
} }
for (file in remainingRawDslFiles) { for ((dslFileName, uri) in remainingRawDslFiles) {
val dslFileName = file.name val compressResult = ensureDslDictZip(uri, dslFileName, folderUri, onProgress = null)
val compressResult = ensureDslDictZip(file.uri, dslFileName, folderUri, onProgress = null)
if (compressResult.isSuccess) { if (compressResult.isSuccess) {
ArchiveUtils.deleteFile(context, file.uri) ArchiveUtils.deleteFile(context, uri)
} }
} }
onProgress?.invoke(1f) onProgress?.invoke(1f)
Result.success(Unit) Result.success(Unit)
} catch (e: CancellationException) {
deleteOrphanedFiles(folderUri)
throw e
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) Result.failure(e)
} }
} }
private suspend fun deleteOrphanedFiles(folderUri: Uri) {
try {
withContext(Dispatchers.IO + NonCancellable) {
val files = fileStorageManager.listFilesInFolder(folderUri)
for ((name, uri) in files) {
val n = name.lowercase()
val isRawDsl = n.endsWith(DictionaryConfig.DSL_EXTENSION) &&
!n.endsWith(DictionaryConfig.DICTZIP_EXTENSION) &&
!n.endsWith(".dsl.gz")
val isBareDz = n.endsWith(".dz") && !n.endsWith(DictionaryConfig.DICTZIP_EXTENSION)
when {
isBareDz -> ArchiveUtils.deleteFile(context, uri)
isRawDsl -> {
val pair = fileStorageManager.findFile(folderUri, "$name.dz")
if (pair == null || !pair.exists()) {
ArchiveUtils.deleteFile(context, uri)
}
}
}
}
}
} catch (_: Exception) {
}
}
private suspend fun ensureDslDictZip( private suspend fun ensureDslDictZip(
dslUri: Uri, dslUri: Uri,
dslFileName: String, dslFileName: String,
@@ -8,6 +8,8 @@ import com.example.research.core.domain.model.DictionarySource
import com.example.research.data.local.preferences.PreferencesManager import com.example.research.data.local.preferences.PreferencesManager
import com.example.research.common.util.FileStorageManager import com.example.research.common.util.FileStorageManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -23,91 +25,69 @@ class DictionaryRepository(
private val extractor = DictionaryExtractor(context, fileStorageManager) private val extractor = DictionaryExtractor(context, fileStorageManager)
private val cleaner = DictionaryCleaner(context, fileStorageManager) private val cleaner = DictionaryCleaner(context, fileStorageManager)
private var filesBeforeDownload = mutableSetOf<String>() private class DownloadSession(val filesBefore: Set<String>, val prefixes: Set<String>)
private var downloadedPrefixes = mutableSetOf<String>()
@Volatile
private var session: DownloadSession? = null
companion object { companion object {
private const val TAG = "DictionaryRepository" private const val TAG = "DictionaryRepository"
} }
suspend fun areDictionariesUpToDate( suspend fun sourcesWithUpdates(
sources: List<DictionarySource>? = null sources: List<DictionarySource>
): Boolean = withContext(Dispatchers.IO) { ): Set<String> = withContext(Dispatchers.IO) {
val folderUri = preferencesManager.dictionaryPath val uri = Uri.fromFile(File(preferencesManager.dictionaryPath))
val uri = Uri.fromFile(File(folderUri)) checker.sourcesWithUpdates(uri, sources, fileStorageManager.listFilesInFolder(uri))
val files = fileStorageManager.listFilesInFolder(uri)
val sourcesToCheck = sources ?: preferencesManager.dictionarySources.first()
checker.areDictionariesUpToDate(uri, sourcesToCheck, files)
} }
suspend fun hasEnabledSources(): Boolean = withContext(Dispatchers.IO) { suspend fun getEnabledSources(sourceUrls: List<String>? = null): List<DictionarySource> =
preferencesManager.dictionarySources.first().any { it.isEnabled } withContext(Dispatchers.IO) {
val enabledSources = preferencesManager.dictionarySources.first()
.filter { it.isEnabled }
if (sourceUrls == null) {
return@withContext enabledSources
} }
suspend fun downloadDictionaries(
onProgress: (Float) -> Unit
): Result<Unit> = withContext(Dispatchers.IO) {
val folderUri = preferencesManager.dictionaryPath
val uri = Uri.fromFile(File(folderUri))
val files = fileStorageManager.listFilesInFolder(uri)
cleaner.deleteCorruptedArchives(uri, files)
synchronized(filesBeforeDownload) {
filesBeforeDownload = files.map { it.name }.toMutableSet()
}
val sources = preferencesManager.dictionarySources.first()
val enabledSources = sources.filter { it.isEnabled }
synchronized(downloadedPrefixes) {
downloadedPrefixes = enabledSources.mapNotNull {
DictionarySource.extractPrefix(it.urlTemplate)
}.toMutableSet()
}
downloader.downloadDictionaries(
folderUri = uri,
sources = enabledSources,
onProgress = onProgress
)
}
suspend fun downloadSpecificSources(
sourceUrls: List<String>,
onProgress: (Float) -> Unit
): Result<Unit> = withContext(Dispatchers.IO) {
val folderUri = preferencesManager.dictionaryPath
val uri = Uri.fromFile(File(folderUri))
val files = fileStorageManager.listFilesInFolder(uri)
cleaner.deleteCorruptedArchives(uri, files)
synchronized(filesBeforeDownload) {
filesBeforeDownload = files.map { it.name }.toMutableSet()
}
val allSources = preferencesManager.dictionarySources.first()
val sourceKeys = sourceUrls.mapTo(mutableSetOf(), DictionarySource::identityKey) val sourceKeys = sourceUrls.mapTo(mutableSetOf(), DictionarySource::identityKey)
val sourcesToDownload = allSources.mapNotNull { source -> enabledSources.mapNotNull { source ->
if (!source.isEnabled || DictionarySource.identityKey(source.urlTemplate) !in sourceKeys) { if (DictionarySource.identityKey(source.urlTemplate) !in sourceKeys) {
null null
} else { } else {
source.copy(urlTemplate = DictionarySource.normalizeTemplate(source.urlTemplate)) source.copy(urlTemplate = DictionarySource.normalizeTemplate(source.urlTemplate))
} }
} }
if (sourcesToDownload.isEmpty()) {
return@withContext Result.success(Unit)
} }
synchronized(downloadedPrefixes) { suspend fun filterServerAvailableSources(
downloadedPrefixes = sourcesToDownload.mapNotNull { sources: List<DictionarySource>
DictionarySource.extractPrefix(it.urlTemplate) ): List<DictionarySource> = withContext(Dispatchers.IO) {
}.toMutableSet() if (sources.isEmpty()) {
return@withContext emptyList()
} }
sources
.map { source -> async { source to checker.checkSourceAvailability(source) } }
.awaitAll()
.filter { (_, availability) -> availability != SourceAvailability.NOT_FOUND }
.map { (source, _) -> source }
}
suspend fun downloadSources(
sources: List<DictionarySource>,
onProgress: (Float) -> Unit
): Result<Unit> = withContext(Dispatchers.IO) {
val folderUri = preferencesManager.dictionaryPath
val uri = Uri.fromFile(File(folderUri))
val files = fileStorageManager.listFilesInFolder(uri)
cleaner.deleteCorruptedArchives(uri, files)
session = DownloadSession(
filesBefore = files.mapTo(mutableSetOf()) { it.name },
prefixes = sources.mapNotNullTo(mutableSetOf()) { DictionarySource.extractPrefix(it.urlTemplate) },
)
downloader.downloadDictionaries( downloader.downloadDictionaries(
folderUri = uri, folderUri = uri,
sources = sourcesToDownload, sources = sources,
onProgress = onProgress onProgress = onProgress
) )
} }
@@ -128,64 +108,22 @@ class DictionaryRepository(
cleaner.deleteUnprocessedFiles(uri, files) cleaner.deleteUnprocessedFiles(uri, files)
val filesAfterStandardCleanup = fileStorageManager.listFilesInFolder(uri) val current = session ?: return@withContext Result.success(Unit)
session = null
val existingFiles = synchronized(filesBeforeDownload) { fileStorageManager.listFilesInFolder(uri)
filesBeforeDownload.toSet() .filter { file ->
file.name !in current.filesBefore &&
current.prefixes.any { prefix -> file.name.startsWith("${prefix}_") }
} }
.forEach { (fileName, fileUri) ->
val prefixes = synchronized(downloadedPrefixes) { val docFile = fileStorageManager.getDocumentFile(fileUri)
downloadedPrefixes.toSet() if (docFile != null && docFile.exists() && !docFile.delete() &&
} !fileStorageManager.forceDeleteFile(docFile, fileName)
) {
val finalPrefixes = prefixes.ifEmpty {
val sources = preferencesManager.dictionarySources.first()
sources.mapNotNull { DictionarySource.extractPrefix(it.urlTemplate) }.toSet()
}
val filesToDelete = if (existingFiles.isEmpty()) {
filesAfterStandardCleanup.filter { file ->
val fileName = file.name
finalPrefixes.any { prefix -> fileName.startsWith("${prefix}_") }
}
} else {
filesAfterStandardCleanup.filter { file ->
file.name !in existingFiles &&
finalPrefixes.any { prefix -> file.name.startsWith("${prefix}_") }
}
}
if (filesToDelete.isNotEmpty()) {
var deletedCount = 0
for (file in filesToDelete) {
val fileName = file.name
val docFile = fileStorageManager.getDocumentFile(file.uri)
if (docFile != null && docFile.exists()) {
val deleted = docFile.delete()
if (deleted) {
deletedCount++
} else {
val forceDeleted = fileStorageManager.forceDeleteFile(docFile, fileName)
if (!forceDeleted) {
Log.e(TAG, "deleteUnprocessedFiles() - force delete failed for: $fileName") Log.e(TAG, "deleteUnprocessedFiles() - force delete failed for: $fileName")
} else {
deletedCount++
} }
} }
}
}
}
synchronized(filesBeforeDownload) {
if (filesBeforeDownload.isNotEmpty()) {
filesBeforeDownload.clear()
}
}
synchronized(downloadedPrefixes) {
downloadedPrefixes.clear()
}
Result.success(Unit) Result.success(Unit)
} }
@@ -198,15 +136,7 @@ class DictionaryRepository(
val prefixes = sources.mapNotNull { DictionarySource.extractPrefix(it.urlTemplate) }.toSet() val prefixes = sources.mapNotNull { DictionarySource.extractPrefix(it.urlTemplate) }.toSet()
val result = cleaner.performAllCleanup(uri, prefixes, files) val result = cleaner.performAllCleanup(uri, prefixes, files)
session = null
synchronized(filesBeforeDownload) {
filesBeforeDownload.clear()
}
synchronized(downloadedPrefixes) {
downloadedPrefixes.clear()
}
result result
} }
} }
@@ -3,19 +3,19 @@ package com.example.research.feature.download.service
import android.app.Service import android.app.Service
import android.content.Intent import android.content.Intent
import android.os.IBinder import android.os.IBinder
import android.util.Log
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import com.example.research.ReSearchApplication import com.example.research.ReSearchApplication
import com.example.research.common.progress.renderTitle import com.example.research.common.progress.DictionaryPipelineCoordinator
import com.example.research.common.progress.DictionaryProgressPresenter
import com.example.research.common.util.NotificationHelper import com.example.research.common.util.NotificationHelper
import com.example.research.core.util.OperationResult
import com.example.research.data.repository.LocalDictionaryRepository import com.example.research.data.repository.LocalDictionaryRepository
import com.example.research.feature.download.DownloadManager import com.example.research.feature.download.DownloadManager
import com.example.research.feature.download.model.DownloadState import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.* import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.* import kotlinx.coroutines.SupervisorJob
import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.cancel
import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.Job
class DictionaryForegroundService : Service() { class DictionaryForegroundService : Service() {
@@ -23,21 +23,21 @@ class DictionaryForegroundService : Service() {
const val ACTION_START = "com.example.research.START_DOWNLOAD" const val ACTION_START = "com.example.research.START_DOWNLOAD"
const val ACTION_STOP = "com.example.research.STOP_DOWNLOAD" const val ACTION_STOP = "com.example.research.STOP_DOWNLOAD"
const val ACTION_IMPORT = "com.example.research.START_IMPORT" const val ACTION_IMPORT = "com.example.research.START_IMPORT"
const val ACTION_FINISH = "com.example.research.FINISH_PIPELINE"
private const val NOTIFICATION_ID = NotificationHelper.NOTIFICATION_ID private const val NOTIFICATION_ID = NotificationHelper.NOTIFICATION_ID
} }
private lateinit var downloadManager: DownloadManager private lateinit var downloadManager: DownloadManager
private lateinit var localDictionaryRepository: LocalDictionaryRepository private lateinit var localDictionaryRepository: LocalDictionaryRepository
private lateinit var dictionaryImportManager: com.example.research.feature.import.DictionaryImportManager private lateinit var dictionaryImportManager: com.example.research.feature.import.DictionaryImportManager
private lateinit var progressStateHolder: com.example.research.common.progress.DictionaryProgressStateHolder private lateinit var pipelineCoordinator: DictionaryPipelineCoordinator
private lateinit var presenter: DictionaryProgressPresenter
private val notificationHelper by lazy { private val notificationHelper by lazy {
NotificationHelper(this) NotificationHelper(this)
} }
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
@Volatile @Volatile
private var isHandlingSuccess = false
@Volatile
private var latestStartId = 0 private var latestStartId = 0
override fun onCreate() { override fun onCreate() {
@@ -47,164 +47,8 @@ class DictionaryForegroundService : Service() {
downloadManager = app.downloadManager downloadManager = app.downloadManager
localDictionaryRepository = app.localDictionaryRepository localDictionaryRepository = app.localDictionaryRepository
dictionaryImportManager = app.dictionaryImportManager dictionaryImportManager = app.dictionaryImportManager
progressStateHolder = app.dictionaryProgressStateHolder pipelineCoordinator = app.dictionaryPipelineCoordinator
observeDownloadState() presenter = app.dictionaryProgressPresenter
observeImportState()
observeUnifiedProgress()
}
private fun observeDownloadState() {
serviceScope.launch {
downloadManager.downloadState.collect { state ->
when (state) {
is DownloadState.Success -> handleSuccess()
is DownloadState.Error -> {
notificationHelper.showErrorNotification()
stopForegroundService(removeNotification = false)
}
is DownloadState.Cancelled -> {
notificationHelper.cancelNotification()
stopForegroundService()
}
else -> {}
}
}
}
}
private fun observeImportState() {
serviceScope.launch {
dictionaryImportManager.importState.collect { state ->
when (state) {
is com.example.research.ui.settings.ImportState.Success -> handleSuccess()
is com.example.research.ui.settings.ImportState.Error -> {
notificationHelper.showErrorNotification()
stopForegroundService(removeNotification = false)
}
else -> {}
}
}
}
}
private fun observeUnifiedProgress() {
serviceScope.launch {
progressStateHolder.progressSnapshot
.collect { snapshot ->
if (snapshot != null) {
notificationHelper.showUnifiedProgressNotification(
title = snapshot.renderTitle(this@DictionaryForegroundService),
contentText = "${snapshot.percent}%",
progressPercent = snapshot.percent,
)
}
}
}
}
private suspend fun triggerReindexing(isImportFlow: Boolean) = withContext(Dispatchers.IO) {
try {
waitForIndexingCompletion()
delay(1.seconds)
val app = application as? ReSearchApplication
?: return@withContext
val path = app.preferencesManager.dictionaryPath
if (isImportFlow) {
dictionaryImportManager.updateExtractionProgress(0f)
}
app.downloadDictionaryRepository.extractArchives(onProgress = { progress ->
if (isImportFlow) {
dictionaryImportManager.updateExtractionProgress(progress)
}
})
val result = localDictionaryRepository.scanDirectory(path)
if (result is OperationResult.Success && result.data > 0) {
localDictionaryRepository.warmupIndexes()
app.downloadDictionaryRepository.performAllCleanup()
}
if (isImportFlow) {
dictionaryImportManager.markImportPipelineSuccess()
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e("DictionaryForegroundService", "triggerReindexing() failed: ${e.message}", e)
}
}
private suspend fun waitForIndexingCompletion(): Boolean {
val isIndexing = localDictionaryRepository.indexingProgress.first().isIndexing
if (!isIndexing) {
return true
}
var waitCount = 0
while (localDictionaryRepository.indexingProgress.first().isIndexing && waitCount < 100) {
delay(200.milliseconds)
waitCount++
}
return waitCount < 100
}
private fun handleSuccess() {
if (isHandlingSuccess) return
isHandlingSuccess = true
serviceScope.launch {
try {
val isImportFlow = dictionaryImportManager.importState.value !is com.example.research.ui.settings.ImportState.Idle
if (isImportFlow) {
val app = application as? ReSearchApplication
val path = app?.preferencesManager?.dictionaryPath
val dictionariesDir = path?.let { java.io.File(it) }
val filesBeforeReindex = dictionariesDir?.listFiles()?.map { it.name }?.toSet() ?: emptySet()
try {
triggerReindexing(true)
dictionaryImportManager.getAndClearImportedFiles()
} catch (e: CancellationException) {
val filesToCleanup = dictionaryImportManager.getAndClearImportedFiles()
filesToCleanup.forEach { file ->
try { if (file.exists()) file.delete() } catch (_: Exception) { /* Ignore cleanup failure */ }
}
if (dictionariesDir != null) {
cleanupNewFiles(dictionariesDir, filesBeforeReindex)
}
throw e
}
} else {
triggerReindexing(false)
}
if (isImportFlow) {
notificationHelper.showImportSuccessNotification()
dictionaryImportManager.clearImportState()
} else {
notificationHelper.showSuccessNotification()
}
} catch (e: Exception) {
if (e !is CancellationException) {
Log.e("DownloadForegroundService", "handleSuccess() failed: ${e.message}", e)
notificationHelper.showErrorNotification()
}
} finally {
isHandlingSuccess = false
stopForegroundService(removeNotification = false)
}
}
}
private fun cleanupNewFiles(dir: java.io.File, filesBeforeSnapshot: Set<String>) {
try {
dir.listFiles()?.forEach { file ->
if (file.name !in filesBeforeSnapshot) {
try { file.delete() } catch (_: Exception) { /* Ignore delete failure */ }
}
}
} catch (_: Exception) { /* Ignore directory listing failure */ }
} }
private fun startForegroundService() { private fun startForegroundService() {
@@ -244,20 +88,31 @@ class DictionaryForegroundService : Service() {
stopForTimeout(startId) stopForTimeout(startId)
} }
override fun onTaskRemoved(rootIntent: Intent?) {
cancelAllWork()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onTimeout(startId: Int, fgsType: Int) { override fun onTimeout(startId: Int, fgsType: Int) {
stopForTimeout(startId) stopForTimeout(startId)
} }
private fun stopForTimeout(startId: Int) { private fun stopForTimeout(startId: Int) {
cancelAllWork()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelfResult(startId)
}
private fun cancelAllWork() {
serviceScope.coroutineContext[Job]?.cancelChildren() serviceScope.coroutineContext[Job]?.cancelChildren()
pipelineCoordinator.cancelPipeline()
if (localDictionaryRepository.isIndexingInProgress()) { if (localDictionaryRepository.isIndexingInProgress()) {
localDictionaryRepository.cancelIndexing() localDictionaryRepository.cancelIndexing()
} }
downloadManager.cancelDownload() downloadManager.cancelDownload()
dictionaryImportManager.cancelImport() dictionaryImportManager.cancelImport()
notificationHelper.cancelNotification() presenter.cancelProgress()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelfResult(startId)
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -275,21 +130,16 @@ class DictionaryForegroundService : Service() {
ACTION_IMPORT -> { ACTION_IMPORT -> {
startForegroundService() startForegroundService()
} }
ACTION_FINISH -> {
stopForegroundService(removeNotification = true)
}
ACTION_STOP -> { ACTION_STOP -> {
try { try {
startForegroundService() startForegroundService()
} catch (_: Exception) { } catch (_: Exception) {
// Service may already be stopped // Service may already be stopped
} }
serviceScope.coroutineContext[Job]?.cancelChildren() cancelAllWork()
if (localDictionaryRepository.isIndexingInProgress()) {
localDictionaryRepository.cancelIndexing()
}
downloadManager.cancelDownload()
dictionaryImportManager.cancelImport()
notificationHelper.cancelNotification()
stopForegroundService() stopForegroundService()
} }
} }
@@ -3,6 +3,7 @@ package com.example.research.feature.import
import android.app.Application import android.app.Application
import android.net.Uri import android.net.Uri
import com.example.research.R import com.example.research.R
import com.example.research.common.progress.ImportFlowOperations
import com.example.research.common.util.SafeFileName import com.example.research.common.util.SafeFileName
import com.example.research.core.performance.ReSearchTrace import com.example.research.core.performance.ReSearchTrace
import com.example.research.ui.settings.ImportState import com.example.research.ui.settings.ImportState
@@ -14,16 +15,19 @@ import java.util.Collections
class DictionaryImportManager( class DictionaryImportManager(
private val application: Application private val application: Application
) { ) : ImportFlowOperations {
private val mutableImportState = MutableStateFlow<ImportState>(ImportState.Idle) private val mutableImportState = MutableStateFlow<ImportState>(ImportState.Idle)
val importState: StateFlow<ImportState> = mutableImportState.asStateFlow() val importState: StateFlow<ImportState> = mutableImportState.asStateFlow()
private val managerScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val managerScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var importJob: Job? = null private var importJob: Job? = null
private val importedFiles = Collections.synchronizedList(mutableListOf<File>()) private val importedFiles = Collections.synchronizedList(mutableListOf<File>())
var onFlowStarted: (() -> Unit)? = null
var onTerminal: ((ImportState) -> Unit)? = null
fun importDictionaries(uris: List<Uri>) { fun importDictionaries(uris: List<Uri>) {
importJob?.cancel() importJob?.cancel()
onFlowStarted?.invoke()
importJob = managerScope.launch { importJob = managerScope.launch {
mutableImportState.value = ImportState.Idle mutableImportState.value = ImportState.Idle
performImport(uris) performImport(uris)
@@ -36,6 +40,8 @@ class DictionaryImportManager(
val dictionariesDir = File(context.getExternalFilesDir(null), "dictionaries") val dictionariesDir = File(context.getExternalFilesDir(null), "dictionaries")
val totalFiles = uris.size.coerceAtLeast(1) val totalFiles = uris.size.coerceAtLeast(1)
importedFiles.clear() importedFiles.clear()
val skippedNames = mutableListOf<String>()
val deferredErrors = mutableListOf<String>()
if (!dictionariesDir.exists()) { if (!dictionariesDir.exists()) {
dictionariesDir.mkdirs() dictionariesDir.mkdirs()
@@ -46,9 +52,7 @@ class DictionaryImportManager(
currentCoroutineContext().ensureActive() currentCoroutineContext().ensureActive()
val fileName = getFileName(uri) ?: continue val fileName = getFileName(uri) ?: continue
if (SafeFileName.validate(fileName) == null) { if (SafeFileName.validate(fileName) == null) {
mutableImportState.value = ImportState.Error( deferredErrors += context.getString(R.string.import_invalid_file_name, fileName)
context.getString(R.string.import_invalid_file_name, fileName)
)
continue continue
} }
val lowerFileName = fileName.lowercase() val lowerFileName = fileName.lowercase()
@@ -65,9 +69,7 @@ class DictionaryImportManager(
val destFile = File(dictionariesDir, fileName) val destFile = File(dictionariesDir, fileName)
if (destFile.exists()) { if (destFile.exists()) {
mutableImportState.value = ImportState.Error( skippedNames += fileName
context.getString(R.string.import_file_exists, fileName)
)
continue continue
} }
@@ -115,16 +117,30 @@ class DictionaryImportManager(
currentCoroutineContext().ensureActive() currentCoroutineContext().ensureActive()
mutableImportState.value = ImportState.Success val terminalState = resolveImportOutcome(
importedCount = importedFiles.size,
skippedNames = skippedNames,
deferredErrors = deferredErrors,
).toImportState(
existsMessage = { names ->
context.getString(R.string.import_file_exists, names.joinToString(separator = ", "))
},
invalidMessage = { message -> message },
nothingImportedMessage = { context.getString(R.string.import_nothing_imported) },
)
mutableImportState.value = terminalState
onTerminal?.invoke(terminalState)
} catch (e: CancellationException) { } catch (e: CancellationException) {
cleanupImportedFiles() cleanupImportedFiles()
mutableImportState.value = ImportState.Idle mutableImportState.value = ImportState.Idle
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
mutableImportState.value = ImportState.Error( val errorState = ImportState.Error(
context.getString(R.string.import_error, e.message ?: "Unknown error") context.getString(R.string.import_error, e.message ?: "Unknown error")
) )
mutableImportState.value = errorState
onTerminal?.invoke(errorState)
} }
} }
@@ -154,18 +170,18 @@ class DictionaryImportManager(
return size return size
} }
fun clearImportState() { override fun clearImportState() {
mutableImportState.value = ImportState.Idle mutableImportState.value = ImportState.Idle
} }
fun updateExtractionProgress(progress: Float) { override fun updateExtractionProgress(progress: Float) {
mutableImportState.update { current -> mutableImportState.update { current ->
if (current is ImportState.Idle || current is ImportState.Error) current if (current is ImportState.Idle || current is ImportState.Error) current
else ImportState.Extracting(progress.coerceIn(0f, 1f)) else ImportState.Extracting(progress.coerceIn(0f, 1f))
} }
} }
fun markImportPipelineSuccess() { override fun markImportPipelineSuccess() {
mutableImportState.update { current -> mutableImportState.update { current ->
if (current is ImportState.Idle || current is ImportState.Error) current if (current is ImportState.Idle || current is ImportState.Error) current
else ImportState.Success else ImportState.Success
@@ -179,7 +195,7 @@ class DictionaryImportManager(
mutableImportState.value = ImportState.Idle mutableImportState.value = ImportState.Idle
} }
fun getAndClearImportedFiles(): List<File> { override fun getAndClearImportedFiles(): List<File> {
val files = importedFiles.toList() val files = importedFiles.toList()
importedFiles.clear() importedFiles.clear()
return files return files
@@ -0,0 +1,32 @@
package com.example.research.feature.import
import com.example.research.ui.settings.ImportState
internal sealed interface ImportOutcome {
data object Imported : ImportOutcome
data class AlreadyExists(val names: List<String>) : ImportOutcome
data class InvalidFile(val message: String) : ImportOutcome
data object NothingImported : ImportOutcome
}
internal fun resolveImportOutcome(
importedCount: Int,
skippedNames: List<String>,
deferredErrors: List<String>,
): ImportOutcome = when {
importedCount > 0 -> ImportOutcome.Imported
skippedNames.isNotEmpty() -> ImportOutcome.AlreadyExists(skippedNames)
deferredErrors.isNotEmpty() -> ImportOutcome.InvalidFile(deferredErrors.first())
else -> ImportOutcome.NothingImported
}
internal fun ImportOutcome.toImportState(
existsMessage: (List<String>) -> String,
invalidMessage: (String) -> String,
nothingImportedMessage: () -> String,
): ImportState = when (this) {
ImportOutcome.Imported -> ImportState.Success
is ImportOutcome.AlreadyExists -> ImportState.Error(existsMessage(names))
is ImportOutcome.InvalidFile -> ImportState.Error(invalidMessage(message))
ImportOutcome.NothingImported -> ImportState.Error(nothingImportedMessage())
}
@@ -10,11 +10,13 @@ import com.example.research.core.util.onError
import com.example.research.core.util.onSuccess import com.example.research.core.util.onSuccess
import com.example.research.core.util.sanitizeQuery import com.example.research.core.util.sanitizeQuery
import com.example.research.data.paging.IndexEntryPagingSource import com.example.research.data.paging.IndexEntryPagingSource
import com.example.research.data.local.preferences.PreferencesManager
import com.example.research.data.repository.LocalDictionaryRepository import com.example.research.data.repository.LocalDictionaryRepository
import com.example.research.ui.article.ArticleState import com.example.research.ui.article.ArticleState
import com.example.research.ui.article.ArticleTextWarmUp import com.example.research.ui.article.ArticleTextWarmUp
import com.example.research.ui.article.DslBlock
import com.example.research.ui.article.DslAnnotatedParser import com.example.research.ui.article.DslAnnotatedParser
import com.example.research.ui.article.HEAVY_ARTICLE_BLOCK_COUNT
import com.example.research.ui.article.mergeDslBlocks
import com.example.research.R import com.example.research.R
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
@@ -28,12 +30,12 @@ sealed interface SearchAction {
data class ArticleClicked(val entry: IndexEntry) : SearchAction data class ArticleClicked(val entry: IndexEntry) : SearchAction
data object ClearArticle : SearchAction data object ClearArticle : SearchAction
data class SearchAndLoadArticle(val word: String) : SearchAction data class SearchAndLoadArticle(val word: String) : SearchAction
data class HandleExternalSearch(val text: String) : SearchAction
} }
class SearchViewModel( class SearchViewModel(
application: Application, application: Application,
private val repository: LocalDictionaryRepository, private val repository: LocalDictionaryRepository,
private val preferencesManager: PreferencesManager,
private val savedStateHandle: SavedStateHandle private val savedStateHandle: SavedStateHandle
) : AndroidViewModel(application) { ) : AndroidViewModel(application) {
@@ -51,20 +53,29 @@ class SearchViewModel(
private val mutableErrorMessage = MutableStateFlow<String?>(null) private val mutableErrorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = mutableErrorMessage.asStateFlow() val errorMessage: StateFlow<String?> = mutableErrorMessage.asStateFlow()
val searchHistory: StateFlow<List<String>> = preferencesManager.searchHistory
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
private val indexSearcher by lazy { repository.getIndexSearcher() } private val indexSearcher by lazy { repository.getIndexSearcher() }
private var indexWarmupJob: Job? = null private var indexWarmupJob: Job? = null
private var debounceSaveQueryJob: Job? = null private var debounceSaveQueryJob: Job? = null
private val parseCache = java.util.LinkedHashMap<String, List<DslBlock>>(PARSE_CACHE_SIZE, 0.75f, true) private val parseCache = java.util.LinkedHashMap<String, ArticleState.Success>(PARSE_CACHE_SIZE, 0.75f, true)
private val articleHistory = ArrayDeque<ArticleState.Success>() private val articleHistory = ArrayDeque<ArticleState.Success>()
private val activeDictionaries: StateFlow<List<com.example.research.core.domain.model.Dictionary>> = private val activeDictionaries: StateFlow<List<com.example.research.core.domain.model.Dictionary>> =
repository.dictionaries.map { dictList -> repository.dictionaries.map { dictList ->
dictList.filter { it.isActive } dictList.filter { it.isActive }
}.distinctUntilChanged { previous, current ->
previous.map { it.indexPath } == current.map { it.indexPath }
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000), started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList() initialValue = repository.dictionaries.value.filter { it.isActive }
) )
init { init {
@@ -85,7 +96,12 @@ class SearchViewModel(
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
val searchResults: Flow<PagingData<IndexEntry>> = combine( val searchResults: Flow<PagingData<IndexEntry>> = combine(
searchQuery.debounce(300.milliseconds).distinctUntilChanged(), searchQuery
.map { it to it.isBlank() }
.distinctUntilChangedBy { it.first }
.flatMapLatest { (query, isBlank) ->
if (isBlank) flowOf(query) else flowOf(query).debounce(300.milliseconds)
},
activeDictionaries activeDictionaries
) { query, dictionaries -> ) { query, dictionaries ->
query to dictionaries query to dictionaries
@@ -98,8 +114,7 @@ class SearchViewModel(
pageSize = 12, pageSize = 12,
prefetchDistance = 1, prefetchDistance = 1,
enablePlaceholders = false, enablePlaceholders = false,
initialLoadSize = 12, initialLoadSize = 12
jumpThreshold = 1
), ),
pagingSourceFactory = { pagingSourceFactory = {
IndexEntryPagingSource( IndexEntryPagingSource(
@@ -118,20 +133,24 @@ class SearchViewModel(
SearchAction.ClearQuery -> { SearchAction.ClearQuery -> {
mutableSearchQuery.value = "" mutableSearchQuery.value = ""
savedStateHandle[KEY_SEARCH_QUERY] = "" savedStateHandle[KEY_SEARCH_QUERY] = ""
clearArticle()
} }
is SearchAction.ArticleClicked -> { is SearchAction.ArticleClicked -> {
articleHistory.clear() articleHistory.clear()
recordSearchHistory(action.entry.originalWord)
loadArticle(action.entry) loadArticle(action.entry)
} }
SearchAction.ClearArticle -> clearArticle() SearchAction.ClearArticle -> clearArticle()
is SearchAction.SearchAndLoadArticle -> searchAndLoadArticle(action.word) is SearchAction.SearchAndLoadArticle -> searchAndLoadArticle(action.word)
is SearchAction.HandleExternalSearch -> handleExternalSearch(action.text)
} }
} }
fun onSearchQueryChanged(query: String) { fun onSearchQueryChanged(query: String) {
val sanitized = query.sanitizeQuery().trim() val sanitized = query.sanitizeQuery().trim()
mutableSearchQuery.value = sanitized mutableSearchQuery.value = sanitized
if (sanitized.isBlank()) {
clearArticle()
}
// Debounce SavedStateHandle writes to reduce disk I/O // Debounce SavedStateHandle writes to reduce disk I/O
debounceSaveQueryJob?.cancel() debounceSaveQueryJob?.cancel()
debounceSaveQueryJob = viewModelScope.launch { debounceSaveQueryJob = viewModelScope.launch {
@@ -151,6 +170,27 @@ class SearchViewModel(
mutableErrorMessage.value = null mutableErrorMessage.value = null
} }
fun removeSearchHistoryEntry(query: String) {
viewModelScope.launch {
preferencesManager.removeSearchHistoryEntry(query)
}
}
fun clearSearchHistory() {
viewModelScope.launch {
preferencesManager.clearSearchHistory()
}
}
private fun recordSearchHistory(query: String) {
val trimmed = query.trim()
if (trimmed.isBlank()) return
if (searchHistory.value.firstOrNull() == trimmed) return
viewModelScope.launch {
preferencesManager.addSearchHistoryEntry(trimmed)
}
}
fun saveScrollPosition(index: Int, offset: Int) { fun saveScrollPosition(index: Int, offset: Int) {
mutableScrollPosition.value = index to offset mutableScrollPosition.value = index to offset
} }
@@ -172,12 +212,7 @@ class SearchViewModel(
val cached = synchronized(parseCache) { parseCache[cacheKey] } val cached = synchronized(parseCache) { parseCache[cacheKey] }
if (cached != null) { if (cached != null) {
mutableSelectedArticle.update { mutableSelectedArticle.update {
ArticleState.Success( cached.copy(title = entry.originalWord, dictionaryName = entry.dictionaryName)
title = entry.originalWord,
dslContent = cached,
dictionaryName = entry.dictionaryName,
dictionaryPath = entry.dictionaryPath,
)
} }
return@launch return@launch
} }
@@ -189,6 +224,13 @@ class SearchViewModel(
colorScheme = DslAnnotatedParser.ARTICLE_COLOR_SCHEME, colorScheme = DslAnnotatedParser.ARTICLE_COLOR_SCHEME,
) )
} }
val article = ArticleState.Success(
title = entry.originalWord,
dslContent = mergeDslBlocks(annotatedStringList),
dictionaryName = entry.dictionaryName,
dictionaryPath = entry.dictionaryPath,
isHeavy = annotatedStringList.size > HEAVY_ARTICLE_BLOCK_COUNT,
)
synchronized(parseCache) { synchronized(parseCache) {
if (parseCache.size >= PARSE_CACHE_SIZE) { if (parseCache.size >= PARSE_CACHE_SIZE) {
val iterator = parseCache.entries.iterator() val iterator = parseCache.entries.iterator()
@@ -196,19 +238,12 @@ class SearchViewModel(
iterator.next().run { iterator.remove() } iterator.next().run { iterator.remove() }
} }
} }
parseCache[cacheKey] = annotatedStringList parseCache[cacheKey] = article
} }
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.Default) {
ArticleTextWarmUp.warmUp(getApplication(), annotatedStringList) ArticleTextWarmUp.warmUp(getApplication(), article.dslContent)
}
mutableSelectedArticle.update {
ArticleState.Success(
title = entry.originalWord,
dslContent = annotatedStringList,
dictionaryName = entry.dictionaryName,
dictionaryPath = entry.dictionaryPath,
)
} }
mutableSelectedArticle.update { article }
} }
.onError { message, exception -> .onError { message, exception ->
Log.e(TAG, "loadArticle() - error: $message", exception) Log.e(TAG, "loadArticle() - error: $message", exception)
@@ -273,6 +308,7 @@ class SearchViewModel(
} }
entry?.let { entry?.let {
currentArticle?.let(articleHistory::addLast) currentArticle?.let(articleHistory::addLast)
recordSearchHistory(sanitizedWord)
loadArticle(it) loadArticle(it)
} }
}.onError { message, exception -> }.onError { message, exception ->
@@ -281,21 +317,9 @@ class SearchViewModel(
} }
} }
fun handleExternalSearch(text: String) {
viewModelScope.launch {
val sanitizedText = text.sanitizeQuery()
mutableSearchQuery.value = sanitizedText
searchAndLoadArticle(sanitizedText)
}
}
companion object { companion object {
private const val TAG = "SearchViewModel" private const val TAG = "SearchViewModel"
// Parse cache: stores parsed DslBlock lists to avoid re-parsing articles
// Size: 50 articles - typical user navigation depth in a session
// Memory estimate: ~50KB per article average (text + formatting)
// Total: ~2.5MB worst case
private const val PARSE_CACHE_SIZE = 50 private const val PARSE_CACHE_SIZE = 50
private const val KEY_SEARCH_QUERY = "search_query" private const val KEY_SEARCH_QUERY = "search_query"
@@ -2,9 +2,11 @@ package com.example.research.ui.about
import android.content.Context import android.content.Context
import androidx.annotation.RawRes import androidx.annotation.RawRes
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
@Serializable @Serializable
internal data class AboutLibrariesData( internal data class AboutLibrariesData(
@@ -74,10 +76,8 @@ internal object AboutLibrariesParser {
ignoreUnknownKeys = true ignoreUnknownKeys = true
} }
@OptIn(ExperimentalSerializationApi::class)
fun read(context: Context, @RawRes resourceId: Int): AboutLibrariesData = fun read(context: Context, @RawRes resourceId: Int): AboutLibrariesData =
context.resources.openRawResource(resourceId) context.resources.openRawResource(resourceId)
.bufferedReader() .use { stream -> json.decodeFromStream(stream) }
.use { reader -> json.decodeFromString(reader.readText()) }
fun decode(source: String): AboutLibrariesData = json.decodeFromString(source)
} }
@@ -1,22 +1,24 @@
package com.example.research.ui.about package com.example.research.ui.about
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -25,26 +27,34 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.research.R import com.example.research.R
import com.example.research.common.ui.AppBackButton
import com.example.research.common.ui.components.OutlinedChoiceButton import com.example.research.common.ui.components.OutlinedChoiceButton
import com.example.research.ui.theme.AppWindowInsets import com.example.research.ui.theme.AppWindowInsets
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun AboutScreen( fun AboutScreen(
onNavigateBack: () -> Unit onNavigateBack: () -> Unit
@@ -57,23 +67,32 @@ fun AboutScreen(
} }
val versionName = packageInfo?.versionName ?: stringResource(R.string.version_unknown) val versionName = packageInfo?.versionName ?: stringResource(R.string.version_unknown)
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val librariesData = remember(context) { val librariesData by produceState<AboutLibrariesData?>(initialValue = null, context) {
value = withContext(Dispatchers.IO) {
AboutLibrariesParser.read(context, R.raw.aboutlibraries) AboutLibrariesParser.read(context, R.raw.aboutlibraries)
} }
}
var expandedLibraryId by remember { mutableStateOf<String?>(null) } var expandedLibraryId by remember { mutableStateOf<String?>(null) }
var dialogLicense by remember { mutableStateOf<AboutLicense?>(null) } var dialogLicense by remember { mutableStateOf<AboutLicense?>(null) }
val onToggleLibrary = remember {
{ uniqueId: String ->
expandedLibraryId = if (expandedLibraryId == uniqueId) {
null
} else {
uniqueId
}
}
}
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.about_app_title)) }, title = { Text(stringResource(R.string.about_app_title)) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onNavigateBack) { AppBackButton(
Icon( onClick = onNavigateBack,
painter = painterResource(R.drawable.ic_arrow_back), modifier = Modifier.padding(start = 6.dp)
contentDescription = stringResource(R.string.action_back)
) )
}
}, },
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface containerColor = MaterialTheme.colorScheme.surface
@@ -89,24 +108,20 @@ fun AboutScreen(
.semantics { testTagsAsResourceId = true } .semantics { testTagsAsResourceId = true }
.padding(padding) .padding(padding)
) { ) {
item(key = "about_header") { item(key = "about_header", contentType = "about_header") {
AboutHeader(versionName = versionName) AboutHeader(versionName = versionName)
} }
items( items(
items = librariesData.libraries, items = librariesData?.libraries.orEmpty(),
key = AboutLibrary::uniqueId, key = AboutLibrary::uniqueId,
contentType = { "about_library" },
) { library -> ) { library ->
AboutLibraryRow( AboutLibraryRow(
modifier = Modifier.animateItem(fadeInSpec = tween(150)),
library = library, library = library,
licenses = librariesData.licenses, licenses = librariesData?.licenses.orEmpty(),
expanded = expandedLibraryId == library.uniqueId, expanded = expandedLibraryId == library.uniqueId,
onToggle = { onToggle = onToggleLibrary,
expandedLibraryId = if (expandedLibraryId == library.uniqueId) {
null
} else {
library.uniqueId
}
},
onLicenseClick = { dialogLicense = it }, onLicenseClick = { dialogLicense = it },
) )
} }
@@ -139,13 +154,14 @@ private fun AboutHeader(versionName: String) {
Text( Text(
text = stringResource(R.string.app_name), text = stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.alignByBaseline()
) )
Text( Text(
text = stringResource(R.string.version_format, versionName), text = versionName,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 2.dp) modifier = Modifier.alignByBaseline()
) )
} }
@@ -164,35 +180,39 @@ private fun AboutHeader(versionName: String) {
} }
} }
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
private fun AboutLibraryRow( private fun AboutLibraryRow(
modifier: Modifier = Modifier,
library: AboutLibrary, library: AboutLibrary,
licenses: Map<String, AboutLicense>, licenses: Map<String, AboutLicense>,
expanded: Boolean, expanded: Boolean,
onToggle: () -> Unit, onToggle: (String) -> Unit,
onLicenseClick: (AboutLicense) -> Unit, onLicenseClick: (AboutLicense) -> Unit,
) { ) {
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val website = library.website?.takeIf(String::isNotBlank) val website = library.website?.takeIf(String::isNotBlank)
val source = library.scm?.url?.takeIf(String::isNotBlank) val source = library.scm?.url?.takeIf(String::isNotBlank)
Surface( val background by animateColorAsState(
modifier = Modifier targetValue = if (expanded) {
.fillMaxWidth()
.testTag("about_library_row")
.semantics { testTagsAsResourceId = true },
color = if (expanded) {
MaterialTheme.colorScheme.surfaceContainerHighest MaterialTheme.colorScheme.surfaceContainerHighest
} else { } else {
MaterialTheme.colorScheme.surface MaterialTheme.colorScheme.surface
}, },
onClick = onToggle label = "aboutLibraryRowBackground",
)
Surface(
modifier = modifier
.fillMaxWidth()
.testTag("about_library_row")
.semantics { testTagsAsResourceId = true },
color = background,
onClick = { onToggle(library.uniqueId) }
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.padding(horizontal = 16.dp, vertical = 12.dp) .padding(horizontal = 16.dp, vertical = 12.dp),
.animateContentSize(),
verticalArrangement = Arrangement.spacedBy(5.dp) verticalArrangement = Arrangement.spacedBy(5.dp)
) { ) {
Row( Row(
@@ -294,26 +314,74 @@ private fun LicenseDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onOpenWebsite: (String) -> Unit, onOpenWebsite: (String) -> Unit,
) { ) {
AlertDialog( var open by remember { mutableStateOf(false) }
onDismissRequest = onDismiss, var wasOpen by remember { mutableStateOf(false) }
title = { Text(license.name) }, LaunchedEffect(Unit) { open = true }
text = { val scale by animateFloatAsState(
targetValue = if (open) 1f else 0.92f,
animationSpec = tween(180),
label = "licenseDialogScale",
)
val alpha by animateFloatAsState(
targetValue = if (open) 1f else 0f,
animationSpec = tween(180),
label = "licenseDialogAlpha",
)
LaunchedEffect(open) {
if (open) {
wasOpen = true
} else if (wasOpen) {
snapshotFlow { alpha }.first { it == 0f }
onDismiss()
}
}
BasicAlertDialog(
onDismissRequest = { open = false },
modifier = Modifier.graphicsLayer {
scaleX = scale
scaleY = scale
this.alpha = alpha
}
) {
val maxHeight = with(LocalDensity.current) {
LocalWindowInfo.current.containerSize.height.toDp()
} - 96.dp
Surface(
shape = AlertDialogDefaults.shape,
color = AlertDialogDefaults.containerColor,
tonalElevation = AlertDialogDefaults.TonalElevation,
) {
Column(modifier = Modifier.padding(24.dp).heightIn(max = maxHeight)) {
Text(
text = license.name,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(16.dp))
Text( Text(
text = license.content ?: stringResource(R.string.license_text_unavailable), text = license.content ?: stringResource(R.string.license_text_unavailable),
modifier = Modifier.verticalScroll(rememberScrollState()) style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.weight(1f, fill = false)
.verticalScroll(rememberScrollState())
) )
}, Spacer(modifier = Modifier.height(24.dp))
confirmButton = { Row(
TextButton(onClick = onDismiss) { modifier = Modifier.fillMaxWidth(),
Text(stringResource(R.string.action_close)) horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
} ) {
},
dismissButton = {
license.url?.takeIf(String::isNotBlank)?.let { url -> license.url?.takeIf(String::isNotBlank)?.let { url ->
TextButton(onClick = { onOpenWebsite(url) }) { TextButton(onClick = { onOpenWebsite(url) }) {
Text(stringResource(R.string.website)) Text(stringResource(R.string.website))
} }
} }
TextButton(onClick = { open = false }) {
Text(stringResource(R.string.action_close))
}
}
}
}
} }
)
} }
@@ -196,9 +196,7 @@ private suspend fun PointerInputScope.detectReferenceTaps(
if (change == null) { if (change == null) {
break break
} }
if (change.isConsumed || if ((change.position - downPosition).getDistance() > viewConfiguration.touchSlop) {
(change.position - downPosition).getDistance() > viewConfiguration.touchSlop
) {
canceled = true canceled = true
} }
if (!change.pressed) { if (!change.pressed) {
@@ -1,52 +1,58 @@
package com.example.research.ui.article package com.example.research.ui.article
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.* import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.runtime.* import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.unit.dp import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.unit.dp
import com.example.research.R import com.example.research.R
import com.example.research.common.ui.AppBackButton
import com.example.research.common.ui.theme.dictionaryTitleLarge import com.example.research.common.ui.theme.dictionaryTitleLarge
import com.example.research.feature.search.SearchAction
import com.example.research.ui.theme.Spacing
import com.example.research.ui.theme.AppWindowInsets import com.example.research.ui.theme.AppWindowInsets
import com.example.research.feature.search.SearchViewModel import com.example.research.ui.theme.Spacing
import androidx.compose.runtime.getValue
@Composable
fun ArticleRoute(
searchViewModel: SearchViewModel,
onNavigateBack: () -> Unit
) {
val articleState by searchViewModel.selectedArticle.collectAsStateWithLifecycle()
ArticleScreen(
articleState = articleState,
onNavigateBack = onNavigateBack,
onRefClick = { word ->
searchViewModel.onAction(SearchAction.SearchAndLoadArticle(word))
},
onScrollPositionChanged = { index, offset ->
searchViewModel.updateArticleScrollPosition(index, offset)
},
)
}
@Composable @Composable
fun ArticleContent( fun ArticleContent(
@@ -62,9 +68,17 @@ fun ArticleContent(
} }
is ArticleState.Success -> { is ArticleState.Success -> {
val articleSaveKey = "${articleState.dictionaryPath}:${articleState.title}" val articleSaveKey = "${articleState.dictionaryPath}:${articleState.title}"
val baseTextStyle = MaterialTheme.typography.bodyLarge.copy( val colorScheme = MaterialTheme.colorScheme
color = MaterialTheme.colorScheme.onSurface val typography = MaterialTheme.typography
val baseTextStyle = remember(colorScheme, typography) {
typography.bodyLarge.copy(
color = colorScheme.onSurface,
lineHeightStyle = LineHeightStyle(
alignment = LineHeightStyle.Alignment.Proportional,
trim = LineHeightStyle.Trim.Both,
),
) )
}
val indentStep = 16.dp val indentStep = 16.dp
// Use scroll position from ArticleState if available (for back navigation) // Use scroll position from ArticleState if available (for back navigation)
@@ -120,38 +134,42 @@ fun ArticleContent(
} }
} }
val isHeavyArticle = val isHeavyArticle = articleState.isHeavy
articleState.dslContent.size > HEAVY_ARTICLE_BLOCK_COUNT
val darkTheme = MaterialTheme.colorScheme.background.luminance() < 0.5f
val displayBlocks = remember(
articleState.dslContent,
baseTextStyle,
darkTheme
) {
if (darkTheme) {
articleState.dslContent.map {
it.copy(text = DslAnnotatedParser.remapForDarkTheme(it.text))
}
} else {
articleState.dslContent
}
}
val articleList: @Composable (Modifier) -> Unit = { listModifier ->
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = modifier contentPadding = PaddingValues(top = Spacing.listItemVertical),
.fillMaxSize() modifier = listModifier
.onSizeChanged { viewportSize = it }
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
.padding(end = 16.dp)
.testTag("article_content") .testTag("article_content")
.semantics { .semantics {
testTagsAsResourceId = true testTagsAsResourceId = true
} }
) { ) {
itemsIndexed( itemsIndexed(
items = articleState.dslContent, items = displayBlocks,
key = { index, _ -> key = { index, _ ->
// Use stable key combining article save key and index
"$articleSaveKey:$index" "$articleSaveKey:$index"
}, },
contentType = { _, _ -> "article_block" } contentType = { _, _ -> "article_block" }
) { index, block -> ) { index, block ->
val followedByContinuation =
articleState.dslContent.getOrNull(index + 1)?.continuesPrevious == true
val textContent = @Composable {
Box( Box(
modifier = Modifier modifier = Modifier
.padding( .padding(start = Spacing.contentStart + indentStep * block.indent)
start = Spacing.iconStart + indentStep * block.indent
)
.padding(bottom = if (followedByContinuation) 0.dp else 8.dp)
) { ) {
ArticleLinkText( ArticleLinkText(
block = block, block = block,
@@ -168,12 +186,17 @@ fun ArticleContent(
) )
} }
} }
}
}
val articleListModifier = modifier
.fillMaxSize()
.onSizeChanged { viewportSize = it }
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
.padding(end = 16.dp)
if (isHeavyArticle) { if (isHeavyArticle) {
textContent() articleList(articleListModifier)
} else { } else {
SelectionContainer { textContent() } SelectionContainer { articleList(articleListModifier) }
}
}
} }
} }
is ArticleState.Error -> { is ArticleState.Error -> {
@@ -196,16 +219,7 @@ fun ArticleContent(
} }
} }
null -> { null -> {
Column( Box(modifier = modifier.fillMaxSize())
modifier = modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = stringResource(R.string.article_no_selected),
style = MaterialTheme.typography.bodyLarge
)
}
} }
} }
} }
@@ -215,48 +229,56 @@ fun ArticleTitleBar(
articleState: ArticleState?, articleState: ArticleState?,
showBackButton: Boolean, showBackButton: Boolean,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
compact: Boolean = false,
selectionReset: Int = 0,
) { ) {
val title = when (articleState) { val title = when (articleState) {
is ArticleState.Loading -> articleState.title is ArticleState.Loading -> articleState.title
is ArticleState.Success -> articleState.title is ArticleState.Success -> articleState.title
is ArticleState.Error -> articleState.title is ArticleState.Error -> articleState.title
null -> stringResource(R.string.article_no_selected) null -> ""
} }
val backIcon = painterResource(R.drawable.ic_arrow_back) val backButtonOffset = if (compact) {
val titleLineHeight = with(LocalDensity.current) {
MaterialTheme.typography.dictionaryTitleLarge.lineHeight.toDp()
}
Modifier.offset(y = (titleLineHeight - 48.dp) / 2 + 3.dp)
} else {
Modifier
}
Surface( Surface(
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
modifier = modifier.fillMaxWidth() modifier = modifier.fillMaxWidth()
) { ) {
Box( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.windowInsetsPadding(AppWindowInsets.topAndHorizontalSafeDrawing) .windowInsetsPadding(AppWindowInsets.topAndHorizontalSafeDrawing)
.padding( .then(
top = Spacing.topBarPadding, if (compact) {
bottom = Spacing.topBarPadding Modifier.padding(vertical = Spacing.listItemVertical)
) } else {
Modifier
.padding(top = Spacing.topBarPadding, bottom = Spacing.topBarPadding)
.height(Spacing.topBarHeight) .height(Spacing.topBarHeight)
}
),
verticalAlignment = if (compact) Alignment.Top else Alignment.CenterVertically
) { ) {
if (showBackButton) { if (showBackButton) {
Box( AppBackButton(
onClick = onNavigateBack,
modifier = Modifier modifier = Modifier
.align(Alignment.CenterStart) .padding(start = 10.dp)
.padding(start = Spacing.iconStart) .then(backButtonOffset)
.size(24.dp)
.clickable(onClick = onNavigateBack),
contentAlignment = Alignment.Center
) {
Icon(
painter = backIcon,
contentDescription = stringResource(R.string.action_back),
modifier = Modifier.size(24.dp)
) )
} }
}
key(selectionReset) {
SelectionContainer {
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.dictionaryTitleLarge, style = MaterialTheme.typography.dictionaryTitleLarge,
@@ -264,16 +286,16 @@ fun ArticleTitleBar(
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier modifier = Modifier
.align(Alignment.CenterStart) .padding(start = if (showBackButton) 0.dp else 16.dp)
.padding(start = if (showBackButton) Spacing.searchTextStart else 16.dp)
.testTag("article_title") .testTag("article_title")
.semantics { testTagsAsResourceId = true } .semantics { testTagsAsResourceId = true }
) )
} }
} }
}
}
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
internal fun ArticleScreen( internal fun ArticleScreen(
articleState: ArticleState?, articleState: ArticleState?,
@@ -281,12 +303,20 @@ internal fun ArticleScreen(
onRefClick: ((String) -> Unit)? = null, onRefClick: ((String) -> Unit)? = null,
onScrollPositionChanged: ((Int, Int) -> Unit)? = null, onScrollPositionChanged: ((Int, Int) -> Unit)? = null,
) { ) {
val titleSelectionReset = remember { mutableIntStateOf(0) }
Box(
modifier = Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { titleSelectionReset.intValue++ }
) {
Scaffold( Scaffold(
topBar = { topBar = {
ArticleTitleBar( ArticleTitleBar(
articleState = articleState, articleState = articleState,
showBackButton = true, showBackButton = true,
onNavigateBack = onNavigateBack onNavigateBack = onNavigateBack,
selectionReset = titleSelectionReset.intValue
) )
}, },
contentWindowInsets = AppWindowInsets.contentWithNavigation contentWindowInsets = AppWindowInsets.contentWithNavigation
@@ -299,4 +329,5 @@ internal fun ArticleScreen(
modifier = Modifier.padding(padding) modifier = Modifier.padding(padding)
) )
} }
}
} }
@@ -37,6 +37,7 @@ sealed class ArticleState {
val dictionaryPath: String = "", val dictionaryPath: String = "",
val scrollIndex: Int = 0, val scrollIndex: Int = 0,
val scrollOffset: Int = 0, val scrollOffset: Int = 0,
val isHeavy: Boolean = false,
) : ArticleState() ) : ArticleState()
@Immutable @Immutable
data class Error( data class Error(
@@ -13,11 +13,14 @@ import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
internal const val HEAVY_ARTICLE_BLOCK_COUNT = 100 internal const val HEAVY_ARTICLE_BLOCK_COUNT = 100
internal const val HEAVY_ARTICLE_TOTAL_CHARS = 1_500
object ArticleTextWarmUp { object ArticleTextWarmUp {
suspend fun warmUp(context: Context, blocks: List<DslBlock>) { suspend fun warmUp(context: Context, blocks: List<DslBlock>) {
if (blocks.size <= HEAVY_ARTICLE_BLOCK_COUNT) return if (blocks.size <= HEAVY_ARTICLE_BLOCK_COUNT &&
blocks.sumOf { it.text.length } <= HEAVY_ARTICLE_TOTAL_CHARS
) return
val density = Density(context) val density = Density(context)
val measurer = TextMeasurer( val measurer = TextMeasurer(
defaultFontFamilyResolver = createFontFamilyResolver(context), defaultFontFamilyResolver = createFontFamilyResolver(context),
@@ -23,7 +23,40 @@ object DslAnnotatedParser {
"br", "p", "b", "i", "c", "t", "m", "m0", "m1", "m2", "m3", "m4", "m5", "br", "p", "b", "i", "c", "t", "m", "m0", "m1", "m2", "m3", "m4", "m5",
"ref", "ex", "e", "trn", "com", "lang", "sup", "'" "ref", "ex", "e", "trn", "com", "lang", "sup", "'"
) )
private val ESCAPED_CHARS = setOf('[', ']', '(', ')')
private val DSL_NAMED_COLORS = mapOf(
"black" to Color(0xFF000000),
"silver" to Color(0xFFC0C0C0),
"gray" to Color(0xFF808080),
"grey" to Color(0xFF808080),
"lightgray" to Color(0xFFD3D3D3),
"lightgrey" to Color(0xFFD3D3D3),
"darkgray" to Color(0xFFA9A9A9),
"darkgrey" to Color(0xFFA9A9A9),
"white" to Color(0xFFFFFFFF),
"maroon" to Color(0xFF800000),
"red" to Color(0xFFFF0000),
"darkred" to Color(0xFF8B0000),
"crimson" to Color(0xFFDC143C),
"brown" to Color(0xFFA52A2A),
"orange" to Color(0xFFFFA500),
"pink" to Color(0xFFFFC0CB),
"purple" to Color(0xFF800080),
"violet" to Color(0xFFEE82EE),
"fuchsia" to Color(0xFFFF00FF),
"magenta" to Color(0xFFFF00FF),
"olive" to Color(0xFF808000),
"yellow" to Color(0xFFFFFF00),
"green" to Color(0xFF008000),
"darkgreen" to Color(0xFF006400),
"lime" to Color(0xFF00FF00),
"teal" to Color(0xFF008080),
"aqua" to Color(0xFF00FFFF),
"cyan" to Color(0xFF00FFFF),
"navy" to Color(0xFF000080),
"blue" to Color(0xFF0000FF),
"darkblue" to Color(0xFF00008B),
)
data class ColorScheme( data class ColorScheme(
val secondaryText: Color, val secondaryText: Color,
@@ -37,11 +70,6 @@ object DslAnnotatedParser {
val endIndex: Int val endIndex: Int
) )
// Cache for applyLazyRefs results to avoid recreating AnnotatedString with links
// Size: 32 blocks - covers typical visible + prefetch range in LazyColumn
// Memory estimate: ~20KB per block average (AnnotatedString with LinkAnnotations)
// Total: ~640KB worst case
// LRU eviction automatic via LruCache
private val refCache = object : androidx.collection.LruCache<Pair<DslBlock, Int>, AnnotatedString>(32) { private val refCache = object : androidx.collection.LruCache<Pair<DslBlock, Int>, AnnotatedString>(32) {
override fun sizeOf(key: Pair<DslBlock, Int>, value: AnnotatedString): Int = 1 override fun sizeOf(key: Pair<DslBlock, Int>, value: AnnotatedString): Int = 1
} }
@@ -55,9 +83,9 @@ object DslAnnotatedParser {
val stack = ArrayDeque<SpanStyle>() val stack = ArrayDeque<SpanStyle>()
val refStack = ArrayDeque<Pair<Int, StringBuilder>>() val refStack = ArrayDeque<Pair<Int, StringBuilder>>()
val lazyRefs = mutableListOf<LazyRef>() val lazyRefs = mutableListOf<LazyRef>()
var lastChar: Char? = null
var accentNext = false var accentNext = false
var currentIndent = 0 var currentIndent = 0
var lastAppended: Char? = null
val exampleStyle = SpanStyle(color = colorScheme.secondaryText) val exampleStyle = SpanStyle(color = colorScheme.secondaryText)
val labelStyle = SpanStyle(color = colorScheme.labelText) val labelStyle = SpanStyle(color = colorScheme.labelText)
val linkStyle = SpanStyle(color = colorScheme.linkText, textDecoration = TextDecoration.Underline) val linkStyle = SpanStyle(color = colorScheme.linkText, textDecoration = TextDecoration.Underline)
@@ -75,16 +103,38 @@ object DslAnnotatedParser {
} }
fun flushBlock() { fun flushBlock() {
if (builder.length > 0) { val text = builder.toAnnotatedString()
if (text.any { !it.isWhitespace() }) {
result.add(DslBlock( result.add(DslBlock(
text = builder.toAnnotatedString(), text = text,
indent = currentIndent, indent = currentIndent,
lazyRefs = lazyRefs.toList() lazyRefs = lazyRefs.toList()
)) ))
}
builder = AnnotatedString.Builder() builder = AnnotatedString.Builder()
stack.forEach { builder.pushStyle(it) } stack.forEach { builder.pushStyle(it) }
lazyRefs.clear() lazyRefs.clear()
lastAppended = null
} }
fun appendChar(char: Char, hard: Boolean = false) {
if (char == ' ' && !hard) {
if (lastAppended == null || lastAppended == ' ') return
}
lastAppended = char
builder.append(char)
if (refStack.isNotEmpty()) {
refStack.last().second.append(char)
}
}
fun appendText(value: String) {
if (value.isEmpty()) return
builder.append(value)
if (refStack.isNotEmpty()) {
refStack.last().second.append(value)
}
lastAppended = value.last()
} }
fun marginIndent(tagName: String): Int { fun marginIndent(tagName: String): Int {
@@ -100,26 +150,15 @@ object DslAnnotatedParser {
when (val char = dsl[i]) { when (val char = dsl[i]) {
'\\' -> { '\\' -> {
if (i + 1 < length) { if (i + 1 < length) {
val next = dsl[i + 1] appendChar(dsl[i + 1], hard = true)
if (next in ESCAPED_CHARS) {
builder.append(next)
if (refStack.isNotEmpty()) {
refStack.last().second.append(next)
}
lastChar = next
i += 2 i += 2
continue continue
} }
} appendChar(char)
builder.append(char)
if (refStack.isNotEmpty()) {
refStack.last().second.append(char)
}
lastChar = char
i++ i++
} }
'[' -> { '[' -> {
val end = dsl.indexOf(']', i + 1) val end = tagCloseIndex(dsl, i + 1)
if (end != -1) { if (end != -1) {
val tagStart = i + 1 val tagStart = i + 1
var tagEnd = end var tagEnd = end
@@ -152,6 +191,10 @@ object DslAnnotatedParser {
accentNext = false accentNext = false
} }
pop() pop()
if (tagName.startsWith("m")) {
flushBlock()
currentIndent = 0
}
} else { } else {
when (tagName) { when (tagName) {
"'" -> { "'" -> {
@@ -164,17 +207,15 @@ object DslAnnotatedParser {
} else { } else {
builder.append("\n") builder.append("\n")
} }
lastChar = '\n'
} }
"p" -> { "p" -> {
push(labelStyle) push(labelStyle.copy(fontStyle = FontStyle.Italic))
} }
"b" -> push(SpanStyle(fontWeight = FontWeight.Bold)) "b" -> push(SpanStyle(fontWeight = FontWeight.Bold))
"m", "m0", "m1", "m2", "m3", "m4", "m5" -> { "m", "m0", "m1", "m2", "m3", "m4", "m5" -> {
if (lastChar != '\n' && lastChar != null) { if (lastAppended != null && !lastAppended!!.isWhitespace()) {
if (refStack.isEmpty()) flushBlock() else builder.append("\n") flushBlock()
} }
lastChar = '\n'
currentIndent = marginIndent(tagName) currentIndent = marginIndent(tagName)
push(exampleStyle) push(exampleStyle)
} }
@@ -184,7 +225,18 @@ object DslAnnotatedParser {
refStack.addLast(builder.length to StringBuilder()) refStack.addLast(builder.length to StringBuilder())
} }
"ex", "e" -> push(exampleStyle) "ex", "e" -> push(exampleStyle)
"c", "t", "trn", "lang", "sup" -> push(SpanStyle()) "c" -> {
val colorName = if (spaceIdx >= 0) {
tagWithParams.substring(spaceIdx + 1).trim().lowercase()
} else {
null
}
push(when {
colorName == null -> labelStyle
else -> DSL_NAMED_COLORS[colorName]?.let { SpanStyle(color = it) } ?: SpanStyle()
})
}
"t", "trn", "lang", "sup" -> push(SpanStyle())
"com" -> push(SpanStyle(fontStyle = FontStyle.Italic, color = colorScheme.labelText)) "com" -> push(SpanStyle(fontStyle = FontStyle.Italic, color = colorScheme.labelText))
} }
} }
@@ -192,18 +244,23 @@ object DslAnnotatedParser {
continue continue
} }
} }
builder.append(char) appendChar(char)
if (refStack.isNotEmpty()) { i++
refStack.last().second.append(char)
} }
lastChar = char '\n' -> {
if (refStack.isEmpty()) {
flushBlock()
} else {
appendChar(char)
}
i++
}
'\t' -> {
appendText("\u2002")
i++ i++
} }
else -> { else -> {
builder.append(char) appendChar(char)
if (refStack.isNotEmpty()) {
refStack.last().second.append(char)
}
if (accentNext) { if (accentNext) {
builder.append('\u0301') builder.append('\u0301')
@@ -213,7 +270,6 @@ object DslAnnotatedParser {
accentNext = false accentNext = false
} }
lastChar = char
i++ i++
} }
} }
@@ -228,6 +284,19 @@ object DslAnnotatedParser {
splitOversizedBlocks(result) splitOversizedBlocks(result)
} }
private fun tagCloseIndex(value: String, startIndex: Int): Int {
var index = startIndex
while (index < value.length) {
when {
value[index] == '\\' && index + 1 < value.length -> index += 2
value[index] == '[' -> return -1
value[index] == ']' -> return index
else -> index++
}
}
return -1
}
private fun splitOversizedBlocks(blocks: List<DslBlock>): List<DslBlock> { private fun splitOversizedBlocks(blocks: List<DslBlock>): List<DslBlock> {
if (blocks.none { it.text.length > MAX_BLOCK_TEXT_LENGTH }) return blocks if (blocks.none { it.text.length > MAX_BLOCK_TEXT_LENGTH }) return blocks
return blocks.flatMap { block -> return blocks.flatMap { block ->
@@ -342,6 +411,35 @@ object DslAnnotatedParser {
linkText = Color(0xFF1E88E5), linkText = Color(0xFF1E88E5),
) )
private val DARK_THEME_COLORS: Map<Color, Color> = mapOf(
Color(0xFF000000) to Color(0xFFE8E6E3),
Color(0xFF000080) to Color(0xFF8FA8E0),
Color(0xFF00008B) to Color(0xFF8FA8E0),
Color(0xFF0000FF) to Color(0xFF7C9CE8),
Color(0xFF006400) to Color(0xFF66BB6A),
Color(0xFF008000) to Color(0xFF66BB6A),
Color(0xFF8B0000) to Color(0xFFE57373),
Color(0xFF800000) to Color(0xFFE57373),
Color(0xFF800080) to Color(0xFFBA68C8),
Color(0xFFA52A2A) to Color(0xFFBCAAA4),
Color(0xFF008080) to Color(0xFF4DB6AC),
Color(0xFF808000) to Color(0xFFC0CA33),
)
fun remapForDarkTheme(text: AnnotatedString): AnnotatedString {
if (text.spanStyles.none { it.item.color in DARK_THEME_COLORS }) return text
val builder = AnnotatedString.Builder(text.text)
text.spanStyles.forEach { range ->
val style = DARK_THEME_COLORS[range.item.color]
?.let { range.item.copy(color = it) } ?: range.item
builder.addStyle(style, range.start, range.end)
}
text.paragraphStyles.forEach { range ->
builder.addStyle(range.item, range.start, range.end)
}
return builder.toAnnotatedString()
}
// Allow external cache clearing (e.g., when dictionaries change) // Allow external cache clearing (e.g., when dictionaries change)
fun clearRefCache() { fun clearRefCache() {
refCache.evictAll() refCache.evictAll()
@@ -0,0 +1,51 @@
package com.example.research.ui.article
import androidx.compose.ui.text.AnnotatedString
private const val MERGED_BLOCK_MAX_CHARS = 1500
private const val MERGED_BLOCK_MAX_ITEMS = 12
internal fun mergeDslBlocks(blocks: List<DslBlock>): List<DslBlock> {
if (blocks.size <= MERGED_BLOCK_MAX_ITEMS) return blocks
val merged = ArrayList<DslBlock>(blocks.size / 4)
var i = 0
while (i < blocks.size) {
val builder = AnnotatedString.Builder()
val refs = ArrayList<DslAnnotatedParser.LazyRef>()
var j = i
while (j < blocks.size) {
val block = blocks[j]
repeat(block.indent) { builder.append(' ') }
val paraStart = builder.length
builder.append(block.text)
block.lazyRefs.forEach { ref ->
refs.add(
ref.copy(
startIndex = ref.startIndex + paraStart,
endIndex = ref.endIndex + paraStart
)
)
}
val more = j + 1 < blocks.size &&
(j + 1 - i) < MERGED_BLOCK_MAX_ITEMS &&
builder.length < MERGED_BLOCK_MAX_CHARS
if (more) {
builder.append('\n')
}
j++
if (!more) {
break
}
}
merged.add(
DslBlock(
text = builder.toAnnotatedString(),
indent = 0,
lazyRefs = refs,
continuesPrevious = blocks[i].continuesPrevious
)
)
i = j
}
return merged
}
@@ -1,5 +1,7 @@
package com.example.research.ui.main package com.example.research.ui.main
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -12,7 +14,8 @@ import com.example.research.core.domain.model.IndexEntry
import com.example.research.ui.article.ArticleContent import com.example.research.ui.article.ArticleContent
import com.example.research.ui.article.ArticleTitleBar import com.example.research.ui.article.ArticleTitleBar
import com.example.research.ui.article.ArticleState import com.example.research.ui.article.ArticleState
import com.example.research.ui.main.components.MainTopBar import com.example.research.ui.theme.AppWindowInsets
import com.example.research.ui.theme.Spacing
@OptIn(ExperimentalMaterial3AdaptiveApi::class) @OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable @Composable
@@ -20,9 +23,11 @@ fun AdaptiveMainScreen(
searchState: TextFieldState, searchState: TextFieldState,
canSearch: Boolean, canSearch: Boolean,
isLandscape: Boolean, isLandscape: Boolean,
isSearchBarAtBottom: Boolean,
results: LazyPagingItems<IndexEntry>, results: LazyPagingItems<IndexEntry>,
errorMessage: String?, errorMessage: String?,
showDictionaryName: Boolean, showDictionaryName: Boolean,
searchHistory: List<String>,
articleState: ArticleState?, articleState: ArticleState?,
scrollPosition: Pair<Int, Int>, scrollPosition: Pair<Int, Int>,
onAction: (MainAction) -> Unit, onAction: (MainAction) -> Unit,
@@ -43,21 +48,37 @@ fun AdaptiveMainScreen(
) )
} }
val isQueryEmpty = searchState.text.isEmpty()
val isShowingDetail = scaffoldState.targetState.primary == PaneAdaptedValue.Expanded val isShowingDetail = scaffoldState.targetState.primary == PaneAdaptedValue.Expanded
val isTwoPane = val isTwoPane =
scaffoldState.targetState.primary == PaneAdaptedValue.Expanded && scaffoldState.targetState.primary == PaneAdaptedValue.Expanded &&
scaffoldState.targetState.secondary == PaneAdaptedValue.Expanded scaffoldState.targetState.secondary == PaneAdaptedValue.Expanded
val listEndPadding = if (isTwoPane) 8.dp else 16.dp val listEndPadding = if (isTwoPane) 8.dp else 16.dp
Column(modifier = Modifier.fillMaxSize()) { val searchBar: @Composable () -> Unit = {
MainTopBar( MainSearchBar(
searchState = searchState, searchState = searchState,
enabled = canSearch, canSearch = canSearch,
onQueryChange = { onAction(MainAction.QueryChanged(it)) }, isSearchBarAtBottom = isSearchBarAtBottom,
onClearQuery = { onAction(MainAction.ClearQuery) }, searchHistory = searchHistory,
onNavigateToSettings = onNavigateToSettings onAction = onAction,
onNavigateToSettings = onNavigateToSettings,
) )
}
Column(
modifier = Modifier
.fillMaxSize()
.then(
if (isSearchBarAtBottom) {
Modifier.windowInsetsPadding(AppWindowInsets.statusBarsAndIme)
} else {
Modifier
}
)
) {
if (!isSearchBarAtBottom) {
searchBar()
}
ListDetailPaneScaffold( ListDetailPaneScaffold(
directive = PaneScaffoldDirective.Default.copy( directive = PaneScaffoldDirective.Default.copy(
@@ -84,20 +105,28 @@ fun AdaptiveMainScreen(
.windowInsetsPadding( .windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal) WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)
) )
.padding(start = 32.dp, end = listEndPadding), .padding(start = Spacing.contentStart, end = listEndPadding),
listModifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars), listModifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars),
alignFirstResultWithTitleBar = isSearchBarAtBottom,
) )
}, },
detailPane = { detailPane = {
val titleSelectionReset = remember { mutableIntStateOf(0) }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { titleSelectionReset.intValue++ }
.padding(bottom = 16.dp) .padding(bottom = 16.dp)
) { ) {
ArticleTitleBar( ArticleTitleBar(
articleState = articleState, articleState = articleState,
showBackButton = isShowingDetail, showBackButton = isShowingDetail && articleState != null,
onNavigateBack = onNavigateBackFromArticle, onNavigateBack = onNavigateBackFromArticle,
compact = !isSearchBarAtBottom,
selectionReset = titleSelectionReset.intValue,
modifier = Modifier.windowInsetsPadding( modifier = Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Top) WindowInsets.safeDrawing.only(WindowInsetsSides.Top)
) )
@@ -110,21 +139,21 @@ fun AdaptiveMainScreen(
} }
}, },
) )
if (isSearchBarAtBottom) {
searchBar()
}
} }
LaunchedEffect(articleState, isQueryEmpty) { LaunchedEffect(articleState) {
val targetValue = when { val targetValue = if (articleState == null) {
articleState == null -> ThreePaneScaffoldValue( ThreePaneScaffoldValue(
primary = PaneAdaptedValue.Hidden, primary = PaneAdaptedValue.Hidden,
secondary = PaneAdaptedValue.Expanded, secondary = PaneAdaptedValue.Expanded,
tertiary = PaneAdaptedValue.Hidden, tertiary = PaneAdaptedValue.Hidden,
) )
isQueryEmpty -> ThreePaneScaffoldValue( } else {
primary = PaneAdaptedValue.Expanded, ThreePaneScaffoldValue(
secondary = PaneAdaptedValue.Hidden,
tertiary = PaneAdaptedValue.Hidden,
)
else -> ThreePaneScaffoldValue(
primary = PaneAdaptedValue.Expanded, primary = PaneAdaptedValue.Expanded,
secondary = PaneAdaptedValue.Expanded, secondary = PaneAdaptedValue.Expanded,
tertiary = PaneAdaptedValue.Hidden, tertiary = PaneAdaptedValue.Hidden,
@@ -5,4 +5,7 @@ sealed interface MainAction {
data object ClearQuery : MainAction data object ClearQuery : MainAction
data object ClearError : MainAction data object ClearError : MainAction
data class SaveScrollPosition(val index: Int, val offset: Int) : MainAction data class SaveScrollPosition(val index: Int, val offset: Int) : MainAction
data class HistoryEntryClicked(val query: String) : MainAction
data class RemoveHistoryEntry(val query: String) : MainAction
data object ClearHistory : MainAction
} }
@@ -1,7 +1,9 @@
package com.example.research.ui.main package com.example.research.ui.main
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
@@ -14,11 +16,13 @@ import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.research.ui.theme.AppWindowInsets import com.example.research.ui.theme.AppWindowInsets
import com.example.research.ui.theme.Spacing
import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.research.core.domain.model.IndexEntry import com.example.research.core.domain.model.IndexEntry
import com.example.research.feature.download.config.DictionaryConfig import com.example.research.feature.download.config.DictionaryConfig
import com.example.research.core.domain.model.SearchBarPosition
import com.example.research.feature.download.model.DownloadState import com.example.research.feature.download.model.DownloadState
import com.example.research.feature.search.SearchAction import com.example.research.feature.search.SearchAction
import com.example.research.feature.search.SearchViewModel import com.example.research.feature.search.SearchViewModel
@@ -36,9 +40,11 @@ fun MainRoute(
val results = searchViewModel.searchResults.collectAsLazyPagingItems() val results = searchViewModel.searchResults.collectAsLazyPagingItems()
val errorMessage by searchViewModel.errorMessage.collectAsStateWithLifecycle() val errorMessage by searchViewModel.errorMessage.collectAsStateWithLifecycle()
val articleState by searchViewModel.selectedArticle.collectAsStateWithLifecycle() val articleState by searchViewModel.selectedArticle.collectAsStateWithLifecycle()
val searchHistory by searchViewModel.searchHistory.collectAsStateWithLifecycle()
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle() val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle() val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle()
val isSearchBarAtBottom = settingsState.searchBarPosition == SearchBarPosition.BOTTOM
val scrollPosition = remember(searchViewModel) { val scrollPosition = remember(searchViewModel) {
searchViewModel.scrollPosition.value searchViewModel.scrollPosition.value
@@ -46,17 +52,6 @@ fun MainRoute(
val searchState = remember(searchViewModel) { val searchState = remember(searchViewModel) {
TextFieldState(initialText = searchViewModel.searchQuery.value) TextFieldState(initialText = searchViewModel.searchQuery.value)
} }
val onMainAction = remember(searchViewModel) {
{ action: MainAction ->
when (action) {
is MainAction.QueryChanged -> searchViewModel.onSearchQueryChanged(action.query)
MainAction.ClearQuery -> searchViewModel.onAction(SearchAction.ClearQuery)
MainAction.ClearError -> searchViewModel.clearError()
is MainAction.SaveScrollPosition ->
searchViewModel.saveScrollPosition(action.index, action.offset)
}
}
}
LaunchedEffect(searchState, searchQuery) { LaunchedEffect(searchState, searchQuery) {
if (searchState.text.toString() != searchQuery) { if (searchState.text.toString() != searchQuery) {
@@ -78,28 +73,48 @@ fun MainRoute(
val showDictionaryName = remember(settingsState) { val showDictionaryName = remember(settingsState) {
val activeDictionaries = settingsState.dictionaries.filter { it.isActive } val activeDictionaries = settingsState.dictionaries.filter { it.isActive }
if (activeDictionaries.isEmpty()) {
false
} else {
activeDictionaries.any { dict -> activeDictionaries.any { dict ->
val fileName = dict.path.substringAfterLast("/") val fileName = dict.path.substringAfterLast("/")
!DictionaryConfig.isDabkrsDictionary(fileName) !DictionaryConfig.isDabkrsDictionary(fileName)
} }
} }
}
BoxWithConstraints { BoxWithConstraints {
val isWide = maxWidth >= 600.dp val isWide = maxWidth >= 600.dp
val isLandscape = maxWidth > maxHeight val isLandscape = maxWidth > maxHeight
val onMainAction = remember(searchViewModel, isWide) {
{ action: MainAction ->
when (action) {
is MainAction.QueryChanged -> searchViewModel.onSearchQueryChanged(action.query)
MainAction.ClearQuery -> searchViewModel.onAction(SearchAction.ClearQuery)
MainAction.ClearError -> searchViewModel.clearError()
is MainAction.SaveScrollPosition ->
searchViewModel.saveScrollPosition(action.index, action.offset)
is MainAction.HistoryEntryClicked ->
if (isWide) {
searchViewModel.onSearchQueryChanged(action.query)
searchViewModel.onAction(SearchAction.SearchAndLoadArticle(action.query))
} else {
searchViewModel.onSearchQueryChanged(action.query)
}
is MainAction.RemoveHistoryEntry ->
searchViewModel.removeSearchHistoryEntry(action.query)
MainAction.ClearHistory -> searchViewModel.clearSearchHistory()
}
}
}
if (isWide) { if (isWide) {
AdaptiveMainScreen( AdaptiveMainScreen(
searchState = searchState, searchState = searchState,
canSearch = canSearch, canSearch = canSearch,
isLandscape = isLandscape, isLandscape = isLandscape,
isSearchBarAtBottom = isSearchBarAtBottom,
results = results, results = results,
errorMessage = errorMessage, errorMessage = errorMessage,
showDictionaryName = showDictionaryName, showDictionaryName = showDictionaryName,
searchHistory = searchHistory,
articleState = articleState, articleState = articleState,
scrollPosition = scrollPosition, scrollPosition = scrollPosition,
onAction = onMainAction, onAction = onMainAction,
@@ -120,9 +135,11 @@ fun MainRoute(
MainScreen( MainScreen(
searchState = searchState, searchState = searchState,
canSearch = canSearch, canSearch = canSearch,
isSearchBarAtBottom = isSearchBarAtBottom,
results = results, results = results,
errorMessage = errorMessage, errorMessage = errorMessage,
showDictionaryName = showDictionaryName, showDictionaryName = showDictionaryName,
searchHistory = searchHistory,
scrollPosition = scrollPosition, scrollPosition = scrollPosition,
onAction = onMainAction, onAction = onMainAction,
onArticleClick = onArticleClick, onArticleClick = onArticleClick,
@@ -132,28 +149,82 @@ fun MainRoute(
} }
} }
@Composable
internal fun MainSearchBar(
searchState: TextFieldState,
canSearch: Boolean,
isSearchBarAtBottom: Boolean,
searchHistory: List<String>,
onAction: (MainAction) -> Unit,
onNavigateToSettings: () -> Unit,
) {
MainTopBar(
searchState = searchState,
enabled = canSearch,
isSearchBarAtBottom = isSearchBarAtBottom,
onQueryChange = { onAction(MainAction.QueryChanged(it)) },
onClearQuery = { onAction(MainAction.ClearQuery) },
searchHistory = searchHistory,
onHistoryEntryClick = { onAction(MainAction.HistoryEntryClicked(it)) },
onHistoryEntryRemove = { onAction(MainAction.RemoveHistoryEntry(it)) },
onClearHistory = { onAction(MainAction.ClearHistory) },
onNavigateToSettings = onNavigateToSettings
)
}
@Composable @Composable
internal fun MainScreen( internal fun MainScreen(
searchState: TextFieldState, searchState: TextFieldState,
canSearch: Boolean, canSearch: Boolean,
isSearchBarAtBottom: Boolean,
results: LazyPagingItems<IndexEntry>, results: LazyPagingItems<IndexEntry>,
errorMessage: String?, errorMessage: String?,
showDictionaryName: Boolean, showDictionaryName: Boolean,
searchHistory: List<String>,
scrollPosition: Pair<Int, Int>, scrollPosition: Pair<Int, Int>,
onAction: (MainAction) -> Unit, onAction: (MainAction) -> Unit,
onArticleClick: (IndexEntry) -> Unit, onArticleClick: (IndexEntry) -> Unit,
onNavigateToSettings: () -> Unit onNavigateToSettings: () -> Unit
) { ) {
Scaffold( val searchBar: @Composable () -> Unit = {
topBar = { MainSearchBar(
MainTopBar(
searchState = searchState, searchState = searchState,
enabled = canSearch, canSearch = canSearch,
onQueryChange = { onAction(MainAction.QueryChanged(it)) }, isSearchBarAtBottom = isSearchBarAtBottom,
onClearQuery = { onAction(MainAction.ClearQuery) }, searchHistory = searchHistory,
onNavigateToSettings = onNavigateToSettings onAction = onAction,
onNavigateToSettings = onNavigateToSettings,
) )
}, }
if (isSearchBarAtBottom) {
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(AppWindowInsets.statusBarsAndIme)
) {
SearchResultsPane(
searchState = searchState,
results = results,
errorMessage = errorMessage,
showDictionaryName = showDictionaryName,
scrollPosition = scrollPosition,
onAction = onAction,
onArticleClick = onArticleClick,
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
.padding(start = Spacing.contentStart, end = 16.dp),
alignFirstResultWithTitleBar = true,
)
searchBar()
}
return
}
Scaffold(
topBar = { searchBar() },
contentWindowInsets = AppWindowInsets.navigationOnly contentWindowInsets = AppWindowInsets.navigationOnly
) { paddingValues -> ) { paddingValues ->
SearchResultsPane( SearchResultsPane(
@@ -169,7 +240,7 @@ internal fun MainScreen(
.padding(paddingValues) .padding(paddingValues)
.consumeWindowInsets(paddingValues) .consumeWindowInsets(paddingValues)
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing) .windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
.padding(start = 32.dp, end = 16.dp), .padding(start = Spacing.contentStart, end = 16.dp),
) )
} }
} }
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -15,7 +16,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.text.rememberTextMeasurer
import com.example.research.common.ui.theme.dictionaryTitleLarge
import com.example.research.ui.theme.Spacing
import androidx.paging.LoadState import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.LazyPagingItems
import com.example.research.core.domain.model.IndexEntry import com.example.research.core.domain.model.IndexEntry
@@ -42,6 +48,7 @@ internal fun SearchResultsPane(
onArticleClick: (IndexEntry) -> Unit, onArticleClick: (IndexEntry) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
listModifier: Modifier = Modifier, listModifier: Modifier = Modifier,
alignFirstResultWithTitleBar: Boolean = false,
) { ) {
val listState = rememberLazyListState( val listState = rememberLazyListState(
initialFirstVisibleItemIndex = scrollPosition.first, initialFirstVisibleItemIndex = scrollPosition.first,
@@ -50,9 +57,11 @@ internal fun SearchResultsPane(
var restoredScrollPosition by remember { mutableStateOf(false) } var restoredScrollPosition by remember { mutableStateOf(false) }
var pendingScrollToTop by remember { mutableStateOf(false) } var pendingScrollToTop by remember { mutableStateOf(false) }
var sawRefreshForPendingScroll by remember { mutableStateOf(false) } var sawRefreshForPendingScroll by remember { mutableStateOf(false) }
var staleEntries by remember { mutableStateOf(emptyList<IndexEntry>()) }
val imeBottomPadding = with(LocalDensity.current) { val imeBottomPadding = with(LocalDensity.current) {
WindowInsets.ime.getBottom(this).toDp() WindowInsets.ime.getBottom(this).toDp()
} }
val topPadding = if (alignFirstResultWithTitleBar) titleBarAlignedTopPadding() else 0.dp
LaunchedEffect(listState) { LaunchedEffect(listState) {
snapshotFlow { snapshotFlow {
@@ -72,8 +81,21 @@ internal fun SearchResultsPane(
restoredScrollPosition = true restoredScrollPosition = true
pendingScrollToTop = query.isNotBlank() pendingScrollToTop = query.isNotBlank()
sawRefreshForPendingScroll = false sawRefreshForPendingScroll = false
if (query.isBlank()) {
staleEntries = emptyList()
} }
} }
}
SideEffect {
val loaded = results.itemSnapshotList.items
if (results.loadState.refresh is LoadState.NotLoading && loaded.isNotEmpty()) {
staleEntries = loaded
}
}
val pendingReload = results.itemCount == 0 &&
results.loadState.refresh is LoadState.Loading
val shownStale = if (pendingReload && searchState.text.isNotBlank()) staleEntries else emptyList()
LaunchedEffect(results.loadState.refresh, pendingScrollToTop) { LaunchedEffect(results.loadState.refresh, pendingScrollToTop) {
if (pendingScrollToTop && results.loadState.refresh is LoadState.Loading) { if (pendingScrollToTop && results.loadState.refresh is LoadState.Loading) {
@@ -124,7 +146,21 @@ internal fun SearchResultsPane(
modifier = listModifier.weight(1f), modifier = listModifier.weight(1f),
listState = listState, listState = listState,
showDictionaryName = showDictionaryName, showDictionaryName = showDictionaryName,
contentPadding = PaddingValues(bottom = imeBottomPadding), contentPadding = PaddingValues(top = topPadding, bottom = imeBottomPadding),
staleEntries = shownStale,
) )
} }
} }
@Composable
private fun titleBarAlignedTopPadding(): Dp {
val textMeasurer = rememberTextMeasurer(cacheSize = 0)
val style = MaterialTheme.typography.dictionaryTitleLarge
val density = LocalDensity.current
return remember(textMeasurer, style, density) {
val titleHeight = with(density) {
textMeasurer.measure(text = "A", style = style, maxLines = 1).size.height.toDp()
}
(Spacing.topBarCenter - Spacing.listItemVertical - titleHeight / 2).coerceAtLeast(0.dp)
}
}
@@ -1,9 +1,16 @@
package com.example.research.ui.main.components package com.example.research.ui.main.components
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
@@ -16,55 +23,108 @@ import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import com.example.research.ui.theme.AppWindowInsets
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.research.R import com.example.research.R
import com.example.research.common.ui.appTestTag
import com.example.research.common.ui.iconClickable
import com.example.research.common.ui.theme.dictionaryTitleLarge import com.example.research.common.ui.theme.dictionaryTitleLarge
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import com.example.research.ui.theme.AppWindowInsets
import com.example.research.ui.theme.Spacing import com.example.research.ui.theme.Spacing
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
import kotlin.time.Duration.Companion.milliseconds
private val TopBarHeight = Spacing.topBarHeight private val TopBarHeight = Spacing.topBarHeight
private val TopBarShape = RoundedCornerShape(28.dp) private val TopBarShape = RoundedCornerShape(28.dp)
private val HistorySheetChrome = 64.dp
private val HistorySheetMinHeight = 300.dp
private val HistorySheetBottomPadding = 16.dp
@Composable @Composable
fun MainTopBar( fun MainTopBar(
searchState: TextFieldState, searchState: TextFieldState,
enabled: Boolean, enabled: Boolean,
isSearchBarAtBottom: Boolean = false,
onQueryChange: (String) -> Unit, onQueryChange: (String) -> Unit,
onClearQuery: () -> Unit, onClearQuery: () -> Unit,
searchHistory: List<String>,
onHistoryEntryClick: (String) -> Unit,
onHistoryEntryRemove: (String) -> Unit,
onClearHistory: () -> Unit,
onNavigateToSettings: () -> Unit onNavigateToSettings: () -> Unit
) { ) {
val searchIcon = painterResource(R.drawable.ic_search)
val closeIcon = painterResource(R.drawable.ic_close) val closeIcon = painterResource(R.drawable.ic_close)
val historyIcon = painterResource(R.drawable.ic_manage_search)
val settingsIcon = painterResource(R.drawable.ic_settings) val settingsIcon = painterResource(R.drawable.ic_settings)
val clearLabel = stringResource(R.string.clear_search) val clearLabel = stringResource(R.string.clear_search)
val historyLabel = stringResource(R.string.recent_searches)
val focusManager = LocalFocusManager.current
var showHistorySheet by remember { mutableStateOf(false) }
var topBarBottomPx by remember { mutableIntStateOf(0) }
val coroutineScope = rememberCoroutineScope()
val density = LocalDensity.current
val imeInsets = WindowInsets.ime
Surface( Surface(
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth() modifier = Modifier
.fillMaxWidth()
.onGloballyPositioned { topBarBottomPx = it.boundsInWindow().bottom.roundToInt() }
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.windowInsetsPadding(AppWindowInsets.topAndHorizontalSafeDrawing) .windowInsetsPadding(
if (isSearchBarAtBottom) {
AppWindowInsets.horizontalSafeDrawing
} else {
AppWindowInsets.topAndHorizontalSafeDrawing
}
)
.then(
if (isSearchBarAtBottom) {
Modifier.windowInsetsPadding(AppWindowInsets.bottomNavigation)
} else {
Modifier
}
)
.padding(start = 16.dp, top = 8.dp, bottom = 8.dp, end = 16.dp), .padding(start = 16.dp, top = 8.dp, bottom = 8.dp, end = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
@@ -74,9 +134,21 @@ fun MainTopBar(
enabled = enabled, enabled = enabled,
onQueryChange = onQueryChange, onQueryChange = onQueryChange,
onClearQuery = onClearQuery, onClearQuery = onClearQuery,
searchIcon = searchIcon,
closeIcon = closeIcon, closeIcon = closeIcon,
clearLabel = clearLabel, clearLabel = clearLabel,
historyIcon = historyIcon,
historyLabel = historyLabel,
historyEnabled = searchHistory.isNotEmpty(),
onShowHistory = {
focusManager.clearFocus(force = true)
coroutineScope.launch {
withTimeoutOrNull(350.milliseconds) {
snapshotFlow { imeInsets.getBottom(density) > 0 }
.first { visible -> !visible }
}
showHistorySheet = !showHistorySheet
}
},
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
@@ -99,18 +171,39 @@ fun MainTopBar(
} }
} }
} }
if (showHistorySheet && searchHistory.isNotEmpty()) {
SearchHistorySheet(
history = searchHistory,
isSearchBarAtBottom = isSearchBarAtBottom,
topBarBottomPx = topBarBottomPx,
onDismiss = { showHistorySheet = false },
onEntryClick = { entry ->
focusManager.clearFocus(force = true)
showHistorySheet = false
onHistoryEntryClick(entry)
},
onEntryRemove = onHistoryEntryRemove,
onClearAll = {
showHistorySheet = false
onClearHistory()
}
)
}
} }
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun SearchField( private fun SearchField(
searchState: TextFieldState, searchState: TextFieldState,
enabled: Boolean, enabled: Boolean,
onQueryChange: (String) -> Unit, onQueryChange: (String) -> Unit,
onClearQuery: () -> Unit, onClearQuery: () -> Unit,
searchIcon: androidx.compose.ui.graphics.painter.Painter, closeIcon: Painter,
closeIcon: androidx.compose.ui.graphics.painter.Painter,
clearLabel: String, clearLabel: String,
historyIcon: Painter,
historyLabel: String,
historyEnabled: Boolean,
onShowHistory: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val containerColor = MaterialTheme.colorScheme.surfaceContainerHighest val containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
@@ -133,9 +226,16 @@ private fun SearchField(
lineLimits = TextFieldLineLimits.SingleLine, lineLimits = TextFieldLineLimits.SingleLine,
leadingIcon = { leadingIcon = {
Icon( Icon(
painter = searchIcon, painter = historyIcon,
contentDescription = null, contentDescription = historyLabel,
modifier = Modifier.size(24.dp) tint = LocalContentColor.current.copy(
alpha = if (historyEnabled) 1f else 0.38f
),
modifier = Modifier
.offset(x = 4.dp)
.size(28.dp)
.appTestTag("history_button")
.iconClickable(enabled = historyEnabled, onClick = onShowHistory)
) )
}, },
trailingIcon = if (searchState.text.isNotEmpty()) { trailingIcon = if (searchState.text.isNotEmpty()) {
@@ -146,6 +246,7 @@ private fun SearchField(
onClearQuery() onClearQuery()
}, },
modifier = Modifier modifier = Modifier
.padding(end = 12.dp)
.testTag("clear_search") .testTag("clear_search")
.semantics { testTagsAsResourceId = true } .semantics { testTagsAsResourceId = true }
) { ) {
@@ -179,3 +280,52 @@ private fun SearchField(
.semantics { testTagsAsResourceId = true } .semantics { testTagsAsResourceId = true }
) )
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SearchHistorySheet(
history: List<String>,
isSearchBarAtBottom: Boolean,
topBarBottomPx: Int,
onDismiss: () -> Unit,
onEntryClick: (String) -> Unit,
onEntryRemove: (String) -> Unit,
onClearAll: () -> Unit
) {
val sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
)
val density = LocalDensity.current
val windowHeightPx = LocalWindowInfo.current.containerSize.height
val navigationBarsBottom = WindowInsets.navigationBars.getBottom(density)
val maxContentHeight = remember {
with(density) {
val screenHeight = windowHeightPx.toDp()
if (isSearchBarAtBottom) {
screenHeight - HistorySheetChrome - navigationBarsBottom.toDp()
} else {
screenHeight - topBarBottomPx.toDp()
}
}
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
contentWindowInsets = { WindowInsets(0, 0, 0, 0) },
modifier = Modifier.testTag("search_history_sheet")
) {
SearchHistoryList(
history = history,
onEntryClick = onEntryClick,
onEntryRemove = onEntryRemove,
onClearAll = onClearAll,
contentPadding = PaddingValues(bottom = HistorySheetBottomPadding),
modifier = Modifier
.fillMaxWidth()
.heightIn(min = HistorySheetMinHeight, max = maxContentHeight)
.padding(horizontal = 24.dp)
)
}
}
@@ -0,0 +1,138 @@
package com.example.research.ui.main.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.example.research.R
import com.example.research.common.ui.appTestTag
import com.example.research.common.ui.iconClickable
import com.example.research.common.ui.theme.dictionaryTitleLarge
import com.example.research.ui.theme.Spacing
@Composable
fun SearchHistoryList(
history: List<String>,
onEntryClick: (String) -> Unit,
onEntryRemove: (String) -> Unit,
onClearAll: () -> Unit,
modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
contentPadding: PaddingValues = PaddingValues(),
) {
LazyColumn(
modifier = modifier
.appTestTag("search_history"),
state = listState,
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
item(key = "history_header", contentType = "history_header") {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = Spacing.listItemVertical),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.recent_searches),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Text(
text = stringResource(R.string.clear_search_history),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
softWrap = false,
modifier = Modifier
.padding(end = 18.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.appTestTag("clear_history")
.clickable { onClearAll() }
.padding(horizontal = 12.dp, vertical = 6.dp)
)
}
}
items(
items = history,
key = { it },
contentType = { "history_entry" }
) { entry ->
SearchHistoryItem(
entry = entry,
onEntryClick = onEntryClick,
onEntryRemove = onEntryRemove
)
}
}
}
@Composable
private fun SearchHistoryItem(
entry: String,
onEntryClick: (String) -> Unit,
onEntryRemove: (String) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.appTestTag("search_history_entry")
.clickable { onEntryClick(entry) }
.padding(vertical = Spacing.listItemVertical),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(R.drawable.ic_history),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(end = 16.dp)
.size(20.dp)
)
Text(
text = entry,
style = MaterialTheme.typography.dictionaryTitleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.width(Spacing.topBarHeight)
) {
Icon(
painter = painterResource(R.drawable.ic_close),
contentDescription = stringResource(R.string.remove_search_history_entry),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.appTestTag("remove_history_entry")
.iconClickable { onEntryRemove(entry) }
.size(20.dp)
)
}
}
}
@@ -6,17 +6,13 @@ import androidx.compose.foundation.lazy.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.* import androidx.paging.compose.*
import com.example.research.R
import com.example.research.common.ui.theme.dictionaryTitleLarge import com.example.research.common.ui.theme.dictionaryTitleLarge
import com.example.research.core.domain.model.IndexEntry import com.example.research.core.domain.model.IndexEntry
import com.example.research.core.util.removeDictionarySuffixes import com.example.research.core.util.removeDictionarySuffixes
@@ -29,8 +25,11 @@ fun SearchResultsList(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(), listState: LazyListState = rememberLazyListState(),
showDictionaryName: Boolean = true, showDictionaryName: Boolean = true,
contentPadding: PaddingValues = PaddingValues() contentPadding: PaddingValues = PaddingValues(),
staleEntries: List<IndexEntry> = emptyList(),
) { ) {
val showStale = staleEntries.isNotEmpty() && results.itemCount == 0
val rowCount = if (showStale) staleEntries.size else results.itemCount
LazyColumn( LazyColumn(
modifier = modifier modifier = modifier
.testTag("search_results") .testTag("search_results")
@@ -40,11 +39,18 @@ fun SearchResultsList(
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
items( items(
count = results.itemCount, count = rowCount,
key = results.itemKey { entry -> "${entry.dictionaryPath}_${entry.offset.value}_${entry.word}" }, key = { index ->
contentType = results.itemContentType { "search_result" } val entry = if (showStale) staleEntries.getOrNull(index)
else if (index in 0 until results.itemCount) results.peek(index) else null
entry?.let { "${it.dictionaryPath}:${it.offset.value}:${it.word}" }
?: "search_result_$index"
},
contentType = { "search_result" }
) { index -> ) { index ->
results[index]?.let { entry -> val entry = if (showStale) staleEntries.getOrNull(index)
else if (index in 0 until results.itemCount) results[index] else null
if (entry != null) {
SearchResultItem( SearchResultItem(
entry = entry, entry = entry,
resultIndex = index, resultIndex = index,
@@ -53,26 +59,6 @@ fun SearchResultsList(
) )
} }
} }
when (results.loadState.append) {
is LoadState.Error -> {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(R.string.error_loading_more),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
}
}
}
else -> {}
}
} }
} }
@@ -1,23 +1,32 @@
package com.example.research.ui.navigation package com.example.research.ui.navigation
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.animation.AnimatedContent
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.* import androidx.compose.runtime.saveable.*
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND
import com.example.research.feature.search.SearchAction import com.example.research.feature.search.SearchAction
import com.example.research.feature.search.SearchViewModel import com.example.research.feature.search.SearchViewModel
import com.example.research.feature.download.model.DownloadState
import com.example.research.ui.about.AboutScreen import com.example.research.ui.about.AboutScreen
import com.example.research.ui.article.ArticleRoute import com.example.research.ui.article.ArticleScreen
import com.example.research.ui.article.ArticleState
import com.example.research.ui.main.MainRoute import com.example.research.ui.main.MainRoute
import com.example.research.ui.settings.ImportState
import com.example.research.ui.settings.SettingsRoute import com.example.research.ui.settings.SettingsRoute
import com.example.research.ui.settings.SettingsViewModel import com.example.research.ui.settings.SettingsViewModel
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds
private data class NavigationState( private data class NavigationState(
val shouldShowSettings: Boolean, val shouldShowSettings: Boolean,
@@ -29,11 +38,11 @@ enum class Screen {
Home, Settings, Article, About Home, Settings, Article, About
} }
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable @Composable
fun AppNavigation( fun AppNavigation(
searchViewModel: SearchViewModel, searchViewModel: SearchViewModel,
settingsViewModel: SettingsViewModel settingsViewModel: SettingsViewModel,
seedNoDictionaries: Boolean = false
) { ) {
val screenStack = rememberSaveable( val screenStack = rememberSaveable(
saver = listSaver( saver = listSaver(
@@ -53,39 +62,15 @@ fun AppNavigation(
val currentScreen = screenStack.lastOrNull() ?: Screen.Home val currentScreen = screenStack.lastOrNull() ?: Screen.Home
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle() val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
val progressSnapshot by settingsViewModel.progressSnapshot.collectAsStateWithLifecycle()
val isPipelineActiveByState = remember(
progressSnapshot,
settingsState.indexingProgress.isIndexing,
settingsState.downloadState,
settingsState.importState,
) {
progressSnapshot != null ||
settingsState.indexingProgress.isIndexing ||
settingsState.downloadState is DownloadState.Loading ||
settingsState.downloadState is DownloadState.Extracting ||
settingsState.downloadState is DownloadState.Success ||
settingsState.importState is ImportState.Importing ||
settingsState.importState is ImportState.Extracting ||
settingsState.importState is ImportState.Success
}
var stickyPipelineActive by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(isPipelineActiveByState) {
if (isPipelineActiveByState) {
stickyPipelineActive = true
} else {
delay(1500.milliseconds)
stickyPipelineActive = false
}
}
val navState by remember { val navState by remember {
derivedStateOf { derivedStateOf {
val isOperationActive = stickyPipelineActive val isOperationActive = !settingsState.pipelineIdle
val hasConfirmedNoDictionaries = val hasConfirmedNoDictionaries =
settingsState.hasCompletedStartupScan && settingsState.dictionaries.isEmpty() settingsState.hasCompletedStartupScan && settingsState.dictionaries.isEmpty()
val hasSeededNoDictionaries = seedNoDictionaries && !settingsState.hasCompletedStartupScan
NavigationState( NavigationState(
shouldShowSettings = hasConfirmedNoDictionaries || isOperationActive, shouldShowSettings = hasConfirmedNoDictionaries || hasSeededNoDictionaries || isOperationActive,
showBackButtonInSettings = settingsState.dictionaries.isNotEmpty() && !isOperationActive, showBackButtonInSettings = settingsState.dictionaries.isNotEmpty() && !isOperationActive,
isOperationActive = isOperationActive, isOperationActive = isOperationActive,
) )
@@ -95,10 +80,16 @@ fun AppNavigation(
val shouldShowSettings = navState.shouldShowSettings val shouldShowSettings = navState.shouldShowSettings
val showBackButtonInSettings = navState.showBackButtonInSettings val showBackButtonInSettings = navState.showBackButtonInSettings
val isOperationActive = navState.isOperationActive val isOperationActive = navState.isOperationActive
val isWideScreen = currentWindowAdaptiveInfo() val isWideScreen = currentWindowAdaptiveInfoV2()
.windowSizeClass .windowSizeClass
.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND) .isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)
LaunchedEffect(isWideScreen) {
if (isWideScreen) {
screenStack.removeAll { it == Screen.Article }
}
}
BackHandler(enabled = screenStack.size > 1 && !(currentScreen == Screen.Settings && isOperationActive)) { BackHandler(enabled = screenStack.size > 1 && !(currentScreen == Screen.Settings && isOperationActive)) {
if (currentScreen == Screen.Article) { if (currentScreen == Screen.Article) {
if (searchViewModel.navigateBackFromArticle()) { if (searchViewModel.navigateBackFromArticle()) {
@@ -112,14 +103,33 @@ fun AppNavigation(
} }
val articleState by searchViewModel.selectedArticle.collectAsStateWithLifecycle() val articleState by searchViewModel.selectedArticle.collectAsStateWithLifecycle()
var lastShownArticle by remember { mutableStateOf<ArticleState?>(null) }
LaunchedEffect(articleState) {
if (articleState != null) lastShownArticle = articleState
}
val effectiveScreen = when { val effectiveScreen = when {
currentScreen == Screen.About -> Screen.About currentScreen == Screen.About -> Screen.About
currentScreen == Screen.Article && articleState == null -> if (shouldShowSettings) Screen.Settings else Screen.Home
shouldShowSettings -> Screen.Settings shouldShowSettings -> Screen.Settings
else -> currentScreen currentScreen == Screen.Settings -> Screen.Settings
else -> Screen.Home
} }
when (effectiveScreen) { Box {
AnimatedContent(
targetState = effectiveScreen,
transitionSpec = {
val forward = initialState.ordinal < targetState.ordinal
(
fadeIn(tween(180)) +
slideInHorizontally(tween(180)) { if (forward) it / 3 else -it / 3 }
).togetherWith(
fadeOut(tween(180)) +
slideOutHorizontally(tween(180)) { if (forward) -it / 3 else it / 3 }
)
},
label = "screenTransition",
) { screen ->
when (screen) {
Screen.Home -> { Screen.Home -> {
MainRoute( MainRoute(
searchViewModel = searchViewModel, searchViewModel = searchViewModel,
@@ -150,18 +160,6 @@ fun AppNavigation(
onNavigateToAbout = { screenStack.add(Screen.About) } onNavigateToAbout = { screenStack.add(Screen.About) }
) )
} }
Screen.Article -> {
ArticleRoute(
searchViewModel = searchViewModel,
onNavigateBack = {
if (searchViewModel.navigateBackFromArticle()) {
return@ArticleRoute
}
searchViewModel.onAction(SearchAction.ClearArticle)
if (screenStack.size > 1) screenStack.removeAt(screenStack.lastIndex)
}
)
}
Screen.About -> { Screen.About -> {
AboutScreen( AboutScreen(
onNavigateBack = { onNavigateBack = {
@@ -169,5 +167,36 @@ fun AppNavigation(
} }
) )
} }
Screen.Article -> {}
}
}
AnimatedVisibility(
visible = currentScreen == Screen.Article && articleState != null,
enter = fadeIn(tween(180)) + slideInHorizontally(tween(180)) { it / 3 },
exit = fadeOut(tween(180)) + slideOutHorizontally(tween(180)) { it / 3 },
label = "articleOverlay",
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface
) {
ArticleScreen(
articleState = articleState ?: lastShownArticle,
onNavigateBack = {
if (!searchViewModel.navigateBackFromArticle()) {
searchViewModel.onAction(SearchAction.ClearArticle)
if (screenStack.size > 1) screenStack.removeAt(screenStack.lastIndex)
}
},
onRefClick = { word ->
searchViewModel.onAction(SearchAction.SearchAndLoadArticle(word))
},
onScrollPositionChanged = { index, offset ->
searchViewModel.updateArticleScrollPosition(index, offset)
},
)
}
}
} }
} }
@@ -1,5 +1,8 @@
package com.example.research.ui.settings package com.example.research.ui.settings
import androidx.compose.runtime.Immutable
@Immutable
sealed interface ImportState { sealed interface ImportState {
data object Idle : ImportState data object Idle : ImportState
data class Importing(val progress: Float) : ImportState data class Importing(val progress: Float) : ImportState
@@ -7,3 +10,6 @@ sealed interface ImportState {
data object Success : ImportState data object Success : ImportState
data class Error(val message: String) : ImportState data class Error(val message: String) : ImportState
} }
val ImportState.isActive: Boolean
get() = this is ImportState.Importing || this is ImportState.Extracting || this is ImportState.Success
@@ -3,6 +3,11 @@ package com.example.research.ui.settings
import android.net.Uri import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -14,9 +19,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
@@ -41,17 +44,18 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import com.example.research.R import com.example.research.R
import com.example.research.common.ui.AppBackButton
import com.example.research.common.ui.appTestTag import com.example.research.common.ui.appTestTag
import com.example.research.common.ui.components.CollapsibleHeader import com.example.research.common.ui.components.CollapsibleHeader
import com.example.research.common.ui.components.SectionCard import com.example.research.common.ui.components.SectionCard
import com.example.research.ui.settings.components.DictionaryManagement import com.example.research.ui.settings.components.DictionaryManagement
import com.example.research.ui.settings.components.LanguageSelectionSection import com.example.research.ui.settings.components.LanguageSelectionSection
import com.example.research.ui.settings.components.SearchBarPositionSection
import com.example.research.ui.settings.components.ThemeSelectionSection import com.example.research.ui.settings.components.ThemeSelectionSection
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SettingsRoute( fun SettingsRoute(
viewModel: SettingsViewModel, viewModel: SettingsViewModel,
@@ -99,7 +103,6 @@ fun SettingsRoute(
) )
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
uiState: SettingsUiState, uiState: SettingsUiState,
@@ -121,13 +124,11 @@ fun SettingsScreen(
title = { Text(stringResource(R.string.settings_title)) }, title = { Text(stringResource(R.string.settings_title)) },
navigationIcon = { navigationIcon = {
if (showBackButton) { if (showBackButton) {
IconButton(onClick = onNavigateBack) { AppBackButton(
Icon( onClick = onNavigateBack,
painter = painterResource(R.drawable.ic_arrow_back), modifier = Modifier.padding(start = 6.dp)
contentDescription = stringResource(R.string.action_back)
) )
} }
}
}, },
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface containerColor = MaterialTheme.colorScheme.surface
@@ -235,6 +236,24 @@ fun SettingsScreen(
} }
} }
item(key = "search_bar", contentType = "settings-section") {
CollapsibleSettingsSection(
title = stringResource(R.string.search_bar_position),
iconRes = R.drawable.ic_search,
testTag = "search_bar_section",
isExpanded = uiState.isSearchBarExpanded,
onToggleExpanded = { onEvent(SettingsUiEvent.SectionToggled("search_bar", !uiState.isSearchBarExpanded)) },
) {
SearchBarPositionSection(
currentPosition = uiState.searchBarPosition,
onPositionChanged = { position ->
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onEvent(SettingsUiEvent.SearchBarPositionChanged(position))
}
)
}
}
item(key = "bottom-spacer", contentType = "spacer") { item(key = "bottom-spacer", contentType = "spacer") {
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
} }
@@ -266,7 +285,11 @@ private fun CollapsibleSettingsSection(
testTag = testTag testTag = testTag
) )
if (isExpanded) { AnimatedVisibility(
visible = isExpanded,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -1,18 +1,23 @@
package com.example.research.ui.settings package com.example.research.ui.settings
import android.net.Uri import android.net.Uri
import androidx.compose.runtime.Immutable
import com.example.research.core.domain.model.AppTheme import com.example.research.core.domain.model.AppTheme
import com.example.research.core.domain.model.SearchBarPosition
import com.example.research.core.domain.model.Dictionary import com.example.research.core.domain.model.Dictionary
import com.example.research.core.domain.model.DictionarySource import com.example.research.core.domain.model.DictionarySource
import com.example.research.core.domain.model.IndexingProgress import com.example.research.core.domain.model.IndexingProgress
import com.example.research.DictionaryStatus import com.example.research.DictionaryStatus
import com.example.research.feature.download.model.DownloadState import com.example.research.feature.download.model.DownloadState
@Immutable
data class SettingsUiState( data class SettingsUiState(
val theme: AppTheme = AppTheme.SYSTEM, val theme: AppTheme = AppTheme.SYSTEM,
val language: String = "system", val language: String = "system",
val searchBarPosition: SearchBarPosition = SearchBarPosition.TOP,
val isThemeExpanded: Boolean = false, val isThemeExpanded: Boolean = false,
val isLanguageExpanded: Boolean = false, val isLanguageExpanded: Boolean = false,
val isSearchBarExpanded: Boolean = false,
val isDictionariesExpanded: Boolean = true, val isDictionariesExpanded: Boolean = true,
val dictionaries: List<Dictionary> = emptyList(), val dictionaries: List<Dictionary> = emptyList(),
val dictionaryStatus: DictionaryStatus = DictionaryStatus.Unknown, val dictionaryStatus: DictionaryStatus = DictionaryStatus.Unknown,
@@ -21,12 +26,14 @@ data class SettingsUiState(
val importState: ImportState = ImportState.Idle, val importState: ImportState = ImportState.Idle,
val dictionarySources: List<DictionarySource> = emptyList(), val dictionarySources: List<DictionarySource> = emptyList(),
val hasCompletedStartupScan: Boolean = false, val hasCompletedStartupScan: Boolean = false,
val pipelineIdle: Boolean = true,
val appVersion: String = "1.0", val appVersion: String = "1.0",
) )
sealed interface SettingsUiEvent { sealed interface SettingsUiEvent {
data class ThemeChanged(val theme: AppTheme) : SettingsUiEvent data class ThemeChanged(val theme: AppTheme) : SettingsUiEvent
data class LanguageChanged(val language: String) : SettingsUiEvent data class LanguageChanged(val language: String) : SettingsUiEvent
data class SearchBarPositionChanged(val position: SearchBarPosition) : SettingsUiEvent
data class SectionToggled(val key: String, val isExpanded: Boolean) : SettingsUiEvent data class SectionToggled(val key: String, val isExpanded: Boolean) : SettingsUiEvent
data class DictionaryToggled(val path: String) : SettingsUiEvent data class DictionaryToggled(val path: String) : SettingsUiEvent
data class DictionaryDeleted(val dictionary: Dictionary) : SettingsUiEvent data class DictionaryDeleted(val dictionary: Dictionary) : SettingsUiEvent
@@ -7,17 +7,22 @@ import androidx.lifecycle.viewModelScope
import com.example.research.DictionaryStatus import com.example.research.DictionaryStatus
import com.example.research.R import com.example.research.R
import com.example.research.common.progress.DictionaryProgressStateHolder import com.example.research.common.progress.DictionaryProgressStateHolder
import com.example.research.common.util.DateUtils
import com.example.research.core.domain.model.AppTheme import com.example.research.core.domain.model.AppTheme
import com.example.research.core.domain.model.SearchBarPosition
import com.example.research.core.domain.model.Dictionary import com.example.research.core.domain.model.Dictionary
import com.example.research.core.domain.model.DictionarySource import com.example.research.core.domain.model.DictionarySource
import com.example.research.core.domain.model.IndexingProgress import com.example.research.core.domain.model.IndexingProgress
import com.example.research.core.domain.usecase.DictionarySourceFileMatcher
import com.example.research.core.domain.usecase.DictionarySourceValidator import com.example.research.core.domain.usecase.DictionarySourceValidator
import com.example.research.core.domain.usecase.ManageDictionarySourcesUseCase import com.example.research.core.domain.usecase.ManageDictionarySourcesUseCase
import com.example.research.core.util.OperationResult import com.example.research.core.util.OperationResult
import com.example.research.data.local.preferences.PreferencesManager import com.example.research.data.local.preferences.PreferencesManager
import com.example.research.data.local.preferences.SectionExpansionStates
import com.example.research.data.repository.LocalDictionaryRepository import com.example.research.data.repository.LocalDictionaryRepository
import com.example.research.feature.download.DownloadManager import com.example.research.feature.download.DownloadManager
import com.example.research.feature.download.model.DownloadState import com.example.research.feature.download.model.DownloadState
import com.example.research.feature.download.model.isActive
import com.example.research.feature.download.repository.DictionaryRepository import com.example.research.feature.download.repository.DictionaryRepository
import com.example.research.feature.import.DictionaryImportManager import com.example.research.feature.import.DictionaryImportManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -30,7 +35,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.util.concurrent.ConcurrentHashMap
private data class DictionaryStateInputs( private data class DictionaryStateInputs(
val theme: AppTheme, val theme: AppTheme,
@@ -42,11 +47,13 @@ private data class DictionaryStateInputs(
private data class OperationStateInputs( private data class OperationStateInputs(
val downloadState: DownloadState, val downloadState: DownloadState,
val language: String, val language: String,
val searchBarPosition: SearchBarPosition,
val importState: ImportState, val importState: ImportState,
val dictionaryStatus: DictionaryStatus, val dictionaryStatus: DictionaryStatus,
val hasCompletedStartupScan: Boolean, val hasCompletedStartupScan: Boolean,
val isThemeExpanded: Boolean, val isThemeExpanded: Boolean,
val isLanguageExpanded: Boolean, val isLanguageExpanded: Boolean,
val isSearchBarExpanded: Boolean,
val isDictionariesExpanded: Boolean, val isDictionariesExpanded: Boolean,
) )
@@ -59,6 +66,8 @@ class SettingsViewModel(
private val dictionaryImportManager: DictionaryImportManager, private val dictionaryImportManager: DictionaryImportManager,
progressStateHolder: DictionaryProgressStateHolder, progressStateHolder: DictionaryProgressStateHolder,
appVersion: String, appVersion: String,
initialSectionExpansion: SectionExpansionStates,
initialSearchBarPosition: SearchBarPosition,
) : AndroidViewModel(application) { ) : AndroidViewModel(application) {
private val manageDictionarySourcesUseCase = ManageDictionarySourcesUseCase( private val manageDictionarySourcesUseCase = ManageDictionarySourcesUseCase(
@@ -66,7 +75,16 @@ class SettingsViewModel(
validator = DictionarySourceValidator(), validator = DictionarySourceValidator(),
) )
private val _uiState = MutableStateFlow(SettingsUiState(appVersion = appVersion)) private val _uiState = MutableStateFlow(
SettingsUiState(
appVersion = appVersion,
isThemeExpanded = initialSectionExpansion.isThemeExpanded,
isLanguageExpanded = initialSectionExpansion.isLanguageExpanded,
isSearchBarExpanded = initialSectionExpansion.isSearchBarExpanded,
isDictionariesExpanded = initialSectionExpansion.isDictionariesExpanded,
searchBarPosition = initialSearchBarPosition,
)
)
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow() val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
val progressSnapshot = progressStateHolder.progressSnapshot val progressSnapshot = progressStateHolder.progressSnapshot
@@ -79,20 +97,49 @@ class SettingsViewModel(
private val language = MutableStateFlow("system") private val language = MutableStateFlow("system")
private val hasCompletedStartupScan = MutableStateFlow(false) private val hasCompletedStartupScan = MutableStateFlow(false)
private val pendingSourceUrls = mutableSetOf<String>() private val pendingSourceUrls = ConcurrentHashMap.newKeySet<String>()
private var isDownloadInProgress = false private var isDownloadInProgress = false
private var cancelRefreshPending = false private var cancelRefreshPending = false
private var errorStateHandled = false
private var successStateHandled = false
private var statusBeforeDownload: DictionaryStatus = DictionaryStatus.Unknown
init { init {
setupStateObservation() setupStateObservation()
observeDictionariesForSeedFlag()
persistDictionaryStatus()
initialize() initialize()
} }
private fun persistDictionaryStatus() {
viewModelScope.launch {
dictionaryStatus.collect { status ->
val persistedName = when (status) {
DictionaryStatus.UpToDate -> "UpToDate"
is DictionaryStatus.NeedsUpdate -> "NeedsUpdate"
DictionaryStatus.Empty -> "Empty"
else -> null
}
if (persistedName != null) {
preferencesManager.saveDictionaryStatus("$persistedName|${DateUtils.getCurrentDateString()}")
}
}
}
}
private fun observeDictionariesForSeedFlag() {
viewModelScope.launch {
localDictionaryRepository.dictionaries.collect { dictionaries ->
if (hasCompletedStartupScan.value) {
preferencesManager.setHadNoDictionaries(dictionaries.isEmpty())
}
}
}
}
private fun setupStateObservation() { private fun setupStateObservation() {
viewModelScope.launch { viewModelScope.launch {
var wasIndexing = false
val dictionaryStateInputs = combine( val dictionaryStateInputs = combine(
preferencesManager.themeMode.map { mode -> preferencesManager.themeMode.map { mode ->
try { AppTheme.valueOf(mode) } catch (_: Exception) { AppTheme.SYSTEM } try { AppTheme.valueOf(mode) } catch (_: Exception) { AppTheme.SYSTEM }
@@ -111,66 +158,68 @@ class SettingsViewModel(
val operationStateInputs = combine( val operationStateInputs = combine(
downloadManager.downloadState, downloadManager.downloadState,
language, language,
preferencesManager.searchBarPosition,
dictionaryImportManager.importState, dictionaryImportManager.importState,
dictionaryStatus, dictionaryStatus,
hasCompletedStartupScan, hasCompletedStartupScan,
preferencesManager.isThemeExpanded, preferencesManager.isThemeExpanded,
preferencesManager.isLanguageExpanded, preferencesManager.isLanguageExpanded,
preferencesManager.isSearchBarExpanded,
preferencesManager.isDictionariesExpanded, preferencesManager.isDictionariesExpanded,
) { params: Array<Any> -> ) { params: Array<Any> ->
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
OperationStateInputs( OperationStateInputs(
downloadState = params[0] as DownloadState, downloadState = params[0] as DownloadState,
language = params[1] as String, language = params[1] as String,
importState = params[2] as ImportState, searchBarPosition = params[2] as SearchBarPosition,
dictionaryStatus = params[3] as DictionaryStatus, importState = params[3] as ImportState,
hasCompletedStartupScan = params[4] as Boolean, dictionaryStatus = params[4] as DictionaryStatus,
isThemeExpanded = params[5] as Boolean, hasCompletedStartupScan = params[5] as Boolean,
isLanguageExpanded = params[6] as Boolean, isThemeExpanded = params[6] as Boolean,
isDictionariesExpanded = params[7] as Boolean, isLanguageExpanded = params[7] as Boolean,
isSearchBarExpanded = params[8] as Boolean,
isDictionariesExpanded = params[9] as Boolean,
) )
} }
combine(dictionaryStateInputs, operationStateInputs) { dictionaryState, operationState -> combine(dictionaryStateInputs, operationStateInputs) { dictionaryState, operationState ->
// Handle indexing completion side effect
val isIndexing = dictionaryState.indexingProgress.isIndexing val isIndexing = dictionaryState.indexingProgress.isIndexing
if (wasIndexing && !isIndexing) {
if (isDownloadInProgress) {
isDownloadInProgress = false
dictionaryStatus.value = DictionaryStatus.UpToDate
}
}
wasIndexing = isIndexing
when (operationState.downloadState) { when (operationState.downloadState) {
is DownloadState.Loading, is DownloadState.Extracting -> { is DownloadState.Loading, is DownloadState.Extracting -> {
isDownloadInProgress = true isDownloadInProgress = true
cancelRefreshPending = false cancelRefreshPending = false
errorStateHandled = false
successStateHandled = false
} }
is DownloadState.Success -> { is DownloadState.Success -> {
pendingSourceUrls.clear() pendingSourceUrls.clear()
isDownloadInProgress = false
if (!successStateHandled) {
successStateHandled = true
dictionaryStatus.value = withContext(Dispatchers.IO) {
evaluateDictionaryStatus()
}
}
} }
is DownloadState.Error -> { is DownloadState.Error -> {
isDownloadInProgress = false isDownloadInProgress = false
if (!errorStateHandled) {
errorStateHandled = true
dictionaryStatus.value = if (dictionaryState.dictionaries.isEmpty()) { dictionaryStatus.value = if (dictionaryState.dictionaries.isEmpty()) {
DictionaryStatus.Empty DictionaryStatus.Empty
} else { } else when (statusBeforeDownload) {
DictionaryStatus.UpToDate DictionaryStatus.Unknown, DictionaryStatus.Checking -> DictionaryStatus.UpToDate
else -> statusBeforeDownload
} }
if (pendingSourceUrls.isNotEmpty()) { if (pendingSourceUrls.isNotEmpty()) {
pendingSourceUrls.forEach { urlTemplate -> pendingSourceUrls.forEach { urlTemplate ->
val source = dictionaryState.dictionarySources.find { val source = dictionaryState.dictionarySources.find {
it.urlTemplate == urlTemplate it.urlTemplate == urlTemplate
} }
if (source != null) { if (source != null) {
val hasDictionary = dictionaryState.dictionaries.any { dict -> val hasDictionary = dictionaryState.dictionaries.any { dictionary ->
DictionarySource.matchesDictionaryFile( DictionarySourceFileMatcher.matches(source, dictionary)
urlTemplate,
File(dict.path).name
)
} }
if (!hasDictionary) { if (!hasDictionary) {
manageDictionarySourcesUseCase.removeSource(source.id) manageDictionarySourcesUseCase.removeSource(source.id)
} }
@@ -179,6 +228,7 @@ class SettingsViewModel(
pendingSourceUrls.clear() pendingSourceUrls.clear()
} }
} }
}
is DownloadState.Cancelled -> { is DownloadState.Cancelled -> {
isDownloadInProgress = false isDownloadInProgress = false
cancelRefreshPending = true cancelRefreshPending = true
@@ -198,7 +248,6 @@ class SettingsViewModel(
refreshDictionaryStatus() refreshDictionaryStatus()
} }
} }
else -> {}
} }
SettingsUiState( SettingsUiState(
@@ -208,11 +257,16 @@ class SettingsViewModel(
indexingProgress = IndexingProgress(isIndexing = isIndexing), indexingProgress = IndexingProgress(isIndexing = isIndexing),
downloadState = operationState.downloadState, downloadState = operationState.downloadState,
language = operationState.language, language = operationState.language,
searchBarPosition = operationState.searchBarPosition,
importState = operationState.importState, importState = operationState.importState,
dictionaryStatus = operationState.dictionaryStatus, dictionaryStatus = operationState.dictionaryStatus,
hasCompletedStartupScan = operationState.hasCompletedStartupScan, hasCompletedStartupScan = operationState.hasCompletedStartupScan,
pipelineIdle = !isIndexing &&
!operationState.downloadState.isActive &&
!operationState.importState.isActive,
isThemeExpanded = operationState.isThemeExpanded, isThemeExpanded = operationState.isThemeExpanded,
isLanguageExpanded = operationState.isLanguageExpanded, isLanguageExpanded = operationState.isLanguageExpanded,
isSearchBarExpanded = operationState.isSearchBarExpanded,
isDictionariesExpanded = operationState.isDictionariesExpanded, isDictionariesExpanded = operationState.isDictionariesExpanded,
appVersion = _uiState.value.appVersion, appVersion = _uiState.value.appVersion,
) )
@@ -223,9 +277,15 @@ class SettingsViewModel(
} }
private fun initialize() { private fun initialize() {
var scanCompleted = false
viewModelScope.launch { viewModelScope.launch {
try { try {
scanDirectoryInternal(preferencesManager.dictionaryPath) scanCompleted = if (localDictionaryRepository.dictionaries.value.isEmpty()) {
scanDirectoryInternal(preferencesManager.dictionaryPath) is OperationResult.Success
} else {
dictionaryStatus.value = DictionaryStatus.UpToDate
false
}
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("SettingsViewModel", "Initial scan failed", e) android.util.Log.e("SettingsViewModel", "Initial scan failed", e)
dictionaryStatus.value = if (localDictionaryRepository.dictionaries.value.isEmpty()) { dictionaryStatus.value = if (localDictionaryRepository.dictionaries.value.isEmpty()) {
@@ -242,6 +302,11 @@ class SettingsViewModel(
android.util.Log.w("SettingsViewModel", "Failed to load language preference: ${e.message}") android.util.Log.w("SettingsViewModel", "Failed to load language preference: ${e.message}")
} }
hasCompletedStartupScan.value = true hasCompletedStartupScan.value = true
if (scanCompleted) {
preferencesManager.setHadNoDictionaries(
localDictionaryRepository.dictionaries.value.isEmpty()
)
}
} }
} }
} }
@@ -250,6 +315,7 @@ class SettingsViewModel(
when (event) { when (event) {
is SettingsUiEvent.ThemeChanged -> setTheme(event.theme) is SettingsUiEvent.ThemeChanged -> setTheme(event.theme)
is SettingsUiEvent.LanguageChanged -> saveLanguage(event.language) is SettingsUiEvent.LanguageChanged -> saveLanguage(event.language)
is SettingsUiEvent.SearchBarPositionChanged -> saveSearchBarPosition(event.position)
is SettingsUiEvent.SectionToggled -> saveSectionExpanded(event.key, event.isExpanded) is SettingsUiEvent.SectionToggled -> saveSectionExpanded(event.key, event.isExpanded)
is SettingsUiEvent.DictionaryToggled -> toggleDictionaryActive(event.path) is SettingsUiEvent.DictionaryToggled -> toggleDictionaryActive(event.path)
is SettingsUiEvent.DictionaryDeleted -> deleteDictionary(event.dictionary) is SettingsUiEvent.DictionaryDeleted -> deleteDictionary(event.dictionary)
@@ -264,18 +330,21 @@ class SettingsViewModel(
private suspend fun evaluateDictionaryStatus(): DictionaryStatus { private suspend fun evaluateDictionaryStatus(): DictionaryStatus {
return try { return try {
val actualDictionaries = localDictionaryRepository.dictionaries.first() val actualDictionaries = localDictionaryRepository.dictionaries.first()
val hasDictionaries = actualDictionaries.isNotEmpty()
if (!hasDictionaries) {
return DictionaryStatus.Empty
}
var sources = preferencesManager.dictionarySources.first() var sources = preferencesManager.dictionarySources.first()
if (downloadManager.downloadState.value !is DownloadState.Loading && if (pendingSourceUrls.isEmpty() &&
downloadManager.downloadState.value !is DownloadState.Loading &&
downloadManager.downloadState.value !is DownloadState.Extracting downloadManager.downloadState.value !is DownloadState.Extracting
) { ) {
val installedSources = installedSources(sources, actualDictionaries) val dictionaryFileNames = localDictionaryRepository.listDictionaryPayloadFileNames(
val installedSourceIds = installedSources.mapTo(mutableSetOf(), DictionarySource::id) preferencesManager.dictionaryPath
)
if (dictionaryFileNames != null) {
val installedSources = DictionarySourceFileMatcher.installedSourcesForFileNames(
sources,
dictionaryFileNames,
)
val installedSourceIds = installedSources
.mapTo(mutableSetOf(), DictionarySource::id)
val staleSourceIds = sources val staleSourceIds = sources
.filterNot { it.id in installedSourceIds } .filterNot { it.id in installedSourceIds }
.map(DictionarySource::id) .map(DictionarySource::id)
@@ -284,14 +353,20 @@ class SettingsViewModel(
sources = installedSources sources = installedSources
} }
} }
}
if (actualDictionaries.isEmpty()) {
return DictionaryStatus.Empty
}
val enabledSources = installedEnabledSources(sources, actualDictionaries) val enabledSources = installedEnabledSources(sources, actualDictionaries)
if (enabledSources.isEmpty()) { if (enabledSources.isEmpty()) {
return DictionaryStatus.UpToDate return DictionaryStatus.UpToDate
} }
val upToDate = dictionaryRepository.areDictionariesUpToDate(enabledSources) val updatable = dictionaryRepository.sourcesWithUpdates(enabledSources)
return if (upToDate) DictionaryStatus.UpToDate else DictionaryStatus.NeedsUpdate return if (updatable.isEmpty()) DictionaryStatus.UpToDate else DictionaryStatus.NeedsUpdate(updatable)
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("SettingsViewModel", "evaluateDictionaryStatus: Error", e) android.util.Log.e("SettingsViewModel", "evaluateDictionaryStatus: Error", e)
if (localDictionaryRepository.dictionaries.value.isNotEmpty()) { if (localDictionaryRepository.dictionaries.value.isNotEmpty()) {
@@ -313,6 +388,12 @@ class SettingsViewModel(
} }
} }
private fun saveSearchBarPosition(position: SearchBarPosition) {
viewModelScope.launch {
preferencesManager.saveSearchBarPosition(position)
}
}
private fun saveLanguage(languageCode: String) { private fun saveLanguage(languageCode: String) {
viewModelScope.launch { viewModelScope.launch {
preferencesManager.saveLanguage(languageCode) preferencesManager.saveLanguage(languageCode)
@@ -331,26 +412,38 @@ class SettingsViewModel(
} }
private fun toggleDictionaryActive(dictionaryPath: String) { private fun toggleDictionaryActive(dictionaryPath: String) {
viewModelScope.launch {
localDictionaryRepository.toggleDictionaryActive(dictionaryPath) localDictionaryRepository.toggleDictionaryActive(dictionaryPath)
} }
}
private fun deleteDictionary(dictionary: Dictionary) { private fun deleteDictionary(dictionary: Dictionary) {
viewModelScope.launch { viewModelScope.launch {
localDictionaryRepository.deleteDictionary(dictionary) when (localDictionaryRepository.deleteDictionary(dictionary)) {
is OperationResult.Success -> {
manageDictionarySourcesUseCase.removeSourceForDictionary(dictionary) val remainingDictionaryFileNames =
localDictionaryRepository.listDictionaryPayloadFileNames(
val remainingDictionaries = localDictionaryRepository.dictionaries.first() preferencesManager.dictionaryPath
)
if (remainingDictionaries.isEmpty()) { manageDictionarySourcesUseCase.removeSourceForDictionary(
dictionaryStatus.value = DictionaryStatus.Empty dictionary = dictionary,
remainingDictionaryFileNames = remainingDictionaryFileNames,
)
dictionaryStatus.value = withContext(Dispatchers.IO) {
evaluateDictionaryStatus()
}
}
is OperationResult.Error -> {
effectChannel.send(
getApplication<Application>().getString(R.string.error_delete_dictionary)
)
}
} }
} }
} }
private fun addDictionarySources(urlTemplates: List<String>) { private fun addDictionarySources(urlTemplates: List<String>) {
viewModelScope.launch { viewModelScope.launch {
try {
val validUrls = mutableListOf<String>() val validUrls = mutableListOf<String>()
urlTemplates.forEach { urlTemplate -> urlTemplates.forEach { urlTemplate ->
@@ -359,23 +452,30 @@ class SettingsViewModel(
return@forEach return@forEach
} }
val normalizedUrl = DictionarySource.normalizeTemplate(trimmed)
if (!pendingSourceUrls.add(normalizedUrl)) {
return@forEach
}
try {
when (manageDictionarySourcesUseCase.addSource(trimmed)) { when (manageDictionarySourcesUseCase.addSource(trimmed)) {
is ManageDictionarySourcesUseCase.AddSourceResult.Success -> { is ManageDictionarySourcesUseCase.AddSourceResult.Success -> {
pendingSourceUrls.add(DictionarySource.normalizeTemplate(trimmed))
validUrls.add(trimmed) validUrls.add(trimmed)
} }
is ManageDictionarySourcesUseCase.AddSourceResult.ValidationFailed -> { is ManageDictionarySourcesUseCase.AddSourceResult.ValidationFailed -> {
pendingSourceUrls.remove(normalizedUrl)
// Validation failed, skip this source // Validation failed, skip this source
} }
} }
} catch (e: Exception) {
pendingSourceUrls.remove(normalizedUrl)
android.util.Log.e("SettingsViewModel", "Error adding dictionary source", e)
}
} }
if (validUrls.isNotEmpty()) { if (validUrls.isNotEmpty()) {
startDownloadForSources(validUrls) startDownloadForSources(validUrls)
} }
} catch (e: Exception) {
android.util.Log.e("SettingsViewModel", "Error adding dictionary sources", e)
}
} }
} }
@@ -388,6 +488,7 @@ class SettingsViewModel(
return@launch return@launch
} }
statusBeforeDownload = dictionaryStatus.value
dictionaryStatus.value = DictionaryStatus.Checking dictionaryStatus.value = DictionaryStatus.Checking
isDownloadInProgress = true isDownloadInProgress = true
@@ -411,6 +512,7 @@ class SettingsViewModel(
return@launch return@launch
} }
statusBeforeDownload = dictionaryStatus.value
dictionaryStatus.value = DictionaryStatus.Checking dictionaryStatus.value = DictionaryStatus.Checking
val installedSources = installedEnabledSources( val installedSources = installedEnabledSources(
@@ -446,12 +548,25 @@ class SettingsViewModel(
private fun installedSources( private fun installedSources(
sources: List<DictionarySource>, sources: List<DictionarySource>,
dictionaries: List<Dictionary> dictionaries: List<Dictionary>
): List<DictionarySource> { ): List<DictionarySource> =
val installedFileNames = dictionaries.map { File(it.path).name } DictionarySourceFileMatcher.installedSources(sources, dictionaries)
return sources.filter { source ->
installedFileNames.any { fileName -> private suspend fun installedDictionariesAreCurrent(): Boolean {
DictionarySource.matchesDictionaryFile(source.urlTemplate, fileName) val sources = preferencesManager.dictionarySources.first()
} val dictionaries = localDictionaryRepository.dictionaries.first()
val datedEnabledSources = installedEnabledSources(sources, dictionaries)
.filter { DictionarySource.hasDatePlaceholder(it.urlTemplate) }
if (datedEnabledSources.isEmpty()) return true
val fileNames = localDictionaryRepository.listDictionaryPayloadFileNames(
preferencesManager.dictionaryPath
) ?: return true
val today = DateUtils.getCurrentDateString()
return datedEnabledSources.all { source ->
val baseName = DictionarySource.extractFileName(source.urlTemplate, today)
.removeSuffix(".gz")
.removeSuffix(".dsl")
.removeSuffix(".dz")
fileNames.any { it.startsWith(baseName) }
} }
} }
@@ -499,12 +614,23 @@ class SettingsViewModel(
is OperationResult.Success -> { is OperationResult.Success -> {
val dictionaries = localDictionaryRepository.dictionaries.first() val dictionaries = localDictionaryRepository.dictionaries.first()
if (dictionaries.isEmpty()) { if (dictionaries.isEmpty()) {
dictionaryStatus.value = DictionaryStatus.Empty dictionaryStatus.value = withContext(Dispatchers.IO) {
evaluateDictionaryStatus()
}
isDownloadInProgress = false isDownloadInProgress = false
} else { } else if (isDownloadInProgress) {
if (isDownloadInProgress) {
dictionaryStatus.value = DictionaryStatus.UpToDate dictionaryStatus.value = DictionaryStatus.UpToDate
isDownloadInProgress = false isDownloadInProgress = false
} else {
val savedStatus = preferencesManager.dictionaryStatus()
val savedStatusName = savedStatus?.substringBefore('|')
val savedStatusDate = savedStatus?.substringAfter('|', "")
val restoredStatus = if (savedStatusName == "UpToDate") DictionaryStatus.UpToDate else null
if (restoredStatus != null &&
savedStatusDate == DateUtils.getCurrentDateString() &&
withContext(Dispatchers.IO) { installedDictionariesAreCurrent() }
) {
dictionaryStatus.value = restoredStatus
} else { } else {
dictionaryStatus.value = DictionaryStatus.Checking dictionaryStatus.value = DictionaryStatus.Checking
val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() } val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
@@ -28,8 +28,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
@@ -48,6 +50,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.research.R import com.example.research.R
import com.example.research.common.ui.components.BadgedIcon
import com.example.research.core.domain.model.Dictionary import com.example.research.core.domain.model.Dictionary
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -81,7 +84,9 @@ fun DictionaryListItem(
dictionary: Dictionary, dictionary: Dictionary,
onToggle: () -> Unit, onToggle: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
isDeleteBlocked: () -> Boolean = { false },
hasUpdate: Boolean = false,
) { ) {
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
val density = LocalDensity.current val density = LocalDensity.current
@@ -93,7 +98,10 @@ fun DictionaryListItem(
val offsetAnim = remember { Animatable(0f) } val offsetAnim = remember { Animatable(0f) }
var rawOffset by remember { mutableFloatStateOf(0f) } var rawOffset by remember { mutableFloatStateOf(0f) }
val isDeleteRevealed by remember { derivedStateOf { rawOffset <= -swipeThresholdPx } } val isDeleteBlockedCurrent by rememberUpdatedState(isDeleteBlocked)
val isDeleteRevealed by remember {
derivedStateOf { !isDeleteBlockedCurrent() && rawOffset <= -swipeThresholdPx }
}
LaunchedEffect(isDeleteRevealed) { LaunchedEffect(isDeleteRevealed) {
if (isDeleteRevealed) { if (isDeleteRevealed) {
@@ -101,6 +109,16 @@ fun DictionaryListItem(
} }
} }
LaunchedEffect(Unit) {
snapshotFlow { isDeleteBlockedCurrent() }
.collect { blocked ->
if (blocked) {
offsetAnim.snapTo(0f)
rawOffset = 0f
}
}
}
val currentOnDelete by rememberUpdatedState(onDelete) val currentOnDelete by rememberUpdatedState(onDelete)
val currentOnToggle by rememberUpdatedState(onToggle) val currentOnToggle by rememberUpdatedState(onToggle)
@@ -121,6 +139,8 @@ fun DictionaryListItem(
Box( Box(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.height(DictionaryItemHeight)
.clipToBounds()
.testTag("dictionary_item") .testTag("dictionary_item")
) { ) {
Row( Row(
@@ -132,12 +152,15 @@ fun DictionaryListItem(
.pointerInput(maxSwipePx, swipeThresholdPx) { .pointerInput(maxSwipePx, swipeThresholdPx) {
detectHorizontalDragGestures( detectHorizontalDragGestures(
onDragStart = { onDragStart = {
if (isDeleteBlockedCurrent()) return@detectHorizontalDragGestures
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}, },
onDragEnd = { onDragEnd = {
if (isDeleteBlockedCurrent()) return@detectHorizontalDragGestures
val target = if (rawOffset <= -swipeThresholdPx) -maxSwipePx else 0f val target = if (rawOffset <= -swipeThresholdPx) -maxSwipePx else 0f
rawOffset = target rawOffset = target
scope.launch { scope.launch {
if (isDeleteBlockedCurrent()) return@launch
offsetAnim.animateTo( offsetAnim.animateTo(
target, target,
animationSpec = spring(stiffness = Spring.StiffnessMedium) animationSpec = spring(stiffness = Spring.StiffnessMedium)
@@ -145,9 +168,11 @@ fun DictionaryListItem(
} }
}, },
onDragCancel = { onDragCancel = {
if (isDeleteBlockedCurrent()) return@detectHorizontalDragGestures
val target = if (rawOffset <= -swipeThresholdPx) -maxSwipePx else 0f val target = if (rawOffset <= -swipeThresholdPx) -maxSwipePx else 0f
rawOffset = target rawOffset = target
scope.launch { scope.launch {
if (isDeleteBlockedCurrent()) return@launch
offsetAnim.animateTo( offsetAnim.animateTo(
target, target,
animationSpec = spring(stiffness = Spring.StiffnessMedium) animationSpec = spring(stiffness = Spring.StiffnessMedium)
@@ -155,9 +180,13 @@ fun DictionaryListItem(
} }
}, },
onHorizontalDrag = { _, dragAmount -> onHorizontalDrag = { _, dragAmount ->
if (isDeleteBlockedCurrent()) return@detectHorizontalDragGestures
val newOffset = (rawOffset + dragAmount).coerceIn(-maxSwipePx, 0f) val newOffset = (rawOffset + dragAmount).coerceIn(-maxSwipePx, 0f)
rawOffset = newOffset rawOffset = newOffset
scope.launch { offsetAnim.snapTo(newOffset) } scope.launch {
if (isDeleteBlockedCurrent()) return@launch
offsetAnim.snapTo(newOffset)
}
} }
) )
}, },
@@ -168,11 +197,15 @@ fun DictionaryListItem(
modifier = Modifier.size(IconButtonSize), modifier = Modifier.size(IconButtonSize),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( BadgedIcon(
painter = painterResource(R.drawable.ic_book), painter = painterResource(R.drawable.ic_book),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(IconSize) size = IconSize,
badgeDescription = if (hasUpdate) {
stringResource(R.string.dictionary_update_available)
} else {
null
},
) )
} }
@@ -222,25 +255,11 @@ fun DictionaryListItem(
} }
if (isDeleteRevealed) { if (isDeleteRevealed) {
IconButton( Box(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
currentOnDelete()
scope.launch { offsetAnim.snapTo(0f) }
rawOffset = 0f
},
modifier = Modifier modifier = Modifier
.padding(end = ItemHorizontalPadding) .padding(end = ItemHorizontalPadding)
.graphicsLayer { translationX = -offsetAnim.value }
.size(IconButtonSize) .size(IconButtonSize)
) {
Icon(
painter = painterResource(R.drawable.ic_delete),
contentDescription = stringResource(R.string.dictionary_delete),
tint = DeleteIconColor,
modifier = Modifier.size(IconSize)
) )
}
} else { } else {
Switch( Switch(
checked = dictionary.isActive, checked = dictionary.isActive,
@@ -257,5 +276,29 @@ fun DictionaryListItem(
) )
} }
} }
if (isDeleteRevealed) {
IconButton(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
currentOnDelete()
scope.launch {
offsetAnim.snapTo(0f)
rawOffset = 0f
}
},
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = ItemHorizontalPadding)
.size(IconButtonSize)
) {
Icon(
painter = painterResource(R.drawable.ic_delete),
contentDescription = stringResource(R.string.dictionary_delete),
tint = DeleteIconColor,
modifier = Modifier.size(IconSize)
)
}
}
} }
} }
@@ -1,26 +1,42 @@
package com.example.research.ui.settings.components package com.example.research.ui.settings.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -32,6 +48,7 @@ import com.example.research.common.ui.components.CollapsibleHeader
import com.example.research.common.ui.components.EmptyState import com.example.research.common.ui.components.EmptyState
import com.example.research.common.ui.components.SectionCard import com.example.research.common.ui.components.SectionCard
import com.example.research.core.domain.model.Dictionary import com.example.research.core.domain.model.Dictionary
import com.example.research.core.domain.usecase.DictionarySourceFileMatcher
import com.example.research.core.domain.model.DictionarySource import com.example.research.core.domain.model.DictionarySource
import com.example.research.feature.download.model.DownloadState import com.example.research.feature.download.model.DownloadState
import com.example.research.ui.settings.ImportState import com.example.research.ui.settings.ImportState
@@ -60,7 +77,10 @@ fun DictionaryManagement(
) { ) {
val (showUrlBottomSheet, setShowUrlBottomSheet) = remember { mutableStateOf(false) } val (showUrlBottomSheet, setShowUrlBottomSheet) = remember { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
)
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
@@ -75,6 +95,27 @@ fun DictionaryManagement(
derivedStateOf { importState is ImportState.Importing || importState is ImportState.Extracting } derivedStateOf { importState is ImportState.Importing || importState is ImportState.Extracting }
} }
val hasEnabledSources by remember(sources) { derivedStateOf { sources.any { it.isEnabled } } } val hasEnabledSources by remember(sources) { derivedStateOf { sources.any { it.isEnabled } } }
val isUpdateAvailable = dictionaryStatus is DictionaryStatus.NeedsUpdate &&
!isInProgress && hasEnabledSources && dictionaries.isNotEmpty()
val updatablePaths = remember(dictionaryStatus, sources, dictionaries, isUpdateAvailable) {
val ids = (dictionaryStatus as? DictionaryStatus.NeedsUpdate)?.sourceIds
if (!isUpdateAvailable || ids.isNullOrEmpty()) {
emptySet()
} else {
val updatableSources = sources.filter { it.id in ids }
dictionaries
.filter { dictionary ->
updatableSources.any { DictionarySourceFileMatcher.matches(it, dictionary) }
}
.mapTo(mutableSetOf(), Dictionary::path)
}
}
// Invariant: the mutation-block flag is read only at state-read/gesture time (derivedStateOf,
// snapshotFlow, pointerInput callbacks), never at row composition time. A composition-time read
// (e.g. Modifier.alpha(if (isDeleteBlocked()) ...)) would invalidate every row on each flip.
val isMutationsBlockedState = rememberUpdatedState(isInProgress)
val isDeleteBlocked = remember { { isMutationsBlockedState.value } }
SectionCard( SectionCard(
modifier = modifier modifier = modifier
@@ -97,10 +138,19 @@ fun DictionaryManagement(
isExpanded = isExpanded, isExpanded = isExpanded,
onToggle = onToggleExpanded, onToggle = onToggleExpanded,
subtitle = headerSubtitle, subtitle = headerSubtitle,
testTag = "dictionary_header" testTag = "dictionary_header",
badgeDescription = if (isUpdateAvailable && !isExpanded) {
stringResource(R.string.dictionaries_update_available)
} else {
null
},
) )
if (isExpanded) { AnimatedVisibility(
visible = isExpanded,
enter = expandVertically(animationSpec = tween(250)) + fadeIn(tween(250)),
exit = shrinkVertically(animationSpec = tween(200)) + fadeOut(tween(200)),
) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -126,7 +176,7 @@ fun DictionaryManagement(
// Disable accessibility on background content during import to improve performance. // Disable accessibility on background content during import to improve performance.
// The progress dialog remains accessible for cancellation. // The progress dialog remains accessible for cancellation.
val backgroundModifier = if (isInProgress) { val backgroundModifier = if (isInProgress) {
Modifier.semantics(mergeDescendants = true) { } Modifier.semantics { hideFromAccessibility() }
} else { } else {
Modifier Modifier
} }
@@ -153,7 +203,7 @@ fun DictionaryManagement(
) )
} }
} else { } else {
if (dictionaryStatus is DictionaryStatus.NeedsUpdate && !isInProgress && hasEnabledSources) { if (isUpdateAvailable) {
DictionaryUpdateCard( DictionaryUpdateCard(
onStartDownload = onStartDownload, onStartDownload = onStartDownload,
modifier = backgroundModifier modifier = backgroundModifier
@@ -166,7 +216,9 @@ fun DictionaryManagement(
onDeleteDictionary = onDeleteDictionary, onDeleteDictionary = onDeleteDictionary,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.then(backgroundModifier) .then(backgroundModifier),
isDeleteBlocked = isDeleteBlocked,
updatablePaths = updatablePaths,
) )
if (!isInProgress) { if (!isInProgress) {
@@ -194,7 +246,8 @@ fun DictionaryManagement(
focusManager.clearFocus(force = true) focusManager.clearFocus(force = true)
setShowUrlBottomSheet(false) setShowUrlBottomSheet(false)
}, },
sheetState = sheetState sheetState = sheetState,
contentWindowInsets = { WindowInsets.statusBars }
) { ) {
DictionaryUrlInputContent( DictionaryUrlInputContent(
existingSources = sources, existingSources = sources,
@@ -237,6 +290,7 @@ private fun DictionaryProgressSectionHost(
label = label, label = label,
onCancel = onCancel, onCancel = onCancel,
percent = progressSnapshot?.percent ?: 0, percent = progressSnapshot?.percent ?: 0,
indeterminate = progressSnapshot?.indeterminate ?: false,
testTag = progressTestTag, testTag = progressTestTag,
) )
} }
@@ -246,18 +300,40 @@ private fun DictionaryListSection(
dictionaries: List<Dictionary>, dictionaries: List<Dictionary>,
onToggleDictionary: (String) -> Unit, onToggleDictionary: (String) -> Unit,
onDeleteDictionary: (Dictionary) -> Unit, onDeleteDictionary: (Dictionary) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
isDeleteBlocked: () -> Boolean = { false },
updatablePaths: Set<String> = emptySet(),
) { ) {
var appearedPaths by remember { mutableStateOf(emptySet<String>()) }
LaunchedEffect(dictionaries) {
val visiblePaths = dictionaries.mapTo(mutableSetOf(), Dictionary::path)
appearedPaths = appearedPaths intersect visiblePaths
}
Column( Column(
modifier = modifier, modifier = modifier,
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
dictionaries.forEach { dictionary -> dictionaries.forEach { dictionary ->
key(dictionary.path) {
val hasAppeared = dictionary.path in appearedPaths
val rowAlpha by animateFloatAsState(
targetValue = if (hasAppeared) 1f else 0f,
animationSpec = tween(150),
label = "dictionaryRowAppear",
)
LaunchedEffect(dictionary.path) {
appearedPaths = appearedPaths + dictionary.path
}
Box(modifier = Modifier.alpha(rowAlpha)) {
DictionaryListItem( DictionaryListItem(
dictionary = dictionary, dictionary = dictionary,
onToggle = { onToggleDictionary(dictionary.path) }, onToggle = { onToggleDictionary(dictionary.path) },
onDelete = { onDeleteDictionary(dictionary) } onDelete = { onDeleteDictionary(dictionary) },
isDeleteBlocked = isDeleteBlocked,
hasUpdate = dictionary.path in updatablePaths,
) )
} }
} }
}
}
} }
@@ -1,8 +1,8 @@
package com.example.research.ui.settings.components package com.example.research.ui.settings.components
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
@@ -19,6 +20,10 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.research.R import com.example.research.R
private const val MILLIS_PER_PROGRESS_UNIT = 10_000
private const val MIN_PROGRESS_MOTION_MS = 50
private const val MAX_PROGRESS_MOTION_MS = 650
@Composable @Composable
fun DictionaryProgressSection( fun DictionaryProgressSection(
visible: Boolean, visible: Boolean,
@@ -27,6 +32,7 @@ fun DictionaryProgressSection(
onCancel: () -> Unit, onCancel: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
percent: Int, percent: Int,
indeterminate: Boolean,
testTag: String, testTag: String,
) { ) {
AnimatedVisibility( AnimatedVisibility(
@@ -40,7 +46,13 @@ fun DictionaryProgressSection(
var displayedLabel by remember { mutableStateOf(label) } var displayedLabel by remember { mutableStateOf(label) }
var displayedPercent by remember { mutableIntStateOf(percent) } var displayedPercent by remember { mutableIntStateOf(percent) }
var displayedTestTag by remember { mutableStateOf(testTag) } var displayedTestTag by remember { mutableStateOf(testTag) }
var displayedIndeterminate by remember { mutableStateOf(indeterminate) }
LaunchedEffect(visible) {
if (visible) {
monotonicTargetProgress = targetProgress
}
}
LaunchedEffect(visible, targetProgress) { LaunchedEffect(visible, targetProgress) {
if (visible) { if (visible) {
monotonicTargetProgress = maxOf(monotonicTargetProgress, targetProgress) monotonicTargetProgress = maxOf(monotonicTargetProgress, targetProgress)
@@ -51,13 +63,24 @@ fun DictionaryProgressSection(
displayedLabel = label displayedLabel = label
displayedPercent = percent displayedPercent = percent
displayedTestTag = testTag displayedTestTag = testTag
displayedIndeterminate = indeterminate
} }
} }
val animatedProgress by animateFloatAsState( val animatedState = remember { Animatable(0f) }
LaunchedEffect(monotonicTargetProgress) {
val delta = monotonicTargetProgress - animatedState.value
if (delta > 0f) {
animatedState.animateTo(
targetValue = monotonicTargetProgress, targetValue = monotonicTargetProgress,
animationSpec = tween(durationMillis = 650, easing = FastOutSlowInEasing), animationSpec = tween(
label = "dictionary-progress" durationMillis = (delta * MILLIS_PER_PROGRESS_UNIT).toInt()
.coerceIn(MIN_PROGRESS_MOTION_MS, MAX_PROGRESS_MOTION_MS),
easing = LinearEasing,
),
) )
}
}
val animatedProgress = animatedState.value
Column(modifier = Modifier.testTag(displayedTestTag)) { Column(modifier = Modifier.testTag(displayedTestTag)) {
Row( Row(
@@ -69,13 +92,23 @@ fun DictionaryProgressSection(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
if (!displayedIndeterminate) {
Text( Text(
text = "$displayedPercent%", text = "$displayedPercent%",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
} }
Spacer(Modifier.height(8.dp)) }
Box(
modifier = Modifier
.fillMaxWidth()
.height(WavyProgressIndicatorDefaults.LinearContainerHeight),
contentAlignment = Alignment.Center,
) {
if (displayedIndeterminate) {
LinearWavyProgressIndicator(modifier = Modifier.fillMaxWidth())
} else {
LinearProgressIndicator( LinearProgressIndicator(
progress = { animatedProgress }, progress = { animatedProgress },
modifier = Modifier modifier = Modifier
@@ -83,7 +116,9 @@ fun DictionaryProgressSection(
.height(4.dp) .height(4.dp)
.clip(MaterialTheme.shapes.small), .clip(MaterialTheme.shapes.small),
) )
Spacer(Modifier.height(8.dp)) }
}
Spacer(Modifier.height(4.dp))
OutlinedButton( OutlinedButton(
onClick = onCancel, onClick = onCancel,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -3,6 +3,9 @@ package com.example.research.ui.settings.components
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.material3.* import androidx.compose.material3.*
@@ -16,7 +19,6 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.research.R import com.example.research.R
import com.example.research.core.domain.model.DictionarySource import com.example.research.core.domain.model.DictionarySource
@@ -64,6 +66,29 @@ private fun validateDictionaryUrl(context: UrlValidationContext): String? {
} }
} }
private val urlSplitRegex = Regex("[\\s,;]+")
private fun splitPastedUrls(text: String): List<String>? =
text.split(urlSplitRegex).filter { it.isNotEmpty() }.takeIf { tokens ->
tokens.size > 1 && tokens.all {
DictionarySource.validateTemplate(it) is DictionarySource.ValidationResult.Valid
}
}
private val urlInputTransformation = InputTransformation {
val text = toString()
val filtered = text
.take(2048)
.filter { char ->
!char.isISOControl() &&
char != '\u0000' &&
char.code !in 128..<160
}
if (filtered != text) {
replace(0, length, filtered)
}
}
@Composable @Composable
fun DictionaryUrlInputContent( fun DictionaryUrlInputContent(
existingSources: List<DictionarySource>, existingSources: List<DictionarySource>,
@@ -71,11 +96,22 @@ fun DictionaryUrlInputContent(
onCancel: () -> Unit, onCancel: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val urls = remember { mutableStateListOf(TextFieldValue("")) } val urls = remember { mutableStateListOf(TextFieldState("")) }
val validationErrors = remember { mutableStateMapOf<Int, String>() } val validationErrors = remember { mutableStateMapOf<Int, String>() }
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
LaunchedEffect(Unit) {
snapshotFlow { urls.map { it.text.toString() } }.collect { texts ->
val (index, tokens) = texts.withIndex().firstNotNullOfOrNull { (i, text) ->
splitPastedUrls(text)?.let { i to it }
} ?: return@collect
urls[index].edit { replace(0, length, tokens.first()) }
urls.addAll(index + 1, tokens.subList(1, tokens.size).map { TextFieldState(it) })
validationErrors.clear()
}
}
val errorMessages = ErrorMessages( val errorMessages = ErrorMessages(
empty = stringResource(R.string.dictionary_source_url_empty), empty = stringResource(R.string.dictionary_source_url_empty),
duplicateInForm = stringResource(R.string.dictionary_source_duplicate_in_form), duplicateInForm = stringResource(R.string.dictionary_source_duplicate_in_form),
@@ -96,8 +132,8 @@ fun DictionaryUrlInputContent(
// Cache prefixes for all input URLs to avoid repeated extraction during recomposition // Cache prefixes for all input URLs to avoid repeated extraction during recomposition
val inputUrlPrefixes = remember { val inputUrlPrefixes = remember {
derivedStateOf { derivedStateOf {
urls.associate { urlValue -> urls.associate { urlState ->
val urlString = urlValue.text.trim() val urlString = urlState.trimmedText()
urlString to if (urlString.isNotEmpty()) { urlString to if (urlString.isNotEmpty()) {
DictionarySource.extractPrefix(urlString) DictionarySource.extractPrefix(urlString)
} else null } else null
@@ -119,10 +155,10 @@ fun DictionaryUrlInputContent(
style = MaterialTheme.typography.headlineSmall style = MaterialTheme.typography.headlineSmall
) )
urls.forEachIndexed { index, urlValue -> urls.forEachIndexed { index, urlState ->
val isLastItem = index == urls.lastIndex val isLastItem = index == urls.lastIndex
val urlString = urlValue.text.trim() val urlString = urlState.trimmedText()
val cachedPrefix = inputUrlPrefixes.value[urlString] val cachedPrefix = inputUrlPrefixes.value[urlString]
val realtimeError = remember(urlString, existingPrefixes, urls.size, cachedPrefix) { val realtimeError = remember(urlString, existingPrefixes, urls.size, cachedPrefix) {
validateDictionaryUrl( validateDictionaryUrl(
@@ -130,7 +166,7 @@ fun DictionaryUrlInputContent(
url = urlString, url = urlString,
existingSources = existingSources, existingSources = existingSources,
existingPrefixes = existingPrefixes, existingPrefixes = existingPrefixes,
urlsInForm = urls.map { it.text.trim() }, urlsInForm = urls.map { it.trimmedText() },
urlPrefix = cachedPrefix, urlPrefix = cachedPrefix,
errorMessages = errorMessages errorMessages = errorMessages
) )
@@ -140,20 +176,8 @@ fun DictionaryUrlInputContent(
val displayError = validationErrors[index] ?: realtimeError val displayError = validationErrors[index] ?: realtimeError
OutlinedTextField( OutlinedTextField(
value = urlValue, state = urlState,
onValueChange = { newValue -> inputTransformation = urlInputTransformation,
val filtered = newValue.copy(
text = newValue.text
.take(2048)
.filter { char ->
!char.isISOControl() &&
char != '\u0000' &&
char.code !in 128..<160
}
)
urls[index] = filtered
validationErrors.remove(index)
},
label = { Text(if (index == 0) stringResource(R.string.dictionary_source_url_hint) else "URL ${index + 1}") }, label = { Text(if (index == 0) stringResource(R.string.dictionary_source_url_hint) else "URL ${index + 1}") },
isError = displayError != null, isError = displayError != null,
supportingText = displayError?.let { { Text(it) } }, supportingText = displayError?.let { { Text(it) } },
@@ -168,7 +192,7 @@ fun DictionaryUrlInputContent(
Modifier Modifier
} }
), ),
singleLine = true, lineLimits = TextFieldLineLimits.SingleLine,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri, keyboardType = KeyboardType.Uri,
imeAction = if (isLastItem) ImeAction.Done else ImeAction.Next imeAction = if (isLastItem) ImeAction.Done else ImeAction.Next
@@ -188,7 +212,7 @@ fun DictionaryUrlInputContent(
TextButton( TextButton(
onClick = { onClick = {
urls.add(TextFieldValue("")) urls.add(TextFieldState(""))
}, },
modifier = Modifier modifier = Modifier
.align(Alignment.Start) .align(Alignment.Start)
@@ -224,8 +248,8 @@ fun DictionaryUrlInputContent(
val validUrls = mutableListOf<String>() val validUrls = mutableListOf<String>()
val seenUrls = mutableSetOf<String>() val seenUrls = mutableSetOf<String>()
urls.forEachIndexed { index, urlValue -> urls.forEachIndexed { index, urlState ->
val urlString = urlValue.text.trim() val urlString = urlState.trimmedText()
if (urlString.isBlank()) { if (urlString.isBlank()) {
if (urls.size == 1) { if (urls.size == 1) {
validationErrors[index] = errorMessages.empty validationErrors[index] = errorMessages.empty
@@ -263,9 +287,9 @@ fun DictionaryUrlInputContent(
.weight(1f) .weight(1f)
.testTag("add_confirm_button") .testTag("add_confirm_button")
.semantics { testTagsAsResourceId = true }, .semantics { testTagsAsResourceId = true },
enabled = urls.any { it.text.trim().isNotBlank() } && enabled = urls.any { it.trimmedText().isNotBlank() } &&
urls.none { urlValue -> urls.none { urlState ->
val urlString = urlValue.text.trim() val urlString = urlState.trimmedText()
if (urlString.isEmpty()) { if (urlString.isEmpty()) {
false false
} else { } else {
@@ -275,7 +299,7 @@ fun DictionaryUrlInputContent(
url = urlString, url = urlString,
existingSources = existingSources, existingSources = existingSources,
existingPrefixes = existingPrefixes, existingPrefixes = existingPrefixes,
urlsInForm = urls.map { it.text.trim() }, urlsInForm = urls.map { it.trimmedText() },
urlPrefix = urlPrefix, urlPrefix = urlPrefix,
errorMessages = errorMessages errorMessages = errorMessages
) )
@@ -289,3 +313,5 @@ fun DictionaryUrlInputContent(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} }
} }
private fun TextFieldState.trimmedText(): String = text.trim().toString()
@@ -0,0 +1,40 @@
package com.example.research.ui.settings.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import com.example.research.R
import com.example.research.common.ui.components.OutlinedChoiceButton
import com.example.research.core.domain.model.SearchBarPosition
@Composable
fun SearchBarPositionSection(
currentPosition: SearchBarPosition,
onPositionChanged: (SearchBarPosition) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
SearchBarPosition.entries.forEach { position ->
val isSelected = currentPosition == position
OutlinedChoiceButton(
text = when (position) {
SearchBarPosition.TOP -> stringResource(R.string.search_bar_position_top)
SearchBarPosition.BOTTOM -> stringResource(R.string.search_bar_position_bottom)
},
onClick = { onPositionChanged(position) },
modifier = Modifier
.weight(1f)
.testTag("search_bar_${position.name}"),
selected = isSelected
)
}
}
}
@@ -6,7 +6,8 @@ object Spacing {
// Semantic spacing constants for specific UI elements // Semantic spacing constants for specific UI elements
val topBarPadding = 8.dp val topBarPadding = 8.dp
val topBarHeight = 56.dp val topBarHeight = 56.dp
val iconStart = 32.dp val topBarCenter = topBarPadding + topBarHeight / 2
val searchTextStart = 68.dp
val listItemVertical = 8.dp val listItemVertical = 8.dp
val contentStart = 32.dp
} }
@@ -24,12 +24,20 @@ object AppWindowInsets {
@Composable @Composable
get() = WindowInsets.navigationBars.union(WindowInsets.ime) get() = WindowInsets.navigationBars.union(WindowInsets.ime)
val statusBarsAndIme: WindowInsets
@Composable
get() = WindowInsets.statusBars.union(WindowInsets.ime)
val fullScreen: WindowInsets val fullScreen: WindowInsets
@Composable @Composable
get() = WindowInsets.statusBars get() = WindowInsets.statusBars
.union(WindowInsets.navigationBars) .union(WindowInsets.navigationBars)
.union(WindowInsets.displayCutout) .union(WindowInsets.displayCutout)
val bottomNavigation: WindowInsets
@Composable
get() = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)
val navigationOnly: WindowInsets val navigationOnly: WindowInsets
@Composable @Composable
get() = WindowInsets.navigationBars get() = WindowInsets.navigationBars
+9
View File
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M13,3A9,9 0 0,0 4,12H1L4.89,15.89L4.96,16.03L9,12H6A7,7 0 0,1 13,5A7,7 0 0,1 20,12A7,7 0 0,1 13,19C11.07,19 9.32,18.21 8.06,16.94L6.64,18.36C8.27,19.99 10.51,21 13,21A9,9 0 0,0 22,12A9,9 0 0,0 13,3M12,8V13L16.28,15.54L17,14.33L13.5,12.25V8H12Z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M7,9H2V7h5v2zM7,12H2v2h5v-2zM20.59,19l-3.83,-3.83c-0.8,0.52 -1.74,0.83 -2.76,0.83 -2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5c0,1.02 -0.31,1.96 -0.83,2.75L22,17.59 20.59,19zM17,11c0,-1.65 -1.35,-3 -3,-3s-3,1.35 -3,3 1.35,3 3,3 3,-1.35 3,-3zM2,19h10v-2H2v2z" />
</vector>
+14 -7
View File
@@ -2,18 +2,22 @@
<string name="app_name">ReSearch</string> <string name="app_name">ReSearch</string>
<string name="settings_title">Настройки</string> <string name="settings_title">Настройки</string>
<string name="action_back">Назад</string> <string name="action_back">Назад</string>
<string name="version_format">v%1$s</string>
<string name="version_unknown">неизвестно</string> <string name="version_unknown">неизвестно</string>
<string name="button_cancel_download">Отмена</string> <string name="button_cancel_download">Отмена</string>
<string name="action_ok">ОК</string> <string name="action_ok">ОК</string>
<string name="download_error">Ошибка загрузки словарей</string> <string name="download_error">Ошибка загрузки словарей</string>
<string name="download_network_failed">Нет связи с сервером словарей. Проверьте сеть и попробуйте снова</string>
<string name="language">Язык</string> <string name="language">Язык</string>
<string name="language_english">Английский</string> <string name="language_english">Английский</string>
<string name="language_russian">Русский</string> <string name="language_russian">Русский</string>
<string name="language_system">Системный</string> <string name="language_system">Системный</string>
<string name="indexing_label">Индексация словарей</string> <string name="search_bar_position">Строка поиска</string>
<string name="search_bar_position_top">Наверху</string>
<string name="search_bar_position_bottom">Внизу</string>
<string name="clear_search">Очистить поиск</string> <string name="clear_search">Очистить поиск</string>
<string name="article_no_selected">Статья не выбрана</string> <string name="recent_searches">История поиска</string>
<string name="clear_search_history">Очистить</string>
<string name="remove_search_history_entry">Удалить из истории</string>
<string name="article_return_to_search">Вернуться к поиску</string> <string name="article_return_to_search">Вернуться к поиску</string>
<string name="error_checking_dictionaries">Не удалось проверить статус словарей</string> <string name="error_checking_dictionaries">Не удалось проверить статус словарей</string>
<string name="error_loading_article">Ошибка загрузки: %1$s</string> <string name="error_loading_article">Ошибка загрузки: %1$s</string>
@@ -48,6 +52,7 @@
<string name="notification_download_error_title">Ошибка загрузки</string> <string name="notification_download_error_title">Ошибка загрузки</string>
<string name="notification_download_error_text">Не удалось скачать словари</string> <string name="notification_download_error_text">Не удалось скачать словари</string>
<string name="notification_indexing_title">Индексация словарей</string> <string name="notification_indexing_title">Индексация словарей</string>
<string name="notification_preparing_indexing_title">Подготовка словарей к индексации</string>
<string name="archive_failed_open_source_file">Не удалось открыть исходный файл</string> <string name="archive_failed_open_source_file">Не удалось открыть исходный файл</string>
<string name="archive_failed_create_output_file">Не удалось создать выходной файл</string> <string name="archive_failed_create_output_file">Не удалось создать выходной файл</string>
<string name="archive_failed_delete_file">Не удалось удалить файл: %1$s</string> <string name="archive_failed_delete_file">Не удалось удалить файл: %1$s</string>
@@ -57,7 +62,6 @@
<string name="error_scan_directory_failed">Не удалось просканировать папку со словарями</string> <string name="error_scan_directory_failed">Не удалось просканировать папку со словарями</string>
<string name="error_search_failed">Ошибка поиска запроса \'%1$s\' в %2$s</string> <string name="error_search_failed">Ошибка поиска запроса \'%1$s\' в %2$s</string>
<string name="error_failed_load_index_metadata">Не удалось загрузить метаданные индекса: %1$s</string> <string name="error_failed_load_index_metadata">Не удалось загрузить метаданные индекса: %1$s</string>
<string name="error_loading_more">Ошибка загрузки</string>
<plurals name="active_dictionaries_count"> <plurals name="active_dictionaries_count">
<item quantity="one">%1$d активный словарь</item> <item quantity="one">%1$d активный словарь</item>
<item quantity="few">%1$d активных словаря</item> <item quantity="few">%1$d активных словаря</item>
@@ -67,21 +71,23 @@
<string name="dictionary_management_title">Словари</string> <string name="dictionary_management_title">Словари</string>
<string name="import_dictionary_button">Выбрать файлы</string> <string name="import_dictionary_button">Выбрать файлы</string>
<string name="import_error">Не удалось импортировать словарь: %1$s</string> <string name="import_error">Не удалось импортировать словарь: %1$s</string>
<string name="import_file_exists">Файл уже существует: %1$s</string> <string name="import_file_exists">Словарь уже добавлен: %1$s</string>
<string name="import_invalid_file_name">Недопустимое имя файла: %1$s</string> <string name="import_invalid_file_name">Недопустимое имя файла: %1$s</string>
<string name="import_nothing_imported">В выбранном нет поддерживаемых файлов словарей</string>
<string name="dictionary_source_url_hint">URL</string> <string name="dictionary_source_url_hint">URL</string>
<string name="dictionary_source_add_button">Добавить источник</string> <string name="dictionary_source_add_button">Добавить источник</string>
<string name="dictionary_source_duplicate">Этот URL уже существует</string> <string name="dictionary_source_duplicate">Этот URL уже существует</string>
<string name="dictionary_source_duplicate_in_form">Дублирующийся URL в форме</string> <string name="dictionary_source_duplicate_in_form">Дублирующийся URL в форме</string>
<string name="dictionary_source_duplicate_prefix">Этот словарь уже существует (одинаковый префикс)</string> <string name="dictionary_source_duplicate_prefix">Этот словарь уже существует (одинаковый префикс)</string>
<string name="dictionary_source_url_empty">URL не может быть пустым</string> <string name="dictionary_source_url_empty">URL не может быть пустым</string>
<string name="dictionary_source_invalid_protocol">Должен начинаться с http:// или https://</string> <string name="dictionary_source_invalid_protocol">Должен начинаться с https://</string>
<string name="dictionary_source_invalid_extension">Должен заканчиваться на .gz, .dsl или .dz</string> <string name="dictionary_source_invalid_extension">Должен заканчиваться на .gz, .dsl или .dsl.dz</string>
<string name="dictionary_source_too_long">URL слишком длинный (максимум 2048 символов)</string> <string name="dictionary_source_too_long">URL слишком длинный (максимум 2048 символов)</string>
<string name="dictionary_source_invalid_url">Неверный формат URL</string> <string name="dictionary_source_invalid_url">Неверный формат URL</string>
<string name="dictionary_source_suspicious_characters">URL содержит недопустимые символы</string> <string name="dictionary_source_suspicious_characters">URL содержит недопустимые символы</string>
<string name="dictionaries_not_downloaded">Словари еще не загружены</string> <string name="dictionaries_not_downloaded">Словари еще не загружены</string>
<string name="dictionaries_update_available">Доступны обновления словарей</string> <string name="dictionaries_update_available">Доступны обновления словарей</string>
<string name="dictionary_update_available">Доступно обновление</string>
<string name="action_cancel">Отмена</string> <string name="action_cancel">Отмена</string>
<string name="action_add">Добавить</string> <string name="action_add">Добавить</string>
<string name="action_add_another_url">Добавить ещё URL</string> <string name="action_add_another_url">Добавить ещё URL</string>
@@ -95,6 +101,7 @@
</plurals> </plurals>
<string name="dictionary_not_indexed">• Не индексирован</string> <string name="dictionary_not_indexed">• Не индексирован</string>
<string name="dictionary_delete">Удалить словарь</string> <string name="dictionary_delete">Удалить словарь</string>
<string name="error_delete_dictionary">Не удалось удалить словарь</string>
<string name="dictionaries_tap_to_update">Нажмите для обновления</string> <string name="dictionaries_tap_to_update">Нажмите для обновления</string>
<string name="notification_import_title">Импорт словарей</string> <string name="notification_import_title">Импорт словарей</string>
<string name="notification_import_success_title">Импорт завершён</string> <string name="notification_import_success_title">Импорт завершён</string>
+14 -7
View File
@@ -2,18 +2,22 @@
<string name="app_name">ReSearch</string> <string name="app_name">ReSearch</string>
<string name="settings_title">Settings</string> <string name="settings_title">Settings</string>
<string name="action_back">Back</string> <string name="action_back">Back</string>
<string name="version_format">v%1$s</string>
<string name="version_unknown">unknown</string> <string name="version_unknown">unknown</string>
<string name="button_cancel_download">Cancel</string> <string name="button_cancel_download">Cancel</string>
<string name="action_ok">OK</string> <string name="action_ok">OK</string>
<string name="download_error">Dictionary download failed</string> <string name="download_error">Dictionary download failed</string>
<string name="download_network_failed">No connection to the dictionary server. Check your network and try again</string>
<string name="language">Language</string> <string name="language">Language</string>
<string name="language_english">English</string> <string name="language_english">English</string>
<string name="language_russian">Russian</string> <string name="language_russian">Russian</string>
<string name="language_system">System</string> <string name="language_system">System</string>
<string name="indexing_label">Indexing dictionaries</string> <string name="search_bar_position">Search bar</string>
<string name="search_bar_position_top">Top</string>
<string name="search_bar_position_bottom">Bottom</string>
<string name="clear_search">Clear search</string> <string name="clear_search">Clear search</string>
<string name="article_no_selected">No article selected</string> <string name="recent_searches">Search history</string>
<string name="clear_search_history">Clear</string>
<string name="remove_search_history_entry">Remove from history</string>
<string name="article_return_to_search">Return to search</string> <string name="article_return_to_search">Return to search</string>
<string name="error_checking_dictionaries">Failed to check dictionary status</string> <string name="error_checking_dictionaries">Failed to check dictionary status</string>
<string name="error_loading_article">Loading error: %1$s</string> <string name="error_loading_article">Loading error: %1$s</string>
@@ -48,6 +52,7 @@
<string name="notification_download_error_title">Download error</string> <string name="notification_download_error_title">Download error</string>
<string name="notification_download_error_text">Failed to download dictionaries</string> <string name="notification_download_error_text">Failed to download dictionaries</string>
<string name="notification_indexing_title">Indexing dictionaries</string> <string name="notification_indexing_title">Indexing dictionaries</string>
<string name="notification_preparing_indexing_title">Preparing dictionaries for indexing</string>
<string name="archive_failed_open_source_file">Failed to open source file</string> <string name="archive_failed_open_source_file">Failed to open source file</string>
<string name="archive_failed_create_output_file">Failed to create output file</string> <string name="archive_failed_create_output_file">Failed to create output file</string>
<string name="archive_failed_delete_file">Failed to delete file: %1$s</string> <string name="archive_failed_delete_file">Failed to delete file: %1$s</string>
@@ -57,7 +62,6 @@
<string name="error_scan_directory_failed">Failed to scan dictionary folder</string> <string name="error_scan_directory_failed">Failed to scan dictionary folder</string>
<string name="error_search_failed">Search failed for query \'%1$s\' in %2$s</string> <string name="error_search_failed">Search failed for query \'%1$s\' in %2$s</string>
<string name="error_failed_load_index_metadata">Failed to load index metadata: %1$s</string> <string name="error_failed_load_index_metadata">Failed to load index metadata: %1$s</string>
<string name="error_loading_more">Loading error</string>
<plurals name="active_dictionaries_count"> <plurals name="active_dictionaries_count">
<item quantity="one">%1$d active dictionary</item> <item quantity="one">%1$d active dictionary</item>
<item quantity="other">%1$d active dictionaries</item> <item quantity="other">%1$d active dictionaries</item>
@@ -65,21 +69,23 @@
<string name="dictionary_management_title">Dictionaries</string> <string name="dictionary_management_title">Dictionaries</string>
<string name="import_dictionary_button">Select files</string> <string name="import_dictionary_button">Select files</string>
<string name="import_error">Failed to import dictionary: %1$s</string> <string name="import_error">Failed to import dictionary: %1$s</string>
<string name="import_file_exists">File already exists: %1$s</string> <string name="import_file_exists">Dictionary already added: %1$s</string>
<string name="import_invalid_file_name">Invalid file name: %1$s</string> <string name="import_invalid_file_name">Invalid file name: %1$s</string>
<string name="import_nothing_imported">No supported dictionary files were found in the selection</string>
<string name="dictionary_source_url_hint">URL</string> <string name="dictionary_source_url_hint">URL</string>
<string name="dictionary_source_add_button">Add source</string> <string name="dictionary_source_add_button">Add source</string>
<string name="dictionary_source_duplicate">This URL already exists</string> <string name="dictionary_source_duplicate">This URL already exists</string>
<string name="dictionary_source_duplicate_in_form">Duplicate URL in the form</string> <string name="dictionary_source_duplicate_in_form">Duplicate URL in the form</string>
<string name="dictionary_source_duplicate_prefix">This dictionary already exists</string> <string name="dictionary_source_duplicate_prefix">This dictionary already exists</string>
<string name="dictionary_source_url_empty">URL cannot be empty</string> <string name="dictionary_source_url_empty">URL cannot be empty</string>
<string name="dictionary_source_invalid_protocol">Must start with http:// or https://</string> <string name="dictionary_source_invalid_protocol">Must start with https://</string>
<string name="dictionary_source_invalid_extension">Must end with .gz, .dsl, or .dz</string> <string name="dictionary_source_invalid_extension">Must end with .gz, .dsl, or .dsl.dz</string>
<string name="dictionary_source_too_long">URL is too long (max 2048 characters)</string> <string name="dictionary_source_too_long">URL is too long (max 2048 characters)</string>
<string name="dictionary_source_invalid_url">Invalid URL format</string> <string name="dictionary_source_invalid_url">Invalid URL format</string>
<string name="dictionary_source_suspicious_characters">URL contains invalid characters</string> <string name="dictionary_source_suspicious_characters">URL contains invalid characters</string>
<string name="dictionaries_not_downloaded">No dictionaries downloaded yet</string> <string name="dictionaries_not_downloaded">No dictionaries downloaded yet</string>
<string name="dictionaries_update_available">Dictionary updates available</string> <string name="dictionaries_update_available">Dictionary updates available</string>
<string name="dictionary_update_available">Update available</string>
<string name="action_cancel">Cancel</string> <string name="action_cancel">Cancel</string>
<string name="action_add">Add</string> <string name="action_add">Add</string>
<string name="action_add_another_url">Add another URL</string> <string name="action_add_another_url">Add another URL</string>
@@ -91,6 +97,7 @@
</plurals> </plurals>
<string name="dictionary_not_indexed">• Not indexed</string> <string name="dictionary_not_indexed">• Not indexed</string>
<string name="dictionary_delete">Delete dictionary</string> <string name="dictionary_delete">Delete dictionary</string>
<string name="error_delete_dictionary">Failed to delete dictionary</string>
<string name="dictionaries_tap_to_update">Tap to update all dictionaries</string> <string name="dictionaries_tap_to_update">Tap to update all dictionaries</string>
<string name="notification_import_title">Importing dictionaries</string> <string name="notification_import_title">Importing dictionaries</string>
<string name="notification_import_success_title">Import completed</string> <string name="notification_import_success_title">Import completed</string>
File renamed without changes.
@@ -0,0 +1,9 @@
# Strip android.util.Log warnings/errors from release builds.
# Debug builds keep full logging for diagnostics.
-assumenosideeffects class android.util.Log {
public static int w(java.lang.String, java.lang.String);
public static int w(java.lang.String, java.lang.String, java.lang.Throwable);
public static int w(java.lang.String, java.lang.Throwable);
public static int e(java.lang.String, java.lang.String);
public static int e(java.lang.String, java.lang.String, java.lang.Throwable);
}
+1 -1
View File
@@ -2,7 +2,7 @@ plugins {
alias(libs.plugins.aboutlibraries) apply false alias(libs.plugins.aboutlibraries) apply false
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.compose) apply false
id("com.github.ben-manes.versions") version "0.54.0" id("com.github.ben-manes.versions") version "0.65.0"
} }
tasks.register("assembleDebug") { tasks.register("assembleDebug") {
dependsOn(":app:assembleDebug") dependsOn(":app:assembleDebug")
@@ -1 +1 @@
- Initial F-Droid release - Initial release
@@ -0,0 +1,6 @@
- Added search history
- The search bar can be placed at the top or bottom of the screen
- Fixed headwords with stress marks displaying truncated in search results
- Improved download, indexing and navigation stability
- The About screen opens faster
- Updated build tooling and code cleanup
@@ -0,0 +1,6 @@
- Search loads all prefix matches; broad prefix searches are much faster
- Fixed indexing progress freezing on large dictionaries
- Large articles open faster
- Numbered results sort in numeric order
- UI improvements
- Updated libraries and build tooling
@@ -0,0 +1,4 @@
- Fixed a crash and lost dictionary state around search results
- Added diacritic-insensitive search, so accented and plain spellings match each other
- Fixed indexing failures on dictionaries with dense non-Latin text
- Search results now keep loading as you scroll instead of stopping early
@@ -0,0 +1 @@
- Fixed the dictionary update card taking too long to reappear after cancelling a download
@@ -0,0 +1,2 @@
- Dictionaries can no longer be deleted while an update is running, which used to lose their update source
- Fixed the dictionary icon sliding outside the row while swiping to delete
@@ -0,0 +1 @@
- Fixed matching source URLs to installed dictionary files, so deleted dictionaries can be downloaded again from the same URL
@@ -0,0 +1,3 @@
- The dictionary update card now returns after a failed download
- Fixed headwords and articles with escaped characters being displayed incorrectly
- Fixed slow rendering of articles containing many bracket characters
@@ -0,0 +1,16 @@
- The app starts faster
- Faster dictionary indexing
- Improved app stability
- Smooth screen transition animations
- Articles render DSL line breaks, indentation and named colors correctly
- Tabs and extra spaces inside articles no longer break the layout
- The article title can be copied, tapping the article clears the selection
- Source URLs accept host:port addresses
- Dictionary downloads retry automatically when the network switches
- Download progress notifications are more reliable
- Search results clear instantly when the query is erased
- Leftover files from cancelled downloads are now removed automatically
- Long link-heavy articles no longer freeze while scrolling
- Large search result lists render faster
- Dark article colors are readable in the dark theme
- Updated build tooling and documentation
@@ -1,4 +1,4 @@
ReSearch is a fast Android dictionary reader for DSL-format dictionaries. Download, import, and search multiple dictionaries with instant article retrieval via DictZip random-access compression. ReSearch is an Android dictionary reader for DSL-format dictionaries. Download, import, and search multiple dictionaries with instant article retrieval via DictZip random-access compression.
Features: Features:
@@ -8,7 +8,7 @@ Features:
- Import .dsl and .dsl.dz files from device storage - Import .dsl and .dsl.dz files from device storage
- Automatic indexing with binary index files for instant search - Automatic indexing with binary index files for instant search
- Multi-charset support — UTF-8, UTF-16 LE/BE auto-detection - Multi-charset support — UTF-8, UTF-16 LE/BE auto-detection
- Adaptive three-pane layout for tablets and large screens - Adaptive two-pane layout for tablets and large screens
- Light, dark, and system-follow themes - Material 3 UI with dynamic color, plus light, dark, and system-follow themes
- English and Russian localization - English and Russian localization
- No tracking, no ads, fully open source - No tracking, no ads, fully open source
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

@@ -1 +1 @@
- Первый релиз для F-Droid - Первый релиз
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more