Compare commits

...
148 Commits
Author SHA1 Message Date
Markus Fisch a48f67ebd5 Advance version number to 1.74.2 2026-06-10 23:08:33 +02:00
Markus Fisch 328d0d704c Fix FAB lifted twice above the soft keyboard
On SDK < 35 the parent's fitsSystemWindows already shrinks the
FAB container above the soft keyboard, because the deprecated
systemWindowInsets include the IME there.

Adding the IME inset to the container as well lifted the FAB
twice, placing it about twice as high as needed.

Only apply the IME inset on SDK 35+, where the window is
edge-to-edge and the parent is not resized for the keyboard.
2026-06-10 18:36:08 +02:00
Markus Fisch dcecec4d6f Make window inset application idempotent
The inset listener applied the status bar padding only once,
guarded by a paddingTop == 0 check, and grew the toolbar height
incrementally. When the first callback reported wrong insets the
layout stayed stuck with stale values, so affected devices only
settled after several rotations.

Recompute the padded toolbar height from a captured bare height
on every callback and always set the nav bar padding, including
back to zero, so the layout converges on the first correct inset
pass.
2026-06-10 21:19:44 +02:00
Markus Fisch 7546cf25c0 Advance version number to 1.74.1 2026-06-09 21:40:48 +02:00
Markus Fisch bedb0a81d6 Update link to BinaryReceptor 2026-06-09 21:40:22 +02:00
Markus Fisch 658988525a Replace vector drawable background wallpaper
With a PNG because the vector drawable apparently causes
rendering issues on MediaTek devices.

See issue #655.
2026-06-09 19:40:41 +02:00
Markus Fisch 8298ce70ef Allow scanning while device is locked
Show the camera and newly scanned results over the lock screen
while keeping history, media selection, and settings protected.
2026-06-08 20:21:05 +02:00
Markus Fisch ae29a4d207 Advance version number to 1.74.0 2026-06-05 20:46:40 +02:00
Markus Fisch 7fe8126703 Run tests for bundle and release targets 2026-06-05 19:04:40 +02:00
Markus Fisch 4adce7c958 Fix splash lifecycle crash
Use a plain Activity for SplashActivity because it only redirects
to the camera screen and finishes immediately.

This avoids AppCompat lifecycle dispatch crashing on older Android
versions when the activity is destroyed before AndroidX moves it
out of INITIALIZED.
2026-06-05 18:20:07 +02:00
Markus Fisch d11dac7f13 Add content type in history rows
And use some of the parsed content for the content text view.
2026-06-05 18:20:07 +02:00
Markus Fisch f90aaac4f9 Show parsed content types in history
Store content types and use it in the history.
2026-06-05 18:20:07 +02:00
Markus Fisch d5ffcabdd2 Rename "Parsed Data" to "Encoded Data"
Which is a better description.
2026-06-04 19:36:01 +02:00
Markus Fisch 52ca14c9ba Move label input to the top of the screen
Which groups the parsed and the raw content together.
2026-06-04 19:30:23 +02:00
Markus Fisch 6c2c7bef90 Add a section header for meta data 2026-06-04 19:13:51 +02:00
Markus Fisch f285e1d200 Add an addField() extension
To simplify all this repetetive `items.add(Field(…))` calls.
2026-06-05 11:53:26 +02:00
Markus Fisch d7e687871c Make title of parsed data a data item
Which is more structured than using it as a headline/label.

Also simplify using the last parsed data when the user edits
the source.
2026-06-05 11:52:27 +02:00
Markus Fisch 5472993e19 Copy hex data from hex dump
Add a tap handler to the hex dump so binary content can be copied
as a continuous hex string directly from the decode screen.
2026-06-04 18:03:29 +02:00
Markus Fisch 702dc75ddd Show open icon for tracking links
Add the open icon to parsed data values that open an external
link so tracking numbers visibly indicate that tapping them opens
the tracking website.
2026-06-04 18:01:06 +02:00
Markus Fisch 45054c80f8 Avoid crop handle overlapping FAB
Cap the default crop handle position at the FAB-safe home
position so square displays do not place it behind the action
button.
2026-06-04 17:53:45 +02:00
bovirusandGitHub 5a899b5eb3 Update Italian language 2026-06-03 18:16:44 +02:00
Markus Fisch 1d002b645d Update ZXingCpp library to v3.0.2.7 2026-06-02 23:16:15 +02:00
Markus Fisch 456da10f84 Complete missing translations 2026-06-02 22:38:09 +02:00
Markus Fisch 7fbf89380c Use default onClick handler for tracking numbers
So the HTML link doesn't interfere with the copy to clipboard
handling.
2026-06-02 00:19:50 +02:00
Markus Fisch 91a549e96e Make Deutsche Post tracking link a data item 2026-06-02 00:12:09 +02:00
Markus Fisch 3ef6f874b8 Skip parsing data if it's a tracking link
If we already know the data contains a tracking link we
don't need to parse the data for something else.

Unfortunately, the Deutsche Post Matrixcode stamp also
parses as an IDL (International Driver License).
2026-06-01 23:55:16 +02:00
Markus Fisch 45661b75ba Use on-surface color for detector overlay
Match the detector overlay to the theme color used by default
for text and icons.
2026-06-01 23:34:40 +02:00
Markus Fisch cd30a86c69 Fix detector center coordinates
Use view-local coordinates for the detector center because
its drawing, touch handling, and crop ROI math all operate
in the view's own coordinate space.

This avoids offset crop overlays when the view is not laid
out at the parent's origin.
2026-06-01 23:23:30 +02:00
Markus Fisch 13eabaee19 Add an option to choose a custom beep tone
As requested in issue #642.
2026-06-01 22:39:11 +02:00
Markus Fisch 2035c935dc Use consts in PreferencesFragment
Instead of duplicated string literals.
2026-06-01 22:36:20 +02:00
Markus Fisch 1ff170a279 Fix FAB positioning
FAB placement was wrong for the traditional three-button
navigation after the Material You migration.

The bottom inset was applied twice.
2026-05-31 21:57:52 +02:00
Markus Fisch 4fab21c88d Support fido:/ URIs
Recognize spec-style fido:/ URIs and open them with ACTION_VIEW.

Note that FIDO:// URIs already worked before this just fine.
2026-05-31 21:46:56 +02:00
Markus Fisch 1ef9f39978 Remove appendUrlArguments tests
Since appendUrlArguments doesn't exist anymore.
2026-05-31 21:45:02 +02:00
NathanandGitHub 5378e50fe9 Update french translation 2026-05-31 19:01:59 +02:00
Markus Fisch c325713a8c Add a preference to set a custom scanner ID
So a device can be identified if desired.
2026-05-28 21:31:38 +02:00
bovirusandMarkus Fisch a043fba948 Update Italian translation 2026-05-27 21:35:53 +02:00
Markus Fisch 386f882944 Advance version number to 1.73.1 2026-05-27 21:28:49 +02:00
Markus Fisch 65979cbddd Fix stale history row highlighting
Remove ListView choice mode so regular history row taps
no longer persist as checked state after returning from
the scan detail screen.
2026-05-27 21:23:44 +02:00
Markus Fisch 26747d709f Update vdstools library 2026-05-27 21:20:19 +02:00
Markus Fisch 22b6a9f66e Add a note to not update the material library yet
Because the latest version would require minSDK 23, which
is silly, but the usual nonsense from Google, unfortunately.
2026-05-27 21:19:00 +02:00
Markus Fisch ded532c523 Update ZXing-C++ and support Telepen format 2026-05-27 21:17:48 +02:00
Markus Fisch 17c49a4a27 Advance version number to 1.73.0 2026-05-26 23:06:19 +02:00
Markus Fisch 7ff8db0880 Fix deeplink argument parsing
Merge intent extras with parsed deeplink query parameters
in EncodeActivity so app links populate content and format
even when Android supplies an empty extras bundle.

This fixes opened /encode links showing an empty content
field.
2026-05-26 22:48:10 +02:00
Markus Fisch d7837f5795 Migrate to Material You and support dynamic colors
And add a preference toggle to use the original colors
if a user likes that better.
2026-05-26 22:28:13 +02:00
Markus Fisch 1f5c77e802 Add preference to toggle tracking param stripping
Make the URL tracking parameter sanitization introduced opt-out
rather than always-on.
2026-05-26 21:35:58 +02:00
Markus Fisch a4da488a41 Refactor "execShareIntent" to "startIntentOrToast"
A better description of what it does.
2026-05-26 21:12:09 +02:00
Markus Fisch d177c08420 Refactor "fired" to "executed"
Which is a better fit, because we already have an "execute"
function.
2026-05-26 21:07:52 +02:00
Markus Fisch a6123ed233 Optimize imports
With Android Studio.
2026-05-26 21:07:17 +02:00
Markus Fisch 2ebb376a8b Remove redundant let scope function 2026-05-26 21:06:45 +02:00
Markus Fisch 6cac7e8ead Simplify sanitizing URIs
Make stripTrackingParams a top-level extension function,
which is a bit simpler and cleaner in my opinion.
2026-05-26 21:04:42 +02:00
Ashutosh TiwariandGitHub 59f6c9f654 Strip common tracking parameters before opening scanned URLs
Many QR codes in the wild contain analytics tracking parameters
(utm_*, fbclid, gclid, mc_*, _ga, igshid, …) that uniquely identify
the scanning user/session. These add nothing for the user and are
silently propagated to the destination site when openUri() launches
ACTION_VIEW.

Add a tiny content/UrlSanitizer helper that filters a curated set of
~30 well-known tracker parameter names from http(s) URIs, and call it
from openUri() so cleanup happens transparently on every URL launch.

The list is intentionally conservative: only parameters whose sole
purpose is third-party attribution. Functional/site parameters
(q, id, v, page, search, …) are left untouched. Non-http(s) URIs and
URIs without a query string are returned as-is, so there's no
behavioral change for tel:, mailto:, geo:, content:, etc.
2026-05-26 18:19:35 +02:00
Markus Fisch f0c1d2a936 Add a note for cameraX and core-ktx
To stay on these versions because the next ones won't
support minSDK 21 anymore.

Idiots at keyboards  ̄\_(ツ)_/ ̄
2026-05-11 21:54:35 +02:00
Markus Fisch e7aff150f6 Update bouncycastle library 2026-05-11 21:53:11 +02:00
Markus Fisch 10755555ff Update vdstools library 2026-05-11 21:53:01 +02:00
Markus Fisch a78a85df2c Update kotlinx-datetime library 2026-05-11 21:51:46 +02:00
Markus Fisch de9c490429 Update kotlin coroutines library 2026-05-11 21:51:17 +02:00
Markus Fisch 77e686af75 Update tools version 2026-05-11 21:46:14 +02:00
Markus Fisch 0ae13f22b6 Complete missing Latvian translations 2026-05-11 21:43:34 +02:00
Too Late (bot)andGitHub bedfcdec14 Translations update from Toolate 2026-05-11 21:37:34 +02:00
bovirusandGitHub a6ba968ea8 Italian language update 2026-05-06 17:21:28 +02:00
Markus Fisch 5301da5900 Add a blank line between parsers
Easier to read.
2026-05-05 17:03:42 +02:00
Markus Fisch cb7d64991b Parse and display otpauth:// code fields
Show type, account, issuer, algorithm, digits, and
period/counter when scanning an OTP code.
2026-05-05 17:03:42 +02:00
Markus Fisch 6194dc29d5 Update tools version 2026-05-05 17:03:42 +02:00
bovirusandGitHub 3fa39b9fe0 Update Italian language 2026-04-24 21:54:41 +02:00
Markus Fisch f37b846241 Advance version number to 1.72.2 2026-04-23 23:40:35 +02:00
Markus Fisch 8ce33c0071 Add predefined expert and simple profiles
So users can always switch between these profiles.
2026-04-23 18:28:51 +02:00
Markus Fisch e6823386b0 Move setCovered to Mapping.kt
It belongs alongside the other frame/view coordinate
helpers (setFrameRoi, setFrameToView).
2026-04-22 23:20:26 +02:00
Markus Fisch 3e57a59e16 Avoid shared camera mapping state
Move ROI and frame mapping data out of shared mutable activity
fields.

Capture view geometry on the UI thread, then build per-frame
mapping data inside the analyzer so decoding, crosshair checks,
and overlay updates use a consistent snapshot.
2026-04-22 22:58:07 +02:00
Markus Fisch 758220200e Fix isSilent() for media stream
Ringer mode is irrelevant for STREAM_MUSIC. Audio plays in
vibrate mode regardless. Check stream volume instead, so beeps
are only suppressed when the media volume is actually zero.
2026-04-22 22:42:59 +02:00
Markus Fisch b3584392b3 Set share QR listener once per ViewHolder
Not per bind. Allocating a new lambda on every getView()
call is wasteful.
2026-04-22 22:12:36 +02:00
Markus Fisch 3bb26f06d1 Import/export preferences using QR codes
Any profile can be exported/imported via a special QR code now.
2026-04-23 16:11:31 +02:00
Markus Fisch fe4a021cd0 Fix CameraX preview teardown leak 2026-04-22 22:02:59 +02:00
Markus Fisch 0de5565b78 Update tools version 2026-04-20 22:04:04 +02:00
Markus Fisch d9a9acd8c1 Update ZXingCpp library to v3.0.2.5 2026-04-21 13:01:01 +02:00
Markus Fisch 1be31f71ed Update ZXingCpp library to v3.0.2.4 2026-04-21 13:00:58 +02:00
Markus Fisch 8859c61119 Advance version number to 1.72.1 2026-04-11 13:06:04 +02:00
Andrew GunnersonandGitHub c52778f59d Fix crash on upgrades due to missing DATA_BAR entry
Commit 73047cdee6 merged the old
migrateToNativeFormatNames() and migrateBarcodeFormatName() mapping
functions, but the new map was based on the former, which happened to be
missing the entry for DATA_BAR.

Fixes: #636

Signed-off-by: Andrew Gunnerson <accounts+github@chiller3.com>
2026-04-11 12:53:08 +02:00
bovirusandGitHub 5bde3947e3 Italian language update 2026-04-10 22:19:16 +02:00
Markus Fisch 08be31814f Advance version number to 1.72.0 2026-04-09 23:05:29 +02:00
Markus Fisch c5db04b84e Add headlines for result sections
Makes result sections easier to digest.
2026-04-09 21:16:27 +02:00
Markus Fisch 9dccc6a7f5 Fix EPC QR parser tests 2026-04-09 16:46:20 +02:00
Markus Fisch a5deab656b Clamp invalid ROIs to prevent crashes
Fixes a crash path where rounding in the view-to-frame
mapping could produce crop rectangles outside the Y plane.
2026-04-09 16:46:20 +02:00
Markus Fisch b773dfa392 Remove double signature date item for IDB codes 2026-04-09 16:46:20 +02:00
Markus Fisch 7b5106f09d Parse JSON content and show pretty-printed dump 2026-04-09 16:46:16 +02:00
Markus Fisch 16ce45093e Use list selector for parsed data items
So there is some touch feedback.
2026-04-09 19:47:44 +02:00
Markus Fisch e6345f09b3 Reformat code 2026-04-09 12:59:04 +02:00
Markus Fisch de00ed779a Update vdstools to latest version
And migrate the code to use the public API only.

This version doesn't include the Kermit logging library
which would have required a minSdk raise.
2026-04-09 10:58:34 +02:00
Markus Fisch d740aa121b Advance version number to 1.71.0 2026-04-08 21:56:07 +02:00
Markus Fisch 38c40d36bb Preserve parsed data while editing
Keep the last successfully parsed data rows visible in the decode
view while the barcode content is being edited. Avoids jumping of
the layout.
2026-04-08 19:49:25 +02:00
Markus Fisch 3e51f70c7c Decode nested IDB messages
Flatten nested IDB messages into seal fields and decode child values
using the schema-defined coding from vdstools. This fixes nested
content such as MRZ fields inside IDB container messages being shown
as raw byte counts instead of decoded text.
2026-04-08 19:11:26 +02:00
Markus Fisch 502702c7a5 Remove unused indent fields 2026-04-08 19:10:03 +02:00
Markus Fisch 96ff9b6239 Change data table to a label/value list
So values have more space and can be read more easily.
2026-04-08 19:10:03 +02:00
Markus Fisch afd6c74c80 Verify IDB barcodes
Using the german CSCA master list.
2026-04-08 19:09:52 +02:00
Markus Fisch 5ffea2edd8 Fix detecting web URLs with a subdomain 2026-04-08 19:50:05 +02:00
Markus Fisch 78d62395e2 Add missing translations for IDL/EPC labels 2026-04-08 19:38:33 +02:00
Markus Fisch a5d1465528 Remove unnecessary EpcQrInfo wrapper class
And return the map directly.
2026-04-08 19:16:19 +02:00
Markus Fisch 14517bad17 Add vdstools to parse VDS and IDB barcodes
Without verifying yet.
2026-04-08 19:14:15 +02:00
Markus Fisch 7a5e07fb3c Handle deep link in onNewIntent for EncodeActivity
Calendar apps reuse the existing task instead of creating a
new one, so Android calls onNewIntent() rather than onCreate().
Without an onNewIntent() override the intent's query parameters
(format, content, execute) were silently ignored.

Extract intent application into applyIntent() and call it from
both onCreate() and a new onNewIntent() override.
2026-04-07 20:11:52 +02:00
Markus Fisch c6b3ca08f4 Restrict scans to horizontal crosshair
Only accept camera scan results that intersect the horizontal
crosshair when crosshairs are enabled. For 1D linear barcodes,
expand collapsed position lines into a narrow band first so the
same rule works reliably for formats that do not report full
symbol bounds.
2026-04-07 21:34:26 +02:00
Markus Fisch 73047cdee6 Simplify old to new format name translation
To have that list only once.
2026-04-07 19:23:56 +02:00
Markus Fisch bec4aa3120 Replace Code39 with the more specific Code39Std
So users get only Code39Std when they restrict reading to
this format.
2026-04-07 18:17:25 +02:00
Markus Fisch eaaaaf14c2 Add barcode format descriptions
To DecodeActivity and EncodeActivity.

Enables/Disables with meta data.
2026-04-07 20:59:10 +02:00
Markus Fisch 971742aff4 Fix barcode format preference summary
Sort the selected barcode formats alphabetically in the
preferences summary and map stored values back to their
display labels.
2026-04-07 15:46:47 +02:00
Markus Fisch 28915eacdb Simplify setting preference summaries 2026-04-06 23:37:35 +02:00
Markus Fisch a2c1d11b5b Include barcode format in history search
So we can use for barcode formats too.
2026-04-06 23:09:04 +02:00
Markus Fisch e9dd396f34 Add a dedicated barcode icon for PDF417 2026-04-07 12:02:08 +02:00
Markus Fisch c7edba746c Advance version number to 1.70.0 2026-04-03 23:39:44 +02:00
Markus Fisch 34c8dfd169 Add barcode format icons in history 2026-04-03 23:34:45 +02:00
bovirusandGitHub 459980899f Italian language update 2026-04-03 23:10:31 +02:00
Markus Fisch 2e0693cb45 Add beep audio stream preference
Let users choose whether confirmation beeps use the media or
notification audio stream, with media as the default.

Recreate the tone generator when the selected stream changes so the
new preference takes effect immediately, and add the required
localized labels for the new setting.
2026-04-03 12:00:14 +02:00
Markus Fisch 187825932e Parse EPC-QR codes and show the data 2026-04-01 21:30:51 +02:00
Markus Fisch a80802b4bc Improve layout of decode screen
Move the things a user cares most about to the top of the
screen, and everything else to the bottom.
2026-04-01 20:58:02 +02:00
Markus Fisch aa589a5d53 Improve size of generated PDF417 barcodes
PDF417 requires a bit more horizontal width because it
has many modules and doesn't fit into a square shape.
2026-04-01 20:20:46 +02:00
Markus Fisch 6a42e54f0b Fix parsing first field of subfile in IDLParser
Per the AAMVA spec, each subfile in the data section starts with its
2-char type marker (e.g. "ZV", "ZI") directly prepended to the first
field, with no separator between them. The key-value parser had no
awareness of this, so the marker was absorbed into the field key
(e.g. "ZVA" was parsed as "ZVZ" with value "VA01" instead of "01").
2026-04-01 19:55:30 +02:00
Markus Fisch e134b18bba Use quiet zone from library
Which uses zint for linear barcodes but avoids it for all
other types because zint doesn't generate reasonable margins
for other barcodes.

This fixes too thin margins for linear barcodes.
2026-03-31 22:27:59 +02:00
bovirusandGitHub a112484dcb Italian language update 2026-03-27 12:16:26 +01:00
Markus Fisch 4f36030e38 Advance version number to 1.69.1 2026-03-26 19:35:24 +01:00
Yurt PageandMarkus Fisch 90c2c03d12 Update translations 2026-03-26 19:30:24 +01:00
Markus Fisch 3f853be51a Advance version number to 1.69.0 2026-03-26 19:30:03 +01:00
Markus Fisch 835a348fad Fix confirm beep by switching to STREAM_MUSIC
STREAM_NOTIFICATION volume can be zero independently of ringer
mode, so isSilent() would pass but no sound played. STREAM_MUSIC
is the correct stream for in-app sound effects and is not                       affected by this.
2026-03-26 19:30:03 +01:00
Markus Fisch 198a35fc15 Clean up fragments to activities transition 2026-03-26 19:30:03 +01:00
Markus Fisch 6be1b25c38 Add format restrictions to automated actions
And ignored codes too.
2026-03-26 19:30:03 +01:00
Markus Fisch ac632e3aac Restrict searching to a specific format 2026-03-26 19:30:03 +01:00
Markus Fisch ddc5a14558 Clean up naming of search term dialog 2026-03-26 19:30:01 +01:00
Markus Fisch 8b1ffe3e5a Add preference for codes that should not be saved
So short-lived one-shot links like FIDO:// URLs don't
end up in the history.
2026-03-26 19:29:19 +01:00
Markus Fisch e9ba66848f Add an option to show crosshairs 2026-03-26 19:29:19 +01:00
Markus Fisch 7bdef27a50 Pin history items
So it's easy to keep important scans at the top.
2026-03-23 22:14:26 +01:00
Markus Fisch ccd16a0f95 Improve preference icons
And add better ones where appropriate.
2026-03-21 17:51:02 +01:00
Markus Fisch a385678de1 Optimize vector drawables 2026-03-21 17:32:31 +01:00
Markus Fisch 43c597f6fb Add leak canary for debugging 2026-03-21 17:28:09 +01:00
Markus Fisch ecccf34418 Advance version number to 1.68.7 2026-03-20 21:56:20 +01:00
Markus Fisch 4027ce2cc6 Reformat new code 2026-03-20 21:56:07 +01:00
Markus Fisch 209e6c5c30 Add icons to preferences screen
Assign existing action icons to preference rows in the settings
screen so related options are easier to scan.
2026-03-20 21:55:37 +01:00
Markus Fisch 5e29f3fbe9 Update kotlin version 2026-03-20 21:26:05 +01:00
Markus Fisch ba3d63277a Update gradle wrapper 2026-03-20 21:15:56 +01:00
Markus Fisch fe34b7209d Fix quiet zone for recreated barcodes
Older history entries can miss the stored symbol matrix and
therefore fall back to re-encoding the barcode content.

After the ZXingCpp 3 migration, that fallback used a zero margin,
so recreated barcodes from those records lost their quiet zone.
2026-03-20 21:08:20 +01:00
Markus Fisch 0efdca7b4a Remove unnecessary null checking of error message
Because it's always non-null now.
2026-03-20 15:46:38 +01:00
Markus Fisch 827fbedc1f Fix automatic screen brightening
And remove lateinit from `brightenScreenItem` to check
if it's null or not.

Also delay automatic brightening to give Android a bit
more time to sort things out.
2026-03-20 15:46:38 +01:00
Markus Fisch 107b5bafd4 Update history listing on resume
To immediately show label changes, for example.
2026-03-20 15:46:38 +01:00
Markus Fisch 19a71480fa Update tools version 2026-03-20 15:46:38 +01:00
Markus Fisch 909e97a591 Map zoom bar to zoom ratio instead of linear zoom
Linear zoom 0 switches to the ultra-wide lens on devices
that support it, causing blurry images. Mapping to zoom ratios
from 1.0x to maxZoomRatio keeps the main lens in use.
2026-03-17 21:54:34 +01:00
Markus Fisch bc2be96871 Simplify updating the flash FAB
It's much simpler if we invert the availability parameter.
2026-03-20 10:53:39 +01:00
Markus Fisch d834621d99 Reset stored zoom level and always start at 0
Because the previous zoom levels were for the legacy camera API
and they don't carry over to CameraX.

With CameraX, it's also better to start with no zoom.
2026-03-20 10:53:39 +01:00
Markus Fisch ad83be1a60 Use ResolutionStrategy.HIGHEST_AVAILABLE_STRATEGY
Which greatly improves the initial sharpness of the camera image.

Without this, despite autofocus, the initial camera frame can be
a bit blurry on some devices.
2026-03-20 10:53:39 +01:00
Markus Fisch d65ea02b49 Focus on taps but not on swipes 2026-03-20 10:53:32 +01:00
Markus Fisch c8d1357ceb Use CONTINUOUS_PICTURE AF
CameraX defaults to CONTINUOUS_VIDEO autofocus, which trades
focus accuracy for smooth transitions. For barcode scanning we
need the sharpest possible lock, so switch to CONTINUOUS_PICTURE.

But this still isn't enough to have the same quick autofocus the
legacy API had.
2026-03-20 10:52:44 +01:00
Markus Fisch 4c05d046e6 Set CONTROL_SCENE_MODE_BARCODE
To configure the camera for barcode reading (macro focus range,
disabled stabilisation). Same way the legacy Camera API's
SCENE_MODE_BARCODE did.

Unfortunately, this mode isn't supported by some camera drivers
and it doesn't really improve focusing near barcodes on my test
devices.
2026-03-20 10:52:44 +01:00
Markus Fisch 4423ee62c8 Migrate to CameraX
Since the app is on minSDK 21 now, and because some camera
drivers apparently don't support the torch feature through
the legacy API.
2026-03-20 10:52:21 +01:00
226 changed files with 9608 additions and 1894 deletions
+82
View File
@@ -1,5 +1,87 @@
# Change Log
## 1.74.2
* Fix layout for some Android versions
## 1.74.1
* Allow scanning while device is locked
* Fix rendering bug for some GPUs
## 1.74.0
* Add an option to choose a custom beep tone
* Add an option to set a custom scanner ID
* Improve display of content type
* Support fido:/ URIs
* Update Italian and French translations
* Update ZXingCpp
* Fix FAB and crop handle positioning
* Fix splash screen for some Android versions
## 1.73.1
* Update ZXingCpp and support Telepen format
* Update vdstools
* Fix stale history row highlighting
## 1.73.0
* Support Material You and Dynamic Colors
* Strip common tracking parameters before opening scanned URLs
* Parse and display otpauth:// code fields
* Update Italian and Latvian language
* Update BouncyCastle and vdstools library
* Fix deeplink argument parsing
## 1.72.2
* Import/export preferences using QR codes
* Add predefined expert and simple profiles
* Update ZXingCpp library
* Fix beeping on scans
## 1.72.1
* Fix migrating DATA_BAR entries
* Italian language update
## 1.72.0
* Improve result screen
* Update vdstools
* Fix camera initialization
## 1.71.0
* Parse VDS and IDB barcodes
* Restrict scanning to horizontal crosshair if set
* Include barcode format in history search
* Add a dedicated barcode icon for PDF417
* Add barcode format descriptions
* Fix handling deep links
* Fix detecting web URLs
## 1.70.0
* Add barcode format icons in history
* Add beep audio stream preference
* Parse EPC-QR codes and show the data
* Improve layout of decode screen
* Fix parsing first field of subfile in IDLParser
* Use quiet zone from ZXingCpp
* Italian language update
## 1.69.1
* Update translations
## 1.69.0
* Add an option to show crosshairs
* Add preference for codes that should not be saved
* Add option to restrict searching to a specific format
* Pin history items
* Improve preference icons
* Optimize vector drawables
* Fix beeping when phone is set to Vibrate mode
## 1.68.7
* Add icons to preferences screen
* Update history listing on resume
* Fix quiet zone for recreated barcodes
* Fix automatic screen brightening
* Migrate to CameraX
## 1.68.6
* Update ZXingCpp
* Ask for confirmation before deleting a scan from the result screen
+2 -2
View File
@@ -5,10 +5,10 @@ all: debug install start
debug:
./gradlew assembleDebug
release: lint
release: lint test
./gradlew assembleRelease
bundle: lint
bundle: lint test
./gradlew bundleRelease
lint:
+3 -1
View File
@@ -59,6 +59,7 @@ ZXing can read the following barcode formats:
* [ITF][itf]
* [MAXICODE][maxicode] (partial)
* [PDF417][pdf417]
* [Telepen][telepen]
* [QR CODE][qr_code]
* [Micro QR Code][micro_qr_code]
* [rMQR Code][rmqr_code]
@@ -186,7 +187,7 @@ In order to set up Bluetooth forwarding to a computer:
1. Pair your devices (phone and pc) if not paired already
2. Download and run a companion app:
- Windows: https://github.com/KamaleiZestri/BinaryReceptorWindows
- Windows: https://github.com/KamaleiZestri/BinaryReceptor
- Linux: https://github.com/sean666888/bin_eye_bt_receiver
3. In the settings of Binary Eye, enable "Forward scans with Bluetooth"
and select the appropriate target host device that is running the
@@ -207,6 +208,7 @@ In order to set up Bluetooth forwarding to a computer:
[itf]: https://en.wikipedia.org/wiki/Interleaved_2_of_5
[maxicode]: https://en.wikipedia.org/wiki/MaxiCode
[pdf417]: https://en.wikipedia.org/wiki/PDF417
[telepen]: https://en.wikipedia.org/wiki/Telepen
[qr_code]: https://en.wikipedia.org/wiki/QR_code
[micro_qr_code]: https://en.wikipedia.org/wiki/QR_code#Micro_QR_code
[rmqr_code]: https://www.qrcode.com/en/codes/rmqr.html
+17 -7
View File
@@ -8,8 +8,8 @@ android {
minSdk 21
targetSdk sdk_version
versionCode 160
versionName '1.68.6'
versionCode 173
versionName '1.74.2'
}
signingConfigs {
@@ -67,20 +67,30 @@ android {
dependencies {
// Kotlin
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2"
implementation "androidx.core:core-ktx:1.17.0"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0"
implementation 'org.jetbrains.kotlinx:kotlinx-datetime:0.8.0'
implementation "androidx.core:core-ktx:1.17.0" // Keep for minSDK 21.
// Support
implementation "androidx.appcompat:appcompat:1.7.1"
implementation "com.google.android.material:material:1.13.0"
implementation "com.google.android.material:material:1.13.0" // Keep for minSDK 21.
implementation "androidx.preference:preference:1.2.1"
// External
implementation 'com.github.markusfisch:CameraView:1.10.0'
var camerax_version = '1.4.2' // Keep for minSDK 21.
implementation "androidx.camera:camera-core:$camerax_version"
implementation "androidx.camera:camera-camera2:$camerax_version"
implementation "androidx.camera:camera-lifecycle:$camerax_version"
implementation "androidx.camera:camera-view:$camerax_version"
implementation 'com.github.markusfisch:ScalingImageView:1.4.3'
implementation 'com.github.markusfisch:zxing-cpp:v3.0.2.1'
implementation 'com.github.markusfisch:zxing-cpp:v3.0.2.7'
implementation 'de.tsenger:vdstools:0.17.0'
implementation 'org.bouncycastle:bcpkix-jdk15to18:1.84'
// Testing
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.robolectric:robolectric:4.16.1'
// Debugging
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.14'
}
+2
View File
@@ -3,3 +3,5 @@
-dontwarn android.support.v8.**
-keep class android.support.v7.widget.SearchView { *; }
-keep class de.tsenger.vdstools.** { *; }
+3
View File
@@ -148,6 +148,9 @@
<activity
android:name=".activity.AutomatedActionsActivity"
android:exported="false"/>
<activity
android:name=".activity.IgnoreCodesActivity"
android:exported="false"/>
<activity
android:name=".activity.NetworkSuggestionsActivity"
android:exported="false"/>
Binary file not shown.
@@ -2,15 +2,15 @@ package de.markusfisch.android.binaryeye.actions
import android.content.Context
import android.content.Intent
import de.markusfisch.android.binaryeye.content.execShareIntent
import de.markusfisch.android.binaryeye.content.openUrl
import de.markusfisch.android.binaryeye.content.startIntentOrToast
import de.markusfisch.android.binaryeye.widget.toast
abstract class Action {
abstract val iconResId: Int
abstract val titleResId: Int
var fired: Boolean = false
var executed: Boolean = false
abstract fun canExecuteOn(data: ByteArray): Boolean
abstract suspend fun execute(context: Context, data: ByteArray)
@@ -24,7 +24,7 @@ abstract class IntentAction : Action() {
if (intent == null) {
context.toast(errorMsg)
} else {
fired = context.execShareIntent(intent)
executed = context.startIntentOrToast(intent)
}
}
@@ -52,6 +52,6 @@ abstract class SchemeAction : Action() {
}
final override suspend fun execute(context: Context, data: ByteArray) {
fired = context.openUrl(String(data))
executed = context.openUrl(String(data))
}
}
@@ -1,5 +1,6 @@
package de.markusfisch.android.binaryeye.actions
import de.markusfisch.android.binaryeye.actions.fido.FidoAction
import de.markusfisch.android.binaryeye.actions.mail.MailAction
import de.markusfisch.android.binaryeye.actions.mail.MatMsgAction
import de.markusfisch.android.binaryeye.actions.otpauth.OtpauthAction
@@ -17,6 +18,7 @@ object ActionRegistry {
private val REGISTRY: Set<Action> = setOf(
MailAction,
MatMsgAction,
FidoAction,
OtpauthAction,
SmsAction,
TelAction,
@@ -0,0 +1,23 @@
package de.markusfisch.android.binaryeye.actions.fido
import android.content.Context
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.actions.Action
import de.markusfisch.android.binaryeye.content.openUrl
object FidoAction : Action() {
private val fidoUriRegex = "^fido:/[0-9]+$".toRegex(
RegexOption.IGNORE_CASE
)
override val iconResId: Int = R.drawable.ic_action_open
override val titleResId: Int = R.string.open_url
override fun canExecuteOn(data: ByteArray): Boolean {
return String(data).trim().matches(fidoUriRegex)
}
override suspend fun execute(context: Context, data: ByteArray) {
executed = context.openUrl(String(data).trim())
}
}
@@ -15,7 +15,7 @@ object OpenOrSearchAction : Action() {
override fun canExecuteOn(data: ByteArray): Boolean = false
override suspend fun execute(context: Context, data: ByteArray) {
fired = openUrlOrSearch(context, String(data))
executed = openUrlOrSearch(context, String(data))
}
}
@@ -7,7 +7,7 @@ import de.markusfisch.android.binaryeye.content.openUrl
object WebAction : Action() {
private val colloquialRegex =
"^(URL:[ ]*)*(http[s]*://)*[A-Za-z0-9-]{3,}\\.[A-Za-z]{2,}[^ \t\r\n]*$".toRegex()
"^(URL:[ ]*)*(http[s]*://)*[A-Za-z0-9.-]{3,}\\.[A-Za-z]{2,}[^ \t\r\n]*$".toRegex()
override val iconResId: Int = R.drawable.ic_action_open
override val titleResId: Int = R.string.open_url
@@ -24,6 +24,6 @@ object WebAction : Action() {
if (!url.startsWith("http") && !url.startsWith("ftp")) {
url = "http://${url}"
}
fired = context.openUrl(url)
executed = context.openUrl(url)
}
}
@@ -23,12 +23,12 @@ object WifiAction : Action() {
override suspend fun execute(context: Context, data: ByteArray) {
withContext(Dispatchers.IO) {
fired = WifiConnector.parse(String(data))?.let {
executed = WifiConnector.parse(String(data))?.let {
WifiConnector.addNetwork(context, it)
} ?: false
withContext(Dispatchers.Main) {
context.toast(
if (fired) {
if (executed) {
R.string.wifi_added
} else {
R.string.wifi_config_failed
@@ -2,12 +2,11 @@ package de.markusfisch.android.binaryeye.activity
import android.content.Context
import android.content.pm.PackageManager
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import androidx.annotation.LayoutRes
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.isNotEmpty
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.app.PERMISSION_LOCATION
import de.markusfisch.android.binaryeye.app.PERMISSION_WRITE
import de.markusfisch.android.binaryeye.app.applyLocale
@@ -15,15 +14,14 @@ import de.markusfisch.android.binaryeye.app.permissionGrantedCallback
import de.markusfisch.android.binaryeye.app.prefs
import de.markusfisch.android.binaryeye.view.initBars
abstract class ScreenActivity : AppCompatActivity() {
abstract class AbstractBaseActivity : AppCompatActivity() {
override fun attachBaseContext(base: Context?) {
base?.applyLocale(prefs.customLocale)
super.attachBaseContext(base)
}
override fun onCreate(state: Bundle?) {
super.onCreate(state)
setContentView(R.layout.activity_main)
protected fun setScreenContentView(@LayoutRes layoutId: Int) {
setContentView(layoutId)
initBars()
supportActionBar?.setDisplayHomeAsUpEnabled(true)
}
@@ -63,6 +61,5 @@ abstract class ScreenActivity : AppCompatActivity() {
protected open fun onCreateOptionsMenu(
menu: Menu,
inflater: MenuInflater
) {
}
) = Unit
}
@@ -1,6 +1,5 @@
package de.markusfisch.android.binaryeye.activity
import android.app.AlertDialog
import android.app.Dialog
import android.content.Context
import android.os.Bundle
@@ -10,34 +9,32 @@ import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.ListView
import android.widget.RadioGroup
import android.widget.Spinner
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.adapter.prettifyFormatName
import de.markusfisch.android.binaryeye.app.prefs
import de.markusfisch.android.binaryeye.automation.AutomatedAction
import de.markusfisch.android.binaryeye.view.setPaddingFromWindowInsets
import de.markusfisch.android.binaryeye.view.systemBarListViewScrollListener
class AutomatedActionsActivity : ScreenActivity() {
class AutomatedActionsActivity : AbstractBaseActivity() {
private val actions = ArrayList<AutomatedAction>()
private lateinit var adapter: ActionsAdapter
override fun onCreate(state: Bundle?) {
super.onCreate(state)
setScreenContentView(R.layout.activity_automated_actions)
setTitle(R.string.automated_actions)
val frame = findViewById(R.id.content_frame) as ViewGroup
val view = layoutInflater.inflate(
R.layout.fragment_automated_actions,
frame,
false
)
frame.addView(view)
actions.clear()
actions.addAll(prefs.automatedActions)
val listView = view.findViewById<ListView>(R.id.actions)
listView.emptyView = view.findViewById(R.id.no_actions)
val listView = findViewById<ListView>(R.id.actions)
listView.emptyView = findViewById(R.id.no_actions)
adapter = ActionsAdapter(this, actions)
listView.adapter = adapter
listView.setOnScrollListener(systemBarListViewScrollListener)
@@ -49,11 +46,11 @@ class AutomatedActionsActivity : ScreenActivity() {
true
}
view.findViewById<View>(R.id.add).setOnClickListener {
findViewById<View>(R.id.add).setOnClickListener {
editAction(null)
}
view.findViewById<View>(R.id.inset_layout).setPaddingFromWindowInsets()
findViewById<View>(R.id.inset_layout).setPaddingFromWindowInsets(bottom = false)
listView.setPaddingFromWindowInsets()
}
@@ -64,6 +61,7 @@ class AutomatedActionsActivity : ScreenActivity() {
null
)
val regexView = view.findViewById<EditText>(R.id.regex)
val formatView = view.findViewById<Spinner>(R.id.format)
val urlView = view.findViewById<EditText>(R.id.url_template)
val typeGroup = view.findViewById<RadioGroup>(R.id.action_type)
val urlSection = view.findViewById<View>(R.id.url_section)
@@ -71,9 +69,31 @@ class AutomatedActionsActivity : ScreenActivity() {
val intentUriView = view.findViewById<EditText>(
R.id.intent_uri_template
)
val names = resources.getStringArray(
R.array.barcode_formats_names
).toMutableList().apply {
add(0, getString(R.string.all_formats))
}
val values = resources.getStringArray(
R.array.barcode_formats_values
).toMutableList().apply {
add(0, "")
}
formatView.adapter = ArrayAdapter(
this,
android.R.layout.simple_spinner_item,
names
).apply {
setDropDownViewResource(
android.R.layout.simple_spinner_dropdown_item
)
}
if (action != null) {
regexView.setText(action.pattern)
formatView.setSelection(
values.indexOf(action.format ?: "").coerceAtLeast(0)
)
when (action.type) {
AutomatedAction.Type.Intent -> {
urlView.setText(action.urlTemplate ?: "")
@@ -83,6 +103,8 @@ class AutomatedActionsActivity : ScreenActivity() {
intentUriView.setText(action.intentUriTemplate ?: "")
}
}
} else {
formatView.setSelection(0)
}
fun updateTypeSections(checkedId: Int) {
@@ -104,7 +126,7 @@ class AutomatedActionsActivity : ScreenActivity() {
typeGroup.check(initialTypeId)
updateTypeSections(initialTypeId)
val dialog = AlertDialog.Builder(this)
val dialog = MaterialAlertDialogBuilder(this)
.setTitle(
if (action == null) {
R.string.automated_action_add
@@ -122,6 +144,8 @@ class AutomatedActionsActivity : ScreenActivity() {
addOrUpdateAction(
position,
regexView,
formatView,
values,
urlView,
typeGroup,
intentUriView,
@@ -136,6 +160,8 @@ class AutomatedActionsActivity : ScreenActivity() {
private fun addOrUpdateAction(
position: Int?,
regexView: EditText,
formatView: Spinner,
values: List<String>,
urlView: EditText,
typeGroup: RadioGroup,
intentUriView: EditText,
@@ -153,6 +179,9 @@ class AutomatedActionsActivity : ScreenActivity() {
return
}
val isIntent = typeGroup.checkedRadioButtonId == R.id.action_type_intent
val format = formatView.selectedItemPosition.let {
values[it].ifEmpty { null }
}
val newAction = if (isIntent) {
val urlTemplate = urlView.text.toString().trim()
if (urlTemplate.isEmpty()) {
@@ -162,6 +191,7 @@ class AutomatedActionsActivity : ScreenActivity() {
AutomatedAction(
pattern = pattern,
type = AutomatedAction.Type.Intent,
format = format,
urlTemplate = urlTemplate
)
} else {
@@ -185,6 +215,7 @@ class AutomatedActionsActivity : ScreenActivity() {
AutomatedAction(
pattern = pattern,
type = AutomatedAction.Type.CustomIntent,
format = format,
intentUriTemplate = intentUriTemplate
)
}
@@ -199,7 +230,7 @@ class AutomatedActionsActivity : ScreenActivity() {
}
private fun confirmRemoveAction(position: Int) {
AlertDialog.Builder(this)
MaterialAlertDialogBuilder(this)
.setMessage(R.string.automated_action_remove_confirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
actions.removeAt(position)
@@ -250,6 +281,9 @@ class AutomatedActionsActivity : ScreenActivity() {
)
}
}
action.format?.let { format ->
subtitle.text = "${subtitle.text} (${format.prettifyFormatName()})"
}
}
return view
}
@@ -1,7 +1,6 @@
package de.markusfisch.android.binaryeye.activity
import android.annotation.SuppressLint
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
@@ -12,11 +11,11 @@ import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import android.widget.ImageView
import android.widget.SeekBar
import android.widget.TextView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.app.db
import de.markusfisch.android.binaryeye.app.hasWritePermission
@@ -54,7 +53,7 @@ import java.util.Locale
import kotlin.math.max
import kotlin.math.min
class BarcodeActivity : ScreenActivity() {
class BarcodeActivity : AbstractBaseActivity() {
private enum class FileType {
PNG, JPG, SVG, TXT
}
@@ -64,26 +63,20 @@ class BarcodeActivity : ScreenActivity() {
private lateinit var imageView: ConfinedScalingImageView
private lateinit var barcode: Barcode<*>
private lateinit var addToHistoryItem: MenuItem
private lateinit var brightenScreenItem: MenuItem
private var contentView: View? = null
private var addToHistoryItem: MenuItem? = null
private var brightenScreenItem: MenuItem? = null
private var currentBrightness = -1f
override fun onCreate(state: Bundle?) {
super.onCreate(state)
setScreenContentView(R.layout.activity_barcode)
setTitle(R.string.view_barcode)
val frame = findViewById(R.id.content_frame) as ViewGroup
val view = layoutInflater.inflate(
R.layout.fragment_barcode,
frame,
false
)
frame.addView(view)
contentView = view
contentView = findViewById(android.R.id.content)
// Make `imageView` available for `onPause()`.
imageView = view.findViewById(R.id.barcode)
imageView = findViewById(R.id.barcode)
val bitmap: Bitmap
try {
@@ -97,9 +90,7 @@ class BarcodeActivity : ScreenActivity() {
if (message.isNullOrEmpty()) {
message = getString(R.string.error_encoding_barcode)
}
message?.let {
toast(message)
}
toast(message)
finish()
return
}
@@ -121,7 +112,7 @@ class BarcodeActivity : ScreenActivity() {
)
}
view.findViewById<View>(R.id.share).setOnClickListener {
findViewById<View>(R.id.share).setOnClickListener {
it.context?.apply {
pickFileType(R.string.share_as) { fileType ->
shareAs(fileType)
@@ -129,8 +120,8 @@ class BarcodeActivity : ScreenActivity() {
}
}
view.findViewById<View>(R.id.inset_layout).setPaddingFromWindowInsets()
imageView.doOnApplyWindowInsets { _, insets ->
findViewById<View>(R.id.inset_layout).setPaddingFromWindowInsets(bottom = false)
imageView.doOnApplyWindowInsets { _, insets, _ ->
imageView.insets.set(insets)
}
}
@@ -163,7 +154,7 @@ class BarcodeActivity : ScreenActivity() {
content,
barcodeFormat,
getInt(EC_LEVEL),
getInt(MARGIN),
getBoolean(ADD_QUIET_ZONE),
colors
)
}
@@ -173,9 +164,9 @@ class BarcodeActivity : ScreenActivity() {
super.onResume()
if (prefs.brightenScreen) {
// Post to make sure brightenScreenItem is initialized.
contentView?.post {
contentView?.postDelayed({
brightenScreen()
}
}, 500)
}
}
@@ -207,7 +198,7 @@ class BarcodeActivity : ScreenActivity() {
barcode.bitmap(),
barcode.format
)
addToHistoryItem.isVisible = false
addToHistoryItem?.isVisible = false
toast(R.string.added_to_history)
true
}
@@ -266,7 +257,7 @@ class BarcodeActivity : ScreenActivity() {
action: (FileType) -> Unit
) {
val fileTypes = FileType.entries.toTypedArray()
AlertDialog.Builder(this)
MaterialAlertDialogBuilder(this)
.setTitle(title)
.setItems(fileTypes.map { it.name }.toTypedArray()) { _, which ->
action(fileTypes[which])
@@ -290,7 +281,7 @@ class BarcodeActivity : ScreenActivity() {
editText.setText(
encodeFileName("${barcode.format}_${barcode.content}")
)
AlertDialog.Builder(this)
MaterialAlertDialogBuilder(this)
.setView(view)
.setPositiveButton(android.R.string.ok) { _, _ ->
val fileName = editText.text.toString()
@@ -336,7 +327,7 @@ class BarcodeActivity : ScreenActivity() {
val sizeView = view.findViewById<TextView>(R.id.size_display)
val sizeBarView = view.findViewById<SeekBar>(R.id.size_bar)
sizeBarView.initSizeBar(sizeView)
AlertDialog.Builder(this)
MaterialAlertDialogBuilder(this)
.setView(view)
.setPositiveButton(android.R.string.ok) { _, _ ->
write(getSize(sizeBarView.progress))
@@ -435,16 +426,19 @@ class BarcodeActivity : ScreenActivity() {
}
private fun brightenScreen() {
if (isFinishing || isDestroyed) {
return
}
currentBrightness = getScreenBrightness()
setScreenBrightness(1f)
brightenScreenItem.isChecked = true
brightenScreenItem?.isChecked = true
}
private fun restoreScreenBrightness() {
if (currentBrightness > -1f) {
setScreenBrightness(currentBrightness)
currentBrightness = -1f
brightenScreenItem.isChecked = false
brightenScreenItem?.isChecked = false
}
}
@@ -456,7 +450,7 @@ class BarcodeActivity : ScreenActivity() {
private const val CONTENT_RAW = "content_raw"
private const val FORMAT = "format"
private const val EC_LEVEL = "ec_level"
private const val MARGIN = "margin"
private const val ADD_QUIET_ZONE = "add_quiet_zone"
private const val COLORS = "colors"
private const val MIME_PNG = "image/png"
private const val MIME_JPG = "image/jpeg"
@@ -470,9 +464,9 @@ class BarcodeActivity : ScreenActivity() {
): Intent {
val args = Bundle()
when (barcode) {
is ContentBarcode -> args.putEcLevelMargin(
is ContentBarcode -> args.putEcLevelQuietZone(
barcode.ecLevel,
barcode.margin
barcode.addQuietZone
)
is BitMatrixBarcode -> {
@@ -496,12 +490,12 @@ class BarcodeActivity : ScreenActivity() {
content: T,
format: BarcodeFormat,
ecLevel: Int = -1,
margin: Int = 0,
addQuietZone: Boolean = false,
colors: Int = 0
): Intent {
val args = Bundle()
args.putContentFormatColors(content, format, colors)
args.putEcLevelMargin(ecLevel, margin)
args.putEcLevelQuietZone(ecLevel, addQuietZone)
return Intent(context, BarcodeActivity::class.java).apply {
putExtras(args)
}
@@ -531,12 +525,12 @@ class BarcodeActivity : ScreenActivity() {
putInt(COLORS, colors)
}
private fun Bundle.putEcLevelMargin(
private fun Bundle.putEcLevelQuietZone(
ecLevel: Int,
margin: Int
addQuietZone: Boolean
) {
putInt(EC_LEVEL, ecLevel)
putInt(MARGIN, margin)
putBoolean(ADD_QUIET_ZONE, addQuietZone)
}
}
}
@@ -591,7 +585,10 @@ private fun readAndAddToHistory(
formats = setOf(format)
)
)?.first {
db.insertScan(it.toScan())
val scan = it.toScan()
if (!prefs.shouldIgnoreHistoryContent(scan.text, scan.format.name)) {
db.insertScan(scan)
}
true
}
}
File diff suppressed because it is too large Load Diff
@@ -1,52 +1,46 @@
package de.markusfisch.android.binaryeye.activity
import android.app.Activity
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.content.res.ColorStateList
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.DocumentsContract
import androidx.core.net.toUri
import com.google.android.material.floatingactionbutton.FloatingActionButton
import android.text.Editable
import android.text.Html
import android.text.Spannable
import android.text.SpannableString
import android.text.TextWatcher
import android.text.method.LinkMovementMethod
import android.text.style.TypefaceSpan
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TableLayout
import android.widget.TableRow
import android.widget.TextView
import androidx.core.widget.TextViewCompat
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.floatingactionbutton.FloatingActionButton
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.actions.ActionRegistry
import de.markusfisch.android.binaryeye.actions.mail.MatMsg
import de.markusfisch.android.binaryeye.actions.mail.MatMsgAction
import de.markusfisch.android.binaryeye.actions.vtype.VTypeParser
import de.markusfisch.android.binaryeye.actions.vtype.vcard.VCardAction
import de.markusfisch.android.binaryeye.actions.vtype.vevent.VEventAction
import de.markusfisch.android.binaryeye.actions.web.WebAction
import de.markusfisch.android.binaryeye.actions.wifi.WifiAction
import de.markusfisch.android.binaryeye.actions.wifi.WifiConnector
import de.markusfisch.android.binaryeye.adapter.prettifyFormatName
import de.markusfisch.android.binaryeye.adapter.toFormatDescriptionResId
import de.markusfisch.android.binaryeye.app.db
import de.markusfisch.android.binaryeye.app.hasLocationPermission
import de.markusfisch.android.binaryeye.app.hasWritePermission
import de.markusfisch.android.binaryeye.app.prefs
import de.markusfisch.android.binaryeye.content.ContentBarcode
import de.markusfisch.android.binaryeye.content.IdlParser
import de.markusfisch.android.binaryeye.content.ParsedContentType
import de.markusfisch.android.binaryeye.content.copyToClipboard
import de.markusfisch.android.binaryeye.content.idlToRes
import de.markusfisch.android.binaryeye.content.openUrl
import de.markusfisch.android.binaryeye.content.parseData
import de.markusfisch.android.binaryeye.content.shareAsFile
import de.markusfisch.android.binaryeye.content.shareText
import de.markusfisch.android.binaryeye.content.toBarcode
@@ -59,6 +53,7 @@ import de.markusfisch.android.binaryeye.io.askForFileName
import de.markusfisch.android.binaryeye.io.toSaveResult
import de.markusfisch.android.binaryeye.io.writeExternalFile
import de.markusfisch.android.binaryeye.net.createEncodeDeeplink
import de.markusfisch.android.binaryeye.os.showWhenLocked
import de.markusfisch.android.binaryeye.view.hideSoftKeyboard
import de.markusfisch.android.binaryeye.view.setPaddingFromWindowInsets
import de.markusfisch.android.binaryeye.widget.toast
@@ -71,14 +66,21 @@ import kotlinx.coroutines.withContext
import java.io.File
import java.security.MessageDigest
import kotlin.math.roundToInt
import de.markusfisch.android.binaryeye.content.ParsedField as Field
class DecodeActivity : ScreenActivity() {
class DecodeActivity : AbstractBaseActivity() {
private lateinit var labelHeadlineView: TextView
private lateinit var contentView: EditText
private lateinit var formatView: TextView
private lateinit var dataView: TableLayout
private lateinit var contentHeadlineView: TextView
private lateinit var characterCountView: TextView
private lateinit var dataHeadlineView: TextView
private lateinit var formatHeadlineView: TextView
private lateinit var dataView: LinearLayout
private lateinit var metaHeadlineView: TextView
private lateinit var metaView: TableLayout
private lateinit var hexHeadlineView: TextView
private lateinit var hexView: TextView
private lateinit var stampView: TextView
private lateinit var formatDescriptionView: TextView
private lateinit var recreationView: ImageView
private lateinit var labelView: EditText
private lateinit var fab: FloatingActionButton
@@ -98,6 +100,8 @@ class DecodeActivity : ScreenActivity() {
private var action = ActionRegistry.DEFAULT_ACTION
private var isBinary = false
private var originalBytes: ByteArray = ByteArray(0)
private var lastParsedDataItems: List<Field> = emptyList()
private var showingDimmed = false
private var label: String? = null
private var recreationSize = 0
@@ -119,14 +123,11 @@ class DecodeActivity : ScreenActivity() {
override fun onCreate(state: Bundle?) {
super.onCreate(state)
if (intent.getBooleanExtra(SHOW_WHEN_LOCKED, false)) {
showWhenLocked()
}
setScreenContentView(R.layout.activity_decode)
setTitle(R.string.content)
val frame = findViewById(R.id.content_frame) as ViewGroup
val view = layoutInflater.inflate(
R.layout.fragment_decode,
frame,
false
)
frame.addView(view)
val justScanned = intent.hasExtra(DECODED) || intent.hasExtra(DECODED_SCAN)
closeAutomatically = prefs.closeAutomatically && justScanned
@@ -139,33 +140,63 @@ class DecodeActivity : ScreenActivity() {
format = scan.format.name
recreationSize = (200f * dp).roundToInt()
contentView = view.findViewById(R.id.content)
formatView = view.findViewById(R.id.format)
dataView = view.findViewById(R.id.data)
metaView = view.findViewById(R.id.meta)
hexView = view.findViewById(R.id.hex)
stampView = view.findViewById(R.id.stamp)
recreationView = view.findViewById(R.id.recreation)
labelView = view.findViewById(R.id.label)
fab = view.findViewById(R.id.open)
labelHeadlineView = findViewById(R.id.label_headline)
contentView = findViewById(R.id.content)
contentHeadlineView = findViewById(R.id.content_headline)
characterCountView = findViewById(R.id.character_count)
dataHeadlineView = findViewById(R.id.data_headline)
formatHeadlineView = findViewById(R.id.format_headline)
dataView = findViewById(R.id.data)
metaHeadlineView = findViewById(R.id.meta_headline)
metaView = findViewById(R.id.meta)
hexHeadlineView = findViewById(R.id.hex_headline)
hexView = findViewById(R.id.hex)
formatDescriptionView = findViewById(R.id.format_description)
recreationView = findViewById(R.id.recreation)
labelView = findViewById(R.id.label)
fab = findViewById(R.id.open)
contentHeadlineView.setText(
if (isBinary) {
R.string.section_raw_barcode_content
} else {
R.string.section_raw_barcode_content_editable
}
)
if (prefs.showMetaData) {
val descResId = format.toFormatDescriptionResId()
formatHeadlineView.text = getString(
R.string.section_format_details,
format.prettifyFormatName()
)
formatHeadlineView.visibility = View.VISIBLE
if (descResId != 0) {
formatDescriptionView.setText(descResId)
} else {
formatDescriptionView.visibility = View.GONE
}
metaView.fillMetaView(scan)
} else {
formatHeadlineView.visibility = View.GONE
formatDescriptionView.visibility = View.GONE
metaHeadlineView.visibility = View.GONE
metaView.visibility = View.GONE
}
if (scan.id > 0) {
labelHeadlineView.visibility = View.VISIBLE
scan.label?.let {
labelView.setText(it)
label = it
}
} else {
labelHeadlineView.visibility = View.GONE
labelView.visibility = View.GONE
}
view.findViewById<View>(R.id.inset_layout).setPaddingFromWindowInsets()
view.findViewById<View>(R.id.scroll_view).setPaddingFromWindowInsets()
findViewById<View>(R.id.inset_layout).setPaddingFromWindowInsets(bottom = false)
findViewById<View>(R.id.scroll_view).setPaddingFromWindowInsets()
initContentAndFab(justScanned)
updateViewsAndFab(scan.text, originalBytes)
@@ -181,7 +212,7 @@ class DecodeActivity : ScreenActivity() {
label = newLabel
}
}
if (action.fired) {
if (action.executed) {
maybeBackOrFinish()
}
}
@@ -259,15 +290,12 @@ class DecodeActivity : ScreenActivity() {
}
private fun updateViews(text: String, bytes: ByteArray) {
formatView.text = resources.getQuantityString(
R.plurals.barcode_info,
dataView.fillDataView(text, bytes, text != scan.text)
characterCountView.text = resources.getQuantityString(
R.plurals.character_count,
bytes.size,
format.prettifyFormatName(),
bytes.size
)
hexView.showIf(prefs.showHexDump) { v ->
v.text = hexDump(bytes)
}
recreationView.showIf(prefs.showRecreation) { v ->
val unmodified: Boolean
val content = if (isBinary) {
@@ -291,7 +319,8 @@ class DecodeActivity : ScreenActivity() {
ContentBarcode(
content,
scan.format,
scan.errorCorrectionLevel.toErrorCorrectionInt()
scan.errorCorrectionLevel.toErrorCorrectionInt(),
addQuietZone = true
)
}
try {
@@ -308,8 +337,16 @@ class DecodeActivity : ScreenActivity() {
clearRecreation()
}
}
dataView.fillDataView(text, bytes)
stampView.setTrackingLink(bytes, format)
hexView.showIf(prefs.showHexDump) { v ->
hexHeadlineView.visibility = View.VISIBLE
v.text = hexDump(bytes)
v.setOnClickListener {
copyToClipboard(bytes.toHexString())
}
}
if (!prefs.showHexDump) {
hexHeadlineView.visibility = View.GONE
}
}
private fun clearRecreation() {
@@ -319,90 +356,51 @@ class DecodeActivity : ScreenActivity() {
}
}
private fun TableLayout.fillDataView(text: String, bytes: ByteArray) {
val items = LinkedHashMap<Any, CharSequence?>()
private fun LinearLayout.fillDataView(
text: String,
bytes: ByteArray,
isEditing: Boolean
) {
val items = mutableListOf<Field>()
val parsedData = parseData(this@DecodeActivity, text, bytes, format, action)
items.addAll(parsedData.fields)
when (prefs.showChecksum) {
"CRC4" -> items[R.string.crc4] = String.format("%X", crc4(bytes))
"MD5" -> items[R.string.md5] = bytes.md5().toHexString().fold()
"SHA1" -> items[R.string.sha1] = bytes.sha1().toHexString().fold()
"SHA256" -> items[R.string.sha256] =
bytes.sha256().toHexString().fold()
else -> Unit
"CRC4" -> items.addField(R.string.crc4, String.format("%X", crc4(bytes)))
"MD5" -> items.addField(R.string.md5, bytes.md5().toHexString().fold())
"SHA1" -> items.addField(R.string.sha1, bytes.sha1().toHexString().fold())
"SHA256" -> items.addField(R.string.sha256, bytes.sha256().toHexString().fold())
}
IdlParser.parse(String(bytes))?.let {
items.putAll(mapOf("IIN" to it.iin))
items.putAll(
it.elements.entries.associate { (id, value) ->
context.idlToRes(id) to value
}
if (parsedData.type != ParsedContentType.UNKNOWN) {
items.add(
0,
Field(
R.string.parsed_type,
getString(parsedData.type.resId)
)
)
lastParsedDataItems = items
fillDataItems(items, false)
return
}
when (action) {
is MatMsgAction -> items.putAll(
MatMsg(text).run {
mapOf(
R.string.email_to to to,
R.string.email_subject to sub,
R.string.email_body to body
)
}
)
is VCardAction,
is VEventAction -> VTypeParser.parseMap(text).let { vData ->
items.putAll(
vData.map { item ->
item.key to item.value.joinToString("\n") {
it.value
}
}.toMap()
)
}
is WebAction -> try {
items.putAll(
text.toUri().run {
mapOf(
R.string.scheme to scheme,
R.string.host to host,
R.string.query to query
)
}
)
} catch (_: Exception) {
// Ignore
}
is WifiAction -> WifiConnector.parseMap(text)?.let { wifiData ->
items.putAll(
linkedMapOf(
R.string.entry_type to getString(R.string.wifi_network),
R.string.wifi_ssid to wifiData["S"],
R.string.wifi_password to wifiData["P"],
R.string.wifi_type to wifiData["T"],
R.string.wifi_hidden to wifiData["H"],
R.string.wifi_eap to wifiData["E"],
R.string.wifi_identity to wifiData["I"],
R.string.wifi_anonymous_identity to wifiData["A"],
R.string.wifi_phase2 to wifiData["PH2"]
)
)
if (isEditing && lastParsedDataItems.isNotEmpty()) {
if (!showingDimmed) {
fillDataItems(lastParsedDataItems, true)
}
return
}
fill(items)
fillDataItems(items, false)
}
private fun TableLayout.fillMetaView(scan: Scan) {
val items = linkedMapOf<Any, CharSequence?>(
R.string.error_correction_level to scan.errorCorrectionLevel,
R.string.sequence_size to scan.sequenceSize.positiveToString(),
R.string.sequence_index to scan.sequenceIndex.positiveToString(),
R.string.sequence_id to scan.sequenceId,
R.string.gtin_country to scan.country,
R.string.gtin_add_on to scan.addOn,
R.string.gtin_price to scan.price,
R.string.gtin_issue_number to scan.issueNumber,
val items = mutableListOf(
Field(R.string.error_correction_level, scan.errorCorrectionLevel),
Field(R.string.sequence_size, scan.sequenceSize.positiveToString()),
Field(R.string.sequence_index, scan.sequenceIndex.positiveToString()),
Field(R.string.sequence_id, scan.sequenceId),
Field(R.string.gtin_country, scan.country),
Field(R.string.gtin_add_on, scan.addOn),
Field(R.string.gtin_price, scan.price),
Field(R.string.gtin_issue_number, scan.issueNumber),
)
if (!scan.version.isNullOrBlank()) {
val versionString = if (scan.format == ZxingCpp.BarcodeFormat.QRCode) {
@@ -414,57 +412,140 @@ class DecodeActivity : ScreenActivity() {
} else {
scan.version
}
items.putAll(
linkedMapOf(
R.string.barcode_version_number to versionString
)
)
items.addField(R.string.barcode_version_number, versionString)
}
if (scan.format == ZxingCpp.BarcodeFormat.QRCode &&
scan.dataMask > -1
) {
items.putAll(
linkedMapOf(
R.string.qr_data_mask to scan.dataMask.toString()
)
)
items.addField(R.string.qr_data_mask, scan.dataMask.toString())
}
val itemsWithValues = items.filter { !it.value.isNullOrBlank() }
if (!itemsWithValues.isEmpty()) {
metaHeadlineView.visibility = View.VISIBLE
fillTable(itemsWithValues)
}
fill(items)
}
private fun TableLayout.fill(
items: LinkedHashMap<Any, CharSequence?>
) {
private fun TableLayout.fillTable(items: List<Field>) {
removeAllViews()
visibility = if (items.isEmpty()) View.GONE else {
val ctx = context
val spaceBetween = (16f * dp).roundToInt()
items.forEach { item ->
val text = item.value
if (!text.isNullOrBlank()) {
val tr = TableRow(ctx)
val keyView = TextView(ctx)
val tr = TableRow(ctx)
val keyView = TextView(ctx).apply {
when (val key = item.key) {
is Int -> keyView.setText(key)
is String -> keyView.text = key
else -> keyView.text = key.toString()
is Int -> setText(key)
is String -> this.text = key
else -> this.text = key.toString()
}
val valueView = TextView(ctx).apply {
setPadding(spaceBetween, 0, 0, 0)
this.text = text
setOnClickListener {
copyToClipboard(text.toString())
}
}
tr.addView(keyView)
tr.addView(valueView)
addView(tr)
}
val valueView = TextView(ctx).apply {
setPadding(spaceBetween, 0, 0, 0)
this.text = text
setOnClickListener {
copyToClipboard(text.toString())
}
}
tr.addView(keyView)
tr.addView(valueView)
addView(tr)
}
View.VISIBLE
}
}
private fun LinearLayout.fillDataItems(
items: List<Field>,
dimmed: Boolean
) {
showingDimmed = dimmed
removeAllViews()
if (items.isEmpty()) {
dataHeadlineView.visibility = View.GONE
visibility = View.GONE
return
}
dataHeadlineView.visibility = View.VISIBLE
val ctx = context
val spaceBetween = (16f * dp).roundToInt()
for (item in items) {
val text = item.value
if (text.isNullOrBlank()) {
continue
}
val rowView = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
bottomMargin = spaceBetween
}
setBackgroundResource(R.drawable.list_selector)
setOnClickListener {
item.link?.let {
openUrl(it)
}
copyToClipboard(text.toString())
}
}
val keyView = TextView(ctx).apply {
TextViewCompat.setTextAppearance(
this,
R.style.SecondaryText
)
when (val key = item.key) {
is Int -> setText(key)
is String -> this.text = key
else -> this.text = key.toString()
}
}
val valueView = TextView(ctx).apply {
TextViewCompat.setTextAppearance(
this,
android.R.style.TextAppearance_Medium
)
setTextColor(
ColorStateList.valueOf(
ctx.obtainStyledAttributes(
intArrayOf(android.R.attr.textColorPrimary)
).run {
try {
getColor(0, currentTextColor)
} finally {
recycle()
}
}
)
)
this.text = text
if (item.link != null) {
setCompoundDrawablesWithIntrinsicBounds(
0,
0,
R.drawable.ic_action_open,
0
)
compoundDrawablePadding = spaceBetween / 2
}
}
val alpha = if (dimmed) 0.5f else 1f
keyView.alpha = alpha
valueView.alpha = alpha
rowView.addView(keyView)
rowView.addView(valueView)
addView(rowView)
}
visibility = View.VISIBLE
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.fragment_decode, menu)
if (scan.id > 0L) {
@@ -533,7 +614,7 @@ class DecodeActivity : ScreenActivity() {
}
private fun askToRemoveScan(id: Long) {
AlertDialog.Builder(this)
MaterialAlertDialogBuilder(this)
.setMessage(R.string.really_remove_scan)
.setPositiveButton(android.R.string.ok) { _, _ ->
db.removeScan(id)
@@ -657,18 +738,32 @@ class DecodeActivity : ScreenActivity() {
private const val SCAN_BUNDLE = "scan_bundle"
private const val OPEN_DOCUMENT = 1
private const val SCHEME_FILE = "file://"
private const val SHOW_WHEN_LOCKED = "show_when_locked"
fun newIntent(context: Context, scan: Scan): Intent {
fun newIntent(
context: Context,
scan: Scan,
showWhenLocked: Boolean = false
): Intent {
val args = Bundle()
args.putBundle(SCAN_BUNDLE, scan.toBundle())
return Intent(context, DecodeActivity::class.java).apply {
putExtra(DECODED_SCAN, true)
putExtra(SHOW_WHEN_LOCKED, showWhenLocked)
putExtras(args)
}
}
}
}
private fun MutableList<Field>.addField(
key: Any,
value: CharSequence?,
link: String? = null
) {
add(Field(key, value, link))
}
private inline fun <T : View> T.showIf(
visible: Boolean,
block: (T) -> Unit
@@ -727,60 +822,6 @@ private fun hexDump(bytes: ByteArray, charsPerLine: Int = 33): String {
private fun Int.positiveToString() = if (this > -1) this.toString() else ""
private fun TextView.setTrackingLink(bytes: ByteArray, format: String) {
val trackingLink = generateDpTrackingLink(bytes, format)
if (trackingLink != null) {
text = trackingLink.fromHtml()
isClickable = true
movementMethod = LinkMovementMethod.getInstance()
} else {
visibility = View.GONE
}
}
private fun generateDpTrackingLink(bytes: ByteArray, format: String): String? {
// Check for Deutsche Post Matrixcode stamp.
var isStamp = false
var rawData = bytes
if (format == ZxingCpp.BarcodeFormat.DataMatrix.name &&
bytes.toString(Charsets.ISO_8859_1).startsWith("DEA5")
) {
if (bytes.size == 47) {
isStamp = true
} else if (bytes.size > 47) {
// Transform back to original data.
rawData = bytes.toString(Charsets.UTF_8).toByteArray(
Charsets.ISO_8859_1
)
if (rawData.size == 47) {
isStamp = true
}
}
}
if (!isStamp) {
return null
}
val hex = StringBuilder()
hex.append(String.format("%02X", rawData[9]))
hex.append(String.format("%02X", rawData[10]))
hex.append(String.format("%02X", rawData[11]))
hex.append(String.format("%02X", rawData[12]))
hex.append(String.format("%02X", rawData[13]))
hex.append(String.format("%X", (rawData[4].toInt() and 0x0f).toByte()))
hex.append(String.format("%02X", rawData[5]))
hex.append(String.format("%02X", rawData[6]))
hex.append(String.format("%02X", rawData[7]))
hex.append(String.format("%02X", rawData[8]))
val hexString = hex.toString()
val trackingNumber = hexString + String.format(
"%X",
crc4(hexString.toByteArray(Charsets.ISO_8859_1))
)
return "<a href=\"https://www.deutschepost.de/de/s/sendungsverfolgung.html?piececode=$trackingNumber\">Deutsche Post: $trackingNumber</a>"
}
// CRC-4 with polynomial x^4 + x + 1.
private fun crc4(input: ByteArray): Int {
var crc = 0
@@ -805,15 +846,6 @@ private fun crc4(input: ByteArray): Int {
return crc
}
private fun String.fromHtml() = if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N
) {
Html.fromHtml(this, Html.FROM_HTML_MODE_LEGACY)
} else {
@Suppress("DEPRECATION")
Html.fromHtml(this)
}
private fun ByteArray.md5(): ByteArray = MessageDigest.getInstance(
"MD5"
).run {
@@ -3,23 +3,22 @@ package de.markusfisch.android.binaryeye.activity
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.appcompat.widget.SwitchCompat
import androidx.core.net.toUri
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.CheckBox
import android.widget.EditText
import android.widget.Spinner
import android.widget.TextView
import androidx.appcompat.widget.SwitchCompat
import androidx.core.net.toUri
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.adapter.prettifyFormatName
import de.markusfisch.android.binaryeye.adapter.toFormatDescriptionResId
import de.markusfisch.android.binaryeye.app.prefs
import de.markusfisch.android.binaryeye.net.isEncodeDeeplink
import de.markusfisch.android.binaryeye.text.unescape
@@ -30,9 +29,9 @@ import de.markusfisch.android.zxingcpp.ZxingCpp.BarcodeFormat
import java.io.InputStream
import kotlin.math.min
class EncodeActivity : ScreenActivity() {
class EncodeActivity : AbstractBaseActivity() {
private lateinit var formatView: Spinner
private lateinit var formatDescriptionView: TextView
private lateinit var ecLabel: TextView
private lateinit var ecSpinner: Spinner
private lateinit var colorsLabel: TextView
@@ -44,7 +43,7 @@ class EncodeActivity : ScreenActivity() {
private val writeableFormats = arrayListOf(
BarcodeFormat.Aztec,
BarcodeFormat.Codabar,
BarcodeFormat.Code39,
BarcodeFormat.Code39Std,
BarcodeFormat.Code39Ext,
BarcodeFormat.Code32,
BarcodeFormat.PZN,
@@ -97,16 +96,11 @@ class EncodeActivity : ScreenActivity() {
override fun onCreate(state: Bundle?) {
super.onCreate(state)
setScreenContentView(R.layout.activity_encode)
setTitle(R.string.compose_barcode)
val frame = findViewById(R.id.content_frame) as ViewGroup
val view = layoutInflater.inflate(
R.layout.fragment_encode,
frame,
false
)
frame.addView(view)
formatView = view.findViewById(R.id.format)
formatView = findViewById(R.id.format)
formatDescriptionView = findViewById(R.id.format_description)
val formatAdapter = ArrayAdapter(
this,
android.R.layout.simple_spinner_item,
@@ -124,6 +118,13 @@ class EncodeActivity : ScreenActivity() {
id: Long
) {
val format = writeableFormats[position]
val descResId = format.name.toFormatDescriptionResId()
if (descResId != 0) {
formatDescriptionView.setText(descResId)
formatDescriptionView.visibility = View.VISIBLE
} else {
formatDescriptionView.visibility = View.GONE
}
val arrayId = when (format) {
BarcodeFormat.Aztec -> R.array.aztec_error_correction_levels
BarcodeFormat.QRCode -> R.array.qr_error_correction_levels
@@ -152,8 +153,8 @@ class EncodeActivity : ScreenActivity() {
override fun onNothingSelected(parentView: AdapterView<*>?) {}
}
ecLabel = view.findViewById(R.id.error_correction_label)
ecSpinner = view.findViewById(R.id.error_correction_level)
ecLabel = findViewById(R.id.error_correction_label)
ecSpinner = findViewById(R.id.error_correction_level)
ecSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(
parentView: AdapterView<*>?,
@@ -171,54 +172,29 @@ class EncodeActivity : ScreenActivity() {
override fun onNothingSelected(parentView: AdapterView<*>?) {}
}
colorsLabel = view.findViewById(R.id.colors_label)
colorsSpinner = view.findViewById(R.id.colors)
addQuietZoneSwitch = view.findViewById(R.id.add_quiet_zone)
colorsLabel = findViewById(R.id.colors_label)
colorsSpinner = findViewById(R.id.colors)
addQuietZoneSwitch = findViewById(R.id.add_quiet_zone)
addQuietZoneSwitch.isChecked = prefs.addQuietZone
contentView = view.findViewById(R.id.content)
unescapeCheckBox = view.findViewById(R.id.unescape)
contentView = findViewById(R.id.content)
unescapeCheckBox = findViewById(R.id.unescape)
unescapeCheckBox.isChecked = prefs.expandEscapeSequences
var complete = false
val args = intent?.extras ?: getExtrasFromDeeplink()
args?.getString(CONTENT_TEXT)?.let {
contentView.setText(it)
complete = it.isNotEmpty()
}
args?.getByteArray(CONTENT_RAW)?.let {
bytes = it
complete = it.isNotEmpty()
setEncodeByteArray()
}
applyIntent(state == null)
val barcodeFormat = args?.getString(FORMAT)
if (barcodeFormat != null) {
formatView.setSelection(
writeableFormats.indexOf(barcodeFormat.toFormat())
)
} else if (state == null) {
formatView.post {
formatView.setSelection(prefs.indexOfLastSelectedFormat)
}
}
view.findViewById<View>(R.id.encode).setOnClickListener {
findViewById<View>(R.id.encode).setOnClickListener {
it.context.encode()
}
view.findViewById<View>(R.id.inset_layout).setPaddingFromWindowInsets()
view.findViewById<View>(R.id.scroll_view).setPaddingFromWindowInsets()
findViewById<View>(R.id.inset_layout).setPaddingFromWindowInsets(bottom = false)
findViewById<View>(R.id.scroll_view).setPaddingFromWindowInsets()
}
if (complete &&
args?.getBoolean(EXECUTE, false) == true
) {
view.post {
val content = view.context.getContent() ?: return@post
startActivity(newBarcodeIntent(content))
finish()
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
applyIntent(true)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
@@ -251,6 +227,59 @@ class EncodeActivity : ScreenActivity() {
prefs.addQuietZone = addQuietZoneSwitch.isChecked
}
private fun applyIntent(applyDefaultFormat: Boolean) {
val args = Bundle().apply {
intent?.extras?.let { putAll(it) }
getExtrasFromDeeplink()?.let { putAll(it) }
}.takeIf { !it.isEmpty }
val hasTextContent = args?.containsKey(CONTENT_TEXT) == true
val hasRawContent = args?.containsKey(CONTENT_RAW) == true
var complete = bytes?.isNotEmpty() == true ||
contentView.text?.isNotEmpty() == true
if (hasTextContent || hasRawContent) {
bytes = null
contentView.isEnabled = true
contentView.hint = null
unescapeCheckBox.isEnabled = true
}
if (hasTextContent) {
args.getString(CONTENT_TEXT)?.let {
contentView.setText(it)
complete = it.isNotEmpty()
}
}
if (hasRawContent) {
args.getByteArray(CONTENT_RAW)?.let {
bytes = it
complete = it.isNotEmpty()
setEncodeByteArray()
}
}
val barcodeFormat = args?.getString(FORMAT)
if (barcodeFormat != null) {
formatView.setSelection(
writeableFormats.indexOf(barcodeFormat.toFormat())
)
} else if (applyDefaultFormat) {
formatView.post {
formatView.setSelection(prefs.indexOfLastSelectedFormat)
}
}
if (complete &&
args?.getBoolean(EXECUTE, false) == true
) {
contentView.post {
val content = contentView.context.getContent() ?: return@post
startActivity(newBarcodeIntent(content))
finish()
}
}
}
private fun Context.encode() {
hideSoftKeyboard(contentView)
val content = getContent() ?: return
@@ -266,11 +295,7 @@ class EncodeActivity : ScreenActivity() {
content,
format,
format.getErrorCorrectionLevel(ecSpinner.selectedItemPosition),
if (addQuietZoneSwitch.isChecked) {
1
} else {
0
},
addQuietZoneSwitch.isChecked,
if (format.canBeInverted()) {
colorsSpinner.selectedItemPosition
} else 0
@@ -412,6 +437,7 @@ private fun BarcodeFormat.getErrorCorrectionLevel(position: Int) = when (this) {
BarcodeFormat.Aztec -> position
BarcodeFormat.QRCode,
BarcodeFormat.MicroQRCode -> (position + 1) * 2
BarcodeFormat.RMQRCode -> (position + 1) * 4
BarcodeFormat.PDF417 -> position
else -> 0
@@ -1,10 +1,10 @@
package de.markusfisch.android.binaryeye.activity
import android.annotation.SuppressLint
import android.app.AlertDialog
import android.app.SearchManager
import android.content.Context
import android.database.Cursor
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
@@ -13,13 +13,13 @@ import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import android.widget.ListView
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.SearchView
import androidx.appcompat.widget.SwitchCompat
import androidx.core.content.ContextCompat
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.adapter.ScansAdapter
import de.markusfisch.android.binaryeye.app.alertDialog
@@ -48,7 +48,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class HistoryActivity : ScreenActivity() {
class HistoryActivity : AbstractBaseActivity() {
private lateinit var useHistorySwitch: SwitchCompat
private lateinit var listView: ListView
private lateinit var fab: View
@@ -70,9 +70,10 @@ class HistoryActivity : ScreenActivity() {
lockStatusBarColor()
val ac = this@HistoryActivity
@Suppress("DEPRECATION")
ac.window.statusBarColor = ContextCompat.getColor(
ac.window.statusBarColor = MaterialColors.getColor(
ac,
R.color.accent_dark
com.google.android.material.R.attr.colorSecondaryContainer,
Color.TRANSPARENT
)
}
return true
@@ -141,17 +142,15 @@ class HistoryActivity : ScreenActivity() {
override fun onCreate(state: Bundle?) {
super.onCreate(state)
setScreenContentView(R.layout.activity_history)
setTitle(R.string.history)
val frame = findViewById(R.id.content_frame) as ViewGroup
val view = layoutInflater.inflate(R.layout.fragment_history, frame, false)
frame.addView(view)
useHistorySwitch = view.findViewById(
useHistorySwitch = findViewById(
R.id.use_history
)
initHistorySwitch(useHistorySwitch)
listView = view.findViewById(R.id.scans)
listView = findViewById(R.id.scans)
listView.setOnItemClickListener { _, v, position, id ->
if (actionMode != null) {
scansAdapter?.select(v, id, position)
@@ -170,17 +169,15 @@ class HistoryActivity : ScreenActivity() {
}
listView.setOnScrollListener(systemBarListViewScrollListener)
fab = view.findViewById(R.id.share)
fab = findViewById(R.id.share)
fab.setOnClickListener { v ->
v.context.pickListSeparatorAndShare(false)
}
progressView = view.findViewById(R.id.progress_view)
progressView = findViewById(R.id.progress_view)
view.findViewById<View>(R.id.inset_layout).setPaddingFromWindowInsets()
findViewById<View>(R.id.inset_layout).setPaddingFromWindowInsets(bottom = false)
listView.setPaddingFromWindowInsets()
update()
}
override fun onDestroy() {
@@ -190,6 +187,11 @@ class HistoryActivity : ScreenActivity() {
wipeLastShareFile()
}
override fun onResume() {
super.onResume()
update()
}
override fun onPause() {
super.onPause()
listViewState = listView.onSaveInstanceState()
@@ -292,7 +294,16 @@ class HistoryActivity : ScreenActivity() {
cursor.let { cursor ->
// Close previous cursor.
scansAdapter?.also { it.changeCursor(null) }
scansAdapter = ScansAdapter(ac, cursor)
scansAdapter = ScansAdapter(ac, cursor) { id, pinned ->
if (actionMode == null) {
scope.launch {
db.setPinned(id, pinned)
withContext(Dispatchers.Main) {
update()
}
}
}
}
listView.adapter = scansAdapter
listViewState?.also {
listView.onRestoreInstanceState(it)
@@ -333,7 +344,7 @@ class HistoryActivity : ScreenActivity() {
)
val nameView = view.findViewById<EditText>(R.id.name)
nameView.setText(text)
AlertDialog.Builder(this)
MaterialAlertDialogBuilder(this)
.setTitle(
if (content.isNullOrEmpty()) {
getString(R.string.binary_data)
@@ -352,7 +363,7 @@ class HistoryActivity : ScreenActivity() {
}
private fun Context.askToRemoveScans(ids: List<Long>) {
AlertDialog.Builder(this)
MaterialAlertDialogBuilder(this)
.setMessage(
if (ids.size > 1) {
R.string.really_remove_selected_scans
@@ -374,7 +385,7 @@ class HistoryActivity : ScreenActivity() {
}
private fun Context.askToRemoveScans() {
AlertDialog.Builder(this)
MaterialAlertDialogBuilder(this)
.setMessage(
if (filter == null) {
R.string.really_remove_all_scans
@@ -439,7 +450,7 @@ class HistoryActivity : ScreenActivity() {
val separators = resources.getStringArray(
R.array.list_separators_values
)
AlertDialog.Builder(this)
MaterialAlertDialogBuilder(this)
.setTitle(R.string.pick_list_separator)
.setItems(R.array.list_separators_names) { _, which ->
shareScans(separators[which], asFile)
@@ -0,0 +1,172 @@
package de.markusfisch.android.binaryeye.activity
import android.app.Dialog
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.ListView
import android.widget.Spinner
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.adapter.prettifyFormatName
import de.markusfisch.android.binaryeye.adapter.setupFormatSpinner
import de.markusfisch.android.binaryeye.app.prefs
import de.markusfisch.android.binaryeye.preference.IgnoreCode
import de.markusfisch.android.binaryeye.view.setPaddingFromWindowInsets
import de.markusfisch.android.binaryeye.view.systemBarListViewScrollListener
import de.markusfisch.android.binaryeye.widget.toast
class IgnoreCodesActivity : AbstractBaseActivity() {
private val patterns = ArrayList<IgnoreCode>()
private lateinit var adapter: PatternsAdapter
override fun onCreate(state: Bundle?) {
super.onCreate(state)
setScreenContentView(R.layout.activity_ignore_codes)
setTitle(R.string.ignore_codes)
patterns.addAll(prefs.ignoreCodes)
val listView = findViewById<ListView>(R.id.actions)
listView.emptyView = findViewById(R.id.no_actions)
findViewById<TextView>(R.id.no_actions).setText(
R.string.ignore_codes_empty
)
adapter = PatternsAdapter(patterns)
listView.adapter = adapter
listView.setOnScrollListener(systemBarListViewScrollListener)
listView.setOnItemClickListener { _, _, position, _ ->
editPattern(position)
}
listView.setOnItemLongClickListener { _, _, position, _ ->
confirmRemovePattern(position)
true
}
findViewById<View>(R.id.add).apply {
contentDescription = getString(R.string.ignore_code_add)
setOnClickListener {
editPattern(null)
}
}
findViewById<View>(R.id.inset_layout).setPaddingFromWindowInsets(bottom = false)
listView.setPaddingFromWindowInsets()
}
private fun editPattern(position: Int?) {
val pattern = position?.let { patterns[it] }
val view = layoutInflater.inflate(R.layout.dialog_ignore_code, null)
val regexView = view.findViewById<EditText>(R.id.regex)
val formatView = view.findViewById<Spinner>(R.id.format)
val values = setupFormatSpinner(formatView)
if (pattern != null) {
regexView.setText(pattern.pattern)
regexView.setSelection(pattern.pattern.length)
formatView.setSelection(
values.indexOf(pattern.format ?: "").coerceAtLeast(0)
)
} else {
formatView.setSelection(0)
}
val dialog = MaterialAlertDialogBuilder(this)
.setTitle(
if (pattern == null) {
R.string.ignore_code_add
} else {
R.string.ignore_code_edit
}
)
.setView(view)
.setPositiveButton(android.R.string.ok, null)
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.create()
dialog.setOnShowListener {
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
addOrUpdatePattern(position, regexView, formatView, values, dialog)
}
}
dialog.show()
}
private fun addOrUpdatePattern(
position: Int?,
regexView: EditText,
formatView: Spinner,
values: List<String>,
dialog: Dialog,
) {
val pattern = regexView.text.toString().trim()
if (pattern.isEmpty()) {
regexView.error = getString(R.string.ignore_code_regex_required)
return
}
try {
Regex(pattern)
} catch (e: Exception) {
val message = e.message ?: getString(
R.string.ignore_code_regex_invalid
)
regexView.error = message
toast(message)
return
}
val item = IgnoreCode(
pattern = pattern,
format = values[formatView.selectedItemPosition].ifEmpty { null }
)
if (position == null) {
patterns.add(item)
} else {
patterns[position] = item
}
prefs.setIgnoreCodes(patterns)
adapter.notifyDataSetChanged()
dialog.dismiss()
}
private fun confirmRemovePattern(position: Int) {
MaterialAlertDialogBuilder(this)
.setMessage(R.string.ignore_code_remove_confirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
patterns.removeAt(position)
prefs.setIgnoreCodes(patterns)
adapter.notifyDataSetChanged()
}
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.show()
}
private inner class PatternsAdapter(
items: List<IgnoreCode>
) : ArrayAdapter<IgnoreCode>(
this,
android.R.layout.simple_list_item_2,
android.R.id.text1,
items
) {
override fun getView(
position: Int,
convertView: View?,
parent: ViewGroup
): View {
val view = super.getView(position, convertView, parent)
val item = patterns[position]
val title = view.findViewById<TextView>(android.R.id.text1)
val subtitle = view.findViewById<TextView>(android.R.id.text2)
title.text = item.pattern
subtitle.text = item.format?.prettifyFormatName() ?: getString(
R.string.all_formats
)
return view
}
}
}
@@ -5,29 +5,22 @@ import android.net.wifi.WifiManager
import android.net.wifi.WifiNetworkSuggestion
import android.os.Build
import android.os.Bundle
import androidx.annotation.RequiresApi
import androidx.core.util.size
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.ListView
import androidx.annotation.RequiresApi
import androidx.core.util.size
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.view.setPaddingFromWindowInsets
import de.markusfisch.android.binaryeye.view.systemBarListViewScrollListener
import de.markusfisch.android.binaryeye.widget.toast
class NetworkSuggestionsActivity : ScreenActivity() {
class NetworkSuggestionsActivity : AbstractBaseActivity() {
@RequiresApi(Build.VERSION_CODES.R)
override fun onCreate(state: Bundle?) {
super.onCreate(state)
setScreenContentView(R.layout.activity_network_suggestions)
setTitle(R.string.network_suggestions)
val frame = findViewById(R.id.content_frame) as ViewGroup
val view = layoutInflater.inflate(
R.layout.fragment_network_suggestions,
frame,
false
)
frame.addView(view)
val wm = applicationContext.getSystemService(
Context.WIFI_SERVICE
@@ -43,14 +36,14 @@ class NetworkSuggestionsActivity : ScreenActivity() {
}
)
val listView = view.findViewById<ListView>(R.id.suggestions)
listView.emptyView = view.findViewById(R.id.no_suggestions)
val listView = findViewById<ListView>(R.id.suggestions)
listView.emptyView = findViewById(R.id.no_suggestions)
listView.adapter = suggestionArrayAdapter
listView.setOnScrollListener(systemBarListViewScrollListener)
view.findViewById<View>(R.id.inset_layout).setPaddingFromWindowInsets()
findViewById<View>(R.id.inset_layout).setPaddingFromWindowInsets(bottom = false)
listView.setPaddingFromWindowInsets()
view.findViewById<View>(R.id.remove).setOnClickListener {
findViewById<View>(R.id.remove).setOnClickListener {
val removeList = ArrayList<WifiNetworkSuggestion>()
val removeFromAdapter = ArrayList<Suggestion>()
val checked = listView.checkedItemPositions
@@ -110,9 +110,9 @@ class PickActivity : AppCompatActivity() {
cropHandleName = "picker_crop_handle"
}
findViewById<View>(R.id.scan).setOnClickListener {
showResult()
}
findViewById<View>(R.id.scan).setOnClickListener {
showResult()
}
}
private fun getBitmapFromIntent(): Bitmap? = if (
@@ -1,6 +1,5 @@
package de.markusfisch.android.binaryeye.activity
import android.app.AlertDialog
import android.content.Context
import android.os.Bundle
import android.view.View
@@ -9,32 +8,28 @@ import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.ListView
import android.widget.TextView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.app.prefs
import de.markusfisch.android.binaryeye.view.setPaddingFromWindowInsets
import de.markusfisch.android.binaryeye.view.systemBarListViewScrollListener
import de.markusfisch.android.binaryeye.widget.toast
import de.markusfisch.android.zxingcpp.ZxingCpp.BarcodeFormat
class ProfilesActivity : ScreenActivity() {
class ProfilesActivity : AbstractBaseActivity() {
private val profiles = ArrayList<ProfileItem>()
private lateinit var adapter: ProfilesAdapter
override fun onCreate(state: Bundle?) {
super.onCreate(state)
setScreenContentView(R.layout.activity_profiles)
setTitle(R.string.profile)
val frame = findViewById(R.id.content_frame) as ViewGroup
val view = layoutInflater.inflate(
R.layout.fragment_profiles,
frame,
false
)
frame.addView(view)
refreshProfiles()
val listView = view.findViewById<ListView>(R.id.profiles)
listView.emptyView = view.findViewById(R.id.no_profiles)
val listView = findViewById<ListView>(R.id.profiles)
listView.emptyView = findViewById(R.id.no_profiles)
adapter = ProfilesAdapter(this, profiles)
listView.adapter = adapter
listView.setOnScrollListener(systemBarListViewScrollListener)
@@ -46,11 +41,11 @@ class ProfilesActivity : ScreenActivity() {
true
}
view.findViewById<View>(R.id.add).setOnClickListener {
findViewById<View>(R.id.add).setOnClickListener {
addProfile()
}
view.findViewById<View>(R.id.inset_layout).setPaddingFromWindowInsets()
findViewById<View>(R.id.inset_layout).setPaddingFromWindowInsets(bottom = false)
listView.setPaddingFromWindowInsets()
}
@@ -59,11 +54,11 @@ class ProfilesActivity : ScreenActivity() {
profiles.add(
ProfileItem(
null,
getString(R.string.profile_default)
prefs.profileLabel(this, null)
)
)
prefs.profiles.forEach { name ->
profiles.add(ProfileItem(name, name))
profiles.add(ProfileItem(name, prefs.profileLabel(this, name)))
}
if (this::adapter.isInitialized) {
adapter.notifyDataSetChanged()
@@ -86,7 +81,7 @@ class ProfilesActivity : ScreenActivity() {
R.layout.dialog_profile_name, null
)
val editText = view.findViewById<EditText>(R.id.name)
AlertDialog.Builder(this)
MaterialAlertDialogBuilder(this)
.setView(view)
.setPositiveButton(android.R.string.ok) { _, _ ->
val name = editText.text.toString().trim()
@@ -103,7 +98,7 @@ class ProfilesActivity : ScreenActivity() {
private fun confirmRemoveProfile(position: Int) {
val profile = profiles[position]
val profileName = profile.name ?: return
AlertDialog.Builder(this)
MaterialAlertDialogBuilder(this)
.setMessage(R.string.profile_remove_confirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
val isCurrent = prefs.profile == profileName
@@ -122,8 +117,8 @@ class ProfilesActivity : ScreenActivity() {
private val items: List<ProfileItem>
) : ArrayAdapter<ProfileItem>(
context,
android.R.layout.simple_list_item_2,
android.R.id.text1,
R.layout.row_profile,
R.id.title,
items
) {
override fun getView(
@@ -135,6 +130,7 @@ class ProfilesActivity : ScreenActivity() {
val item = items[position]
val isCurrent = item.name == prefs.profile
getViewHolder(view).apply {
current = item
title.text = item.label
if (isCurrent) {
subtitle.text = context.getString(R.string.profile_current)
@@ -150,16 +146,31 @@ class ProfilesActivity : ScreenActivity() {
private fun getViewHolder(
view: View
): ViewHolder = view.tag as ViewHolder? ?: ViewHolder(
view.findViewById(android.R.id.text1),
view.findViewById(android.R.id.text2),
).also {
view.tag = it
view.findViewById(R.id.title),
view.findViewById(R.id.subtitle),
view.findViewById(R.id.share_qr),
).also { holder ->
holder.shareQr.setOnClickListener {
val item = holder.current ?: return@setOnClickListener
context.startActivity(
BarcodeActivity.newIntent(
context,
prefs.toJson(context, item.name),
BarcodeFormat.QRCode,
addQuietZone = true
)
)
}
view.tag = holder
}
private data class ViewHolder(
private class ViewHolder(
val title: TextView,
val subtitle: TextView,
)
val shareQr: View,
) {
var current: ProfileItem? = null
}
}
}
@@ -1,10 +1,10 @@
package de.markusfisch.android.binaryeye.activity
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
class SplashActivity : AppCompatActivity() {
class SplashActivity : Activity() {
override fun onCreate(state: Bundle?) {
super.onCreate(state)
@@ -10,23 +10,12 @@ class WelcomeActivity : AppCompatActivity() {
override fun onCreate(state: Bundle?) {
super.onCreate(state)
setContentView(R.layout.activity_welcome)
findViewById<View>(R.id.expert).setOnClickListener {
finish()
}
findViewById<View>(R.id.simple).setOnClickListener {
prefs.apply {
showCropHandle = false
zoomBySwiping = false
beep = true
copyImmediately = true
openImmediately = true
showMetaData = false
showHexDump = false
defaultSearchUrl = "https://www.google.com/search?q="
openWithUrl = defaultSearchUrl
expandEscapeSequences = false
brightenScreen = true
}
findViewById<View>(R.id.expert).setOnClickListener {
prefs.loadExpertProfile(this)
finish()
}
findViewById<View>(R.id.simple).setOnClickListener {
prefs.loadSimpleProfile(this)
finish()
}
}
@@ -2,31 +2,40 @@ package de.markusfisch.android.binaryeye.adapter
import android.content.Context
import android.database.Cursor
import androidx.core.content.ContextCompat
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.CursorAdapter
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.Spinner
import android.widget.TextView
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.content.ParsedContentType
import de.markusfisch.android.binaryeye.content.getContentPreview
import de.markusfisch.android.binaryeye.content.parsedContentTypeFromName
import de.markusfisch.android.binaryeye.database.Database
import java.text.DateFormat
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Locale
class ScansAdapter(context: Context, cursor: Cursor) :
class ScansAdapter(
context: Context,
cursor: Cursor,
private val onPinClick: (Long, Boolean) -> Unit
) :
CursorAdapter(context, cursor, false) {
private val idIndex = cursor.getColumnIndex(Database.SCANS_ID)
private val pinnedIndex = cursor.getColumnIndex(Database.SCANS_PINNED)
private val timeIndex = cursor.getColumnIndex(Database.SCANS_DATETIME)
private val nameIndex = cursor.getColumnIndex(Database.SCANS_NAME)
private val textIndex = cursor.getColumnIndex(Database.SCANS_TEXT)
private val rawIndex = cursor.getColumnIndex(Database.SCANS_RAW)
private val contentTypeIndex = cursor.getColumnIndex(Database.SCANS_CONTENT_TYPE)
private val formatIndex = cursor.getColumnIndex(Database.SCANS_FORMAT)
private val selections = mutableMapOf<Long, Int>()
private val selectedColor = ContextCompat.getColor(
context, R.color.selected_row
)
fun select(view: View, id: Long, position: Int) {
view.select(
if (selections[id] == null) {
@@ -89,10 +98,22 @@ class ScansAdapter(context: Context, cursor: Cursor) :
context: Context,
cursor: Cursor
) {
val id = cursor.getLong(idIndex)
val pinned = cursor.getInt(pinnedIndex) != 0
val format = cursor.getString(formatIndex)
val contentType = parsedContentTypeFromName(
cursor.getString(contentTypeIndex)
)
getViewHolder(view).apply {
timeView.text = formatDateTime(cursor.getString(timeIndex))
metaView.text = formatDateTime(cursor.getString(timeIndex))
formatView.text = format.prettifyFormatName().withContentType(
context,
contentType
)
formatIconView.setImageResource(format.toBarcodeIcon())
val name = cursor.getString(nameIndex)
val text = cursor.getString(textIndex)
val raw = cursor.getBlob(rawIndex)
var icon = 0
contentView.text = when {
name?.isNotEmpty() == true -> {
@@ -100,8 +121,11 @@ class ScansAdapter(context: Context, cursor: Cursor) :
name
}
text.isNullOrEmpty() -> context.getString(
R.string.binary_data
text.isNullOrEmpty() -> getContentPreview(
context,
text ?: "",
raw ?: ByteArray(0),
format
)
else -> text
@@ -109,9 +133,27 @@ class ScansAdapter(context: Context, cursor: Cursor) :
contentView.setCompoundDrawablesWithIntrinsicBounds(
icon, 0, 0, 0
)
formatView.text = cursor.getString(formatIndex).prettifyFormatName()
pinView.apply {
setImageResource(
if (pinned) {
R.drawable.ic_action_pin
} else {
R.drawable.ic_action_pin_outline
}
)
contentDescription = context.getString(
if (pinned) {
R.string.unpin_item
} else {
R.string.pin_item
}
)
setOnClickListener {
onPinClick(id, !pinned)
}
}
}
val selected = selections[cursor.getLong(idIndex)] != null
val selected = selections[id] != null
view.post {
// Needs to be put on the queue to work.
view.select(selected)
@@ -119,32 +161,123 @@ class ScansAdapter(context: Context, cursor: Cursor) :
}
private fun View.select(selected: Boolean) {
setBackgroundColor(if (selected) selectedColor else 0)
isActivated = selected
refreshDrawableState()
}
private fun getViewHolder(
view: View
): ViewHolder = view.tag as ViewHolder? ?: ViewHolder(
view.findViewById(R.id.time),
view.findViewById(R.id.format_icon),
view.findViewById(R.id.meta),
view.findViewById(R.id.format),
view.findViewById(R.id.content),
view.findViewById(R.id.format)
view.findViewById(R.id.pin)
).also {
view.tag = it
}
private data class ViewHolder(
val timeView: TextView,
val formatIconView: ImageView,
val metaView: TextView,
val formatView: TextView,
val contentView: TextView,
val formatView: TextView
val pinView: ImageButton
)
}
private val formatWordBoundary =
"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|(?<=[A-Za-z])(?=\\d)|(?<=\\d)(?=[A-Za-z])".toRegex()
fun Context.setupFormatSpinner(spinner: Spinner): List<String> {
val names = resources.getStringArray(
R.array.barcode_formats_names
).toMutableList().apply {
add(0, getString(R.string.all_formats))
}
val values = resources.getStringArray(
R.array.barcode_formats_values
).toMutableList().apply {
add(0, "")
}
spinner.adapter = ArrayAdapter(
this,
android.R.layout.simple_spinner_item,
names
).apply {
setDropDownViewResource(
android.R.layout.simple_spinner_dropdown_item
)
}
return values
}
fun String.prettifyFormatName() = replace("_", " ")
.replace(formatWordBoundary, " ")
private fun String.withContentType(
context: Context,
type: ParsedContentType
) = if (type == ParsedContentType.UNKNOWN) {
this
} else {
context.getString(
R.string.scan_meta_data,
this,
context.getString(type.resId)
)
}
fun String.toFormatDescriptionResId(): Int = when (this) {
"Aztec" -> R.string.format_description_aztec
"Codabar" -> R.string.format_description_codabar
"Code39",
"Code39Std" -> R.string.format_description_code39
"Code39Ext" -> R.string.format_description_code39ext
"Code32" -> R.string.format_description_code32
"PZN" -> R.string.format_description_pzn
"Code93" -> R.string.format_description_code93
"Code128" -> R.string.format_description_code128
"DataBar" -> R.string.format_description_databar
"DataBarOmni" -> R.string.format_description_databar_omni
"DataBarStk" -> R.string.format_description_databar_stk
"DataBarLtd" -> R.string.format_description_databar_ltd
"DataBarExp" -> R.string.format_description_databar_exp
"DataBarExpStk" -> R.string.format_description_databar_exp_stk
"DataMatrix" -> R.string.format_description_data_matrix
"DXFilmEdge" -> R.string.format_description_dx_film_edge
"EAN8" -> R.string.format_description_ean8
"EAN13" -> R.string.format_description_ean13
"ITF" -> R.string.format_description_itf
"MaxiCode" -> R.string.format_description_maxicode
"PDF417" -> R.string.format_description_pdf417
"Telepen" -> R.string.format_description_telepen
"TelepenAlpha" -> R.string.format_description_telepen_alpha
"TelepenNumeric" -> R.string.format_description_telepen_numeric
"QRCode" -> R.string.format_description_qr_code
"MicroQRCode" -> R.string.format_description_micro_qr_code
"RMQRCode" -> R.string.format_description_rmqr_code
"UPCA" -> R.string.format_description_upca
"UPCE" -> R.string.format_description_upce
else -> 0
}
private fun String?.toBarcodeIcon(): Int = when (this) {
"QRCode",
"RMQRCode" -> R.drawable.ic_barcode_qr_code
"MicroQRCode" -> R.drawable.ic_barcode_micro_qr
"Aztec" -> R.drawable.ic_barcode_aztec
"DataMatrix",
"MaxiCode" -> R.drawable.ic_barcode_data_matrix
"PDF417" -> R.drawable.ic_barcode_pdf417
else -> R.drawable.ic_barcode_linear
}
private fun formatDateTime(rfc: String): String {
try {
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).parse(rfc)?.let {
@@ -1,7 +1,7 @@
package de.markusfisch.android.binaryeye.app
import android.app.AlertDialog
import android.content.Context
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.coroutines.resume
@@ -9,10 +9,10 @@ import kotlin.coroutines.suspendCoroutine
suspend inline fun <T : Any> alertDialog(
context: Context,
crossinline build: AlertDialog.Builder.(resume: (T?) -> Unit) -> Unit
crossinline build: MaterialAlertDialogBuilder.(resume: (T?) -> Unit) -> Unit
): T? = withContext(Dispatchers.Main) {
suspendCoroutine { continuation ->
AlertDialog.Builder(context).apply {
MaterialAlertDialogBuilder(context).apply {
setOnCancelListener {
continuation.resume(null)
}
@@ -1,6 +1,7 @@
package de.markusfisch.android.binaryeye.app
import android.app.Application
import com.google.android.material.color.DynamicColors
import de.markusfisch.android.binaryeye.database.Database
import de.markusfisch.android.binaryeye.preference.Preferences
@@ -10,7 +11,11 @@ val prefs = Preferences()
class BinaryEyeApp : Application() {
override fun onCreate() {
super.onCreate()
db.open(this)
prefs.init(this)
DynamicColors.applyToActivitiesIfAvailable(
this,
DynamicColors.Precondition { _, _ -> prefs.dynamicColors }
)
db.open(this)
}
}
@@ -9,6 +9,7 @@ import java.util.Locale
data class AutomatedAction(
val pattern: String,
val type: Type,
val format: String? = null,
val urlTemplate: String? = null,
val intentUriTemplate: String? = null
) {
@@ -16,8 +17,9 @@ data class AutomatedAction(
Intent, CustomIntent
}
fun matches(content: String): Boolean = try {
Regex(pattern).matches(content)
fun matches(content: String, format: String): Boolean = try {
(this.format == null || this.format == format) &&
Regex(pattern).matches(content)
} catch (_: Exception) {
false
}
@@ -25,6 +27,7 @@ data class AutomatedAction(
fun toJson(): JSONObject = JSONObject().apply {
put(KEY_PATTERN, pattern)
put(KEY_TYPE, type.name.lowercase(Locale.ROOT))
put(KEY_FORMAT, format ?: "")
put(KEY_URL_TEMPLATE, urlTemplate ?: "")
put(KEY_INTENT_URI_TEMPLATE, intentUriTemplate ?: "")
}
@@ -32,6 +35,7 @@ data class AutomatedAction(
companion object {
private const val KEY_PATTERN = "pattern"
private const val KEY_TYPE = "type"
private const val KEY_FORMAT = "format"
private const val KEY_URL_TEMPLATE = "url_template"
private const val KEY_INTENT_URI_TEMPLATE = "intent_uri_template"
@@ -46,6 +50,7 @@ data class AutomatedAction(
"html" -> return null
else -> Type.Intent
}
val format = obj.optString(KEY_FORMAT, "").trim()
val urlTemplate = obj.optString(KEY_URL_TEMPLATE, "").trim()
val intentUriTemplate = obj.optString(
KEY_INTENT_URI_TEMPLATE,
@@ -54,6 +59,7 @@ data class AutomatedAction(
return AutomatedAction(
pattern = pattern,
type = type,
format = format.ifEmpty { null },
urlTemplate = urlTemplate.ifEmpty { null },
intentUriTemplate = intentUriTemplate.ifEmpty { null }
)
@@ -4,8 +4,8 @@ import android.content.Context
import android.content.Intent
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.app.prefs
import de.markusfisch.android.binaryeye.content.execShareIntent
import de.markusfisch.android.binaryeye.content.openUrl
import de.markusfisch.android.binaryeye.content.startIntentOrToast
import de.markusfisch.android.binaryeye.database.Scan
import de.markusfisch.android.binaryeye.widget.toast
@@ -19,7 +19,7 @@ fun Context.runAutomatedActions(scan: Scan): Boolean {
return false
}
for (action in actions) {
if (action.matches(content)) {
if (action.matches(content, scan.format.name)) {
return when (action.type) {
AutomatedAction.Type.Intent -> {
val url = action.urlTemplate?.buildUrl(scan)
@@ -55,5 +55,5 @@ private fun Context.execCustomIntent(
toast(R.string.automated_action_intent_uri_invalid)
return false
}
return execShareIntent(intent)
return startIntentOrToast(intent)
}
@@ -1,6 +1,7 @@
package de.markusfisch.android.binaryeye.content
import android.graphics.Bitmap
import android.util.Size
import de.markusfisch.android.binaryeye.database.Scan
import de.markusfisch.android.binaryeye.graphics.COLOR_BLACK
import de.markusfisch.android.binaryeye.graphics.COLOR_WHITE
@@ -48,7 +49,8 @@ fun Scan.toBarcode(): Barcode<*> {
ContentBarcode(
content,
format,
errorCorrectionLevel.toErrorCorrectionInt()
errorCorrectionLevel.toErrorCorrectionInt(),
addQuietZone = true
)
}
}
@@ -108,21 +110,24 @@ class ContentBarcode<T>(
content: T,
format: BarcodeFormat,
val ecLevel: Int = -1,
val margin: Int = 0,
val addQuietZone: Boolean = false,
colors: BarcodeColors = BarcodeColors.BLACK_ON_WHITE
) : Barcode<T>(content, format, colors) {
override fun toBitmap(size: Int) = ZxingCpp.encodeAsBitmap(
content, format, size, format.height(size), margin, ecLevel,
setColor = colors.foregroundColor(),
unsetColor = colors.backgroundColor()
)
override fun toBitmap(size: Int): Bitmap {
val s = format.resolveSize(size)
return ZxingCpp.encodeAsBitmap(
content, format, s.width, s.height, addQuietZone, ecLevel,
setColor = colors.foregroundColor(),
unsetColor = colors.backgroundColor()
)
}
override fun toSvg() = ZxingCpp.encodeAsSvg(
content, format, margin, ecLevel
content, format, addQuietZone, ecLevel
)
override fun toText() = ZxingCpp.encodeAsText(
content, format, margin, ecLevel,
content, format, addQuietZone, ecLevel,
inverted = colors == BarcodeColors.BLACK_ON_WHITE
)
}
@@ -184,10 +189,11 @@ private fun BitMatrix.inflate(
return scaled
}
private fun BarcodeFormat.height(size: Int) = when (this) {
private fun BarcodeFormat.resolveSize(size: Int): Size = when (this) {
// 1D barcodes don't need as much vertical space.
BarcodeFormat.Codabar,
BarcodeFormat.Code39,
BarcodeFormat.Code39Std,
BarcodeFormat.Code39Ext,
BarcodeFormat.Code32,
BarcodeFormat.PZN,
@@ -202,8 +208,14 @@ private fun BarcodeFormat.height(size: Int) = when (this) {
BarcodeFormat.EAN8,
BarcodeFormat.EAN13,
BarcodeFormat.ITF,
BarcodeFormat.Telepen,
BarcodeFormat.TelepenAlpha,
BarcodeFormat.TelepenNumeric,
BarcodeFormat.UPCA,
BarcodeFormat.UPCE -> size / 3
BarcodeFormat.UPCE -> Size(size, size / 3)
else -> size
// PDF417 requires more horizontal space.
BarcodeFormat.PDF417 -> Size(size * 3, size / 2)
else -> Size(size, size)
}
@@ -0,0 +1,239 @@
package de.markusfisch.android.binaryeye.content
import android.content.Context
import android.net.Uri
import androidx.core.net.toUri
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.actions.Action
import de.markusfisch.android.binaryeye.actions.ActionRegistry
import de.markusfisch.android.binaryeye.actions.mail.MatMsg
import de.markusfisch.android.binaryeye.actions.mail.MatMsgAction
import de.markusfisch.android.binaryeye.actions.otpauth.OtpauthAction
import de.markusfisch.android.binaryeye.actions.vtype.VTypeParser
import de.markusfisch.android.binaryeye.actions.vtype.vcard.VCardAction
import de.markusfisch.android.binaryeye.actions.vtype.vevent.VEventAction
import de.markusfisch.android.binaryeye.actions.web.WebAction
import de.markusfisch.android.binaryeye.actions.wifi.WifiAction
import de.markusfisch.android.binaryeye.actions.wifi.WifiConnector
import org.json.JSONException
import org.json.JSONObject
enum class ParsedContentType(
val resId: Int
) {
UNKNOWN(0),
DEUTSCHE_POST(R.string.parsed_type_deutsche_post),
JSON(R.string.parsed_type_json),
INTERNATIONAL_DRIVER_LICENSE(
R.string.parsed_type_international_driver_license
),
DIGITAL_SEAL(R.string.parsed_type_digital_seal),
SEPA_EPC_QR(R.string.parsed_type_sepa_epc_qr),
EMAIL(R.string.parsed_type_email),
URL(R.string.parsed_type_url),
OTP(R.string.parsed_type_otp),
CONTACT_CARD(R.string.parsed_type_contact_card),
CALENDAR_EVENT(R.string.parsed_type_calendar_event),
WIFI_NETWORK(R.string.parsed_type_wifi_network);
}
data class ParsedData(
val type: ParsedContentType,
val fields: List<ParsedField>
)
data class ParsedField(
val key: Any,
val value: CharSequence?,
val link: String? = null
)
fun parseData(
context: Context,
text: String,
bytes: ByteArray,
format: String,
action: Action = ActionRegistry.getAction(bytes)
): ParsedData {
val fields = mutableListOf<ParsedField>()
DeutschePostParser.parse(bytes, format)?.let {
fields.addField(
context.getString(R.string.tracking_number),
it.label,
it.link,
)
return ParsedData(ParsedContentType.DEUTSCHE_POST, fields)
}
try {
fields.addField(R.string.formatted_json, JSONObject(text).toString(2))
return ParsedData(ParsedContentType.JSON, fields)
} catch (_: JSONException) {
// Ignore
}
IdlParser.parse(String(bytes))?.let {
fields.addField("IIN", it.iin)
it.elements.forEach { (id, value) ->
fields.addField(context.idlToRes(id), value)
}
return ParsedData(ParsedContentType.INTERNATIONAL_DRIVER_LICENSE, fields)
}
SealParser.parse(context, bytes)?.let { sealFields ->
sealFields.forEach { vf ->
fields.addField(vf.name, vf.value)
}
return ParsedData(ParsedContentType.DIGITAL_SEAL, fields)
}
EpcQrParser.parse(text)?.let {
fields.addAll(it.map { (id, value) ->
ParsedField(context.epcQrToRes(id), value)
})
return ParsedData(ParsedContentType.SEPA_EPC_QR, fields)
}
when (action) {
is MatMsgAction -> MatMsg(text).run {
fields.addField(R.string.email_to, to)
fields.addField(R.string.email_subject, sub)
fields.addField(R.string.email_body, body)
return ParsedData(ParsedContentType.EMAIL, fields)
}
is VCardAction,
is VEventAction -> VTypeParser.parseMap(text).let { parsedMap ->
parsedMap.forEach { item ->
fields.addField(item.key, item.value.joinToString("\n") { it.value })
}
return ParsedData(
if (action is VCardAction) {
ParsedContentType.CONTACT_CARD
} else {
ParsedContentType.CALENDAR_EVENT
},
fields
)
}
is WebAction -> try {
text.toUri().run {
fields.addField(R.string.scheme, scheme)
fields.addField(R.string.host, host)
fields.addField(R.string.query, query)
}
return ParsedData(ParsedContentType.URL, fields)
} catch (_: Exception) {
// Ignore
}
is OtpauthAction -> try {
text.toUri().run {
val label = pathSegments.firstOrNull()?.let {
Uri.decode(it)
} ?: ""
val colonIdx = label.indexOf(':')
val issuerFromLabel = if (colonIdx >= 0) {
label.substring(0, colonIdx)
} else {
null
}
val account = if (colonIdx >= 0) {
label.substring(colonIdx + 1).trim()
} else {
label
}
val issuer = getQueryParameter("issuer") ?: issuerFromLabel
fields.addField(R.string.entry_type, host?.uppercase())
fields.addField(R.string.otp_account, account)
fields.addField(R.string.otp_issuer, issuer)
fields.addField(R.string.otp_algorithm, getQueryParameter("algorithm"))
fields.addField(R.string.otp_digits, getQueryParameter("digits"))
if (host == "totp") {
fields.addField(R.string.otp_period, getQueryParameter("period"))
} else {
fields.addField(R.string.otp_counter, getQueryParameter("counter"))
}
}
return ParsedData(ParsedContentType.OTP, fields)
} catch (_: Exception) {
// Ignore
}
is WifiAction -> WifiConnector.parseMap(text)?.let { wifiData ->
fields.addField(R.string.wifi_ssid, wifiData["S"])
fields.addField(R.string.wifi_password, wifiData["P"])
fields.addField(R.string.wifi_type, wifiData["T"])
fields.addField(R.string.wifi_hidden, wifiData["H"])
fields.addField(R.string.wifi_eap, wifiData["E"])
fields.addField(R.string.wifi_identity, wifiData["I"])
fields.addField(R.string.wifi_anonymous_identity, wifiData["A"])
fields.addField(R.string.wifi_phase2, wifiData["PH2"])
return ParsedData(ParsedContentType.WIFI_NETWORK, fields)
}
}
return ParsedData(ParsedContentType.UNKNOWN, emptyList())
}
fun parsedContentTypeFromName(
name: String?
): ParsedContentType = try {
ParsedContentType.valueOf(name ?: "")
} catch (_: IllegalArgumentException) {
ParsedContentType.UNKNOWN
}
fun getContentPreview(
context: Context,
text: String,
bytes: ByteArray,
format: String,
action: Action = ActionRegistry.getAction(bytes)
): CharSequence {
val parsedData = parseData(context, text, bytes, format, action)
return when (parsedData.type) {
ParsedContentType.UNKNOWN -> text.ifEmpty {
bytes.toHexString()
}
ParsedContentType.URL -> parsedData.valueFor(R.string.host)
?: text
ParsedContentType.WIFI_NETWORK -> parsedData.valueFor(R.string.wifi_ssid)
?: parsedData.firstValue()
?: text
ParsedContentType.EMAIL -> parsedData.valueFor(R.string.email_to)
?: parsedData.valueFor(R.string.email_subject)
?: parsedData.firstValue()
?: text
ParsedContentType.OTP -> parsedData.valueFor(R.string.otp_account)
?: parsedData.firstValue()
?: text
else -> parsedData.firstValue() ?: text
}
}
private fun ParsedData.valueFor(key: Int): CharSequence? =
fields.firstValue { it.key == key }
private fun ParsedData.firstValue(): CharSequence? =
fields.firstValue()
private fun List<ParsedField>.firstValue(
predicate: (ParsedField) -> Boolean = { true }
): CharSequence? = firstOrNull {
predicate(it) && !it.value.isNullOrBlank()
}?.value
private fun MutableList<ParsedField>.addField(
key: Any,
value: CharSequence?,
link: String? = null
) {
add(ParsedField(key, value, link))
}
@@ -0,0 +1,82 @@
package de.markusfisch.android.binaryeye.content
import de.markusfisch.android.zxingcpp.ZxingCpp
data class DeutschePostTrackingLink(
val label: String,
val link: String
)
object DeutschePostParser {
fun parse(
bytes: ByteArray,
format: String
): DeutschePostTrackingLink? {
var isStamp = false
var rawData = bytes
if (format == ZxingCpp.BarcodeFormat.DataMatrix.name &&
bytes.toString(Charsets.ISO_8859_1).startsWith("DEA5")
) {
if (bytes.size == 47) {
isStamp = true
} else if (bytes.size > 47) {
// Transform back to original data.
rawData = bytes.toString(Charsets.UTF_8).toByteArray(
Charsets.ISO_8859_1
)
if (rawData.size == 47) {
isStamp = true
}
}
}
if (!isStamp) {
return null
}
val hex = StringBuilder()
hex.append(String.format("%02X", rawData[9]))
hex.append(String.format("%02X", rawData[10]))
hex.append(String.format("%02X", rawData[11]))
hex.append(String.format("%02X", rawData[12]))
hex.append(String.format("%02X", rawData[13]))
hex.append(String.format("%X", (rawData[4].toInt() and 0x0f).toByte()))
hex.append(String.format("%02X", rawData[5]))
hex.append(String.format("%02X", rawData[6]))
hex.append(String.format("%02X", rawData[7]))
hex.append(String.format("%02X", rawData[8]))
val hexString = hex.toString()
val trackingNumber = hexString + String.format(
"%X",
crc4(hexString.toByteArray(Charsets.ISO_8859_1))
)
return DeutschePostTrackingLink(
trackingNumber,
"https://www.deutschepost.de/de/s/sendungsverfolgung.html?piececode=$trackingNumber",
)
}
// CRC-4 with polynomial x^4 + x + 1.
private fun crc4(input: ByteArray): Int {
var crc = 0
var i = 0
while (i < input.size) {
val c = input[i].toInt()
var j = 0x80
while (j != 0) {
var bit = crc and 0x8
crc = crc shl 1
if (c and j != 0) {
bit = bit xor 0x8
}
if (bit != 0) {
crc = crc xor 0x3
}
j = j ushr 1
}
++i
}
crc = crc and 0xF
return crc
}
}
@@ -0,0 +1,23 @@
package de.markusfisch.android.binaryeye.content
import android.content.Context
import de.markusfisch.android.binaryeye.R
private val elementToLabel = mapOf(
EpcQrElement.SERVICE_TAG to R.string.epc_service_tag,
EpcQrElement.VERSION to R.string.epc_version,
EpcQrElement.CHARSET to R.string.epc_charset,
EpcQrElement.IDENTIFICATION to R.string.epc_identification,
EpcQrElement.BIC to R.string.epc_bic,
EpcQrElement.NAME to R.string.epc_name,
EpcQrElement.IBAN to R.string.epc_iban,
EpcQrElement.AMOUNT to R.string.epc_amount,
EpcQrElement.PURPOSE to R.string.epc_purpose,
EpcQrElement.REFERENCE to R.string.epc_reference,
EpcQrElement.TEXT to R.string.epc_text,
EpcQrElement.INFORMATION to R.string.epc_information
)
fun Context.epcQrToRes(element: EpcQrElement): String {
return elementToLabel[element]?.let { getString(it) } ?: element.name
}
@@ -0,0 +1,141 @@
package de.markusfisch.android.binaryeye.content
enum class EpcQrElement {
SERVICE_TAG, VERSION, CHARSET, IDENTIFICATION,
BIC, NAME, IBAN, AMOUNT, PURPOSE, REFERENCE, TEXT, INFORMATION
}
object EpcQrParser {
private val BIC_REGEX = Regex("[A-Z0-9]{8}([A-Z0-9]{3})?")
private val IBAN_REGEX = Regex("[A-Z]{2}[0-9A-Z]{13,32}")
private val AMOUNT_REGEX = Regex("EUR\\d{1,10}(\\.\\d{2})?")
private val PURPOSE_REGEX = Regex("[A-Z]{4}")
private val charsetMap = mapOf(
"1" to "UTF-8",
"2" to "ISO 8859-1",
"3" to "ISO 8859-2",
"4" to "ISO 8859-4",
"5" to "ISO 8859-5",
"6" to "ISO 8859-7",
"7" to "ISO 8859-10",
"8" to "ISO 8859-15"
)
private fun splitLines(s: String): List<String>? {
val normalized = s
.replace("\r\n", "\n")
.replace('\r', '\n')
.removeSuffix("\n")
if (normalized.isEmpty()) {
return null
}
return normalized.split('\n')
}
private fun isValidVersion(value: String): Boolean {
return value == "001" || value == "002"
}
private fun isValidCharset(value: String): Boolean {
return charsetMap.containsKey(value)
}
private fun isValidIdentification(value: String): Boolean {
return value == "SCT"
}
private fun isValidBic(value: String): Boolean {
return value.matches(BIC_REGEX)
}
private fun isValidIban(value: String): Boolean {
return value.matches(IBAN_REGEX)
}
private fun isValidAmount(value: String): Boolean {
return value.matches(AMOUNT_REGEX)
}
fun parse(s: String?): Map<EpcQrElement, String>? {
if (s.isNullOrEmpty()) {
return null
}
val lines = splitLines(s) ?: return null
if (lines.size !in 7..12) {
return null
}
if (lines[0] != "BCD") {
return null
}
if (!isValidVersion(lines[1])) {
return null
}
if (!isValidCharset(lines[2])) {
return null
}
if (!isValidIdentification(lines[3])) {
return null
}
val bic = lines.getOrElse(4) { "" }
if ((lines[1] == "001" || bic.isNotEmpty()) && !isValidBic(bic)) {
return null
}
val recipient = lines.getOrElse(5) { "" }
if (recipient.isBlank() || recipient.length > 70) {
return null
}
val iban = lines.getOrElse(6) { "" }
if (!isValidIban(iban)) {
return null
}
val amount = lines.getOrElse(7) { "" }
if (amount.isNotEmpty() && !isValidAmount(amount)) {
return null
}
val purpose = lines.getOrElse(8) { "" }
if (purpose.isNotEmpty() && !purpose.matches(PURPOSE_REGEX)) {
return null
}
val remittanceReference = lines.getOrElse(9) { "" }
if (remittanceReference.length > 25) {
return null
}
val remittanceText = lines.getOrElse(10) { "" }
if (remittanceText.length > 140) {
return null
}
if (remittanceReference.isNotEmpty() && remittanceText.isNotEmpty()) {
return null
}
val information = lines.getOrElse(11) { "" }
if (information.length > 70) {
return null
}
return linkedMapOf(
EpcQrElement.SERVICE_TAG to lines[0],
EpcQrElement.VERSION to lines[1],
EpcQrElement.CHARSET to (charsetMap[lines[2]] ?: lines[2]),
EpcQrElement.IDENTIFICATION to lines[3],
EpcQrElement.BIC to bic,
EpcQrElement.NAME to recipient,
EpcQrElement.IBAN to iban,
EpcQrElement.AMOUNT to amount.removePrefix("EUR"),
EpcQrElement.PURPOSE to purpose,
EpcQrElement.REFERENCE to remittanceReference,
EpcQrElement.TEXT to remittanceText,
EpcQrElement.INFORMATION to information
)
}
}
@@ -0,0 +1,191 @@
package de.markusfisch.android.binaryeye.content
import android.content.Context
import de.markusfisch.android.binaryeye.R
import de.tsenger.vdstools.DataEncoder
import de.tsenger.vdstools.Verifier
import de.tsenger.vdstools.idb.IdbSeal
import org.bouncycastle.asn1.ASN1ObjectIdentifier
import org.bouncycastle.asn1.icao.CscaMasterList
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo
import org.bouncycastle.asn1.x9.ECNamedCurveTable
import org.bouncycastle.asn1.x9.X962Parameters
import org.bouncycastle.cert.X509CertificateHolder
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter
import org.bouncycastle.cms.CMSSignedData
import org.bouncycastle.jce.provider.BouncyCastleProvider
import java.security.Provider
import java.security.Security
import java.security.cert.X509Certificate
import java.util.Date
object IdbVerifier {
private const val MASTER_LIST_ASSET = "DE_ML_2026-01-08-12-20-54.ml"
private val provider: Provider by lazy {
BouncyCastleProvider().also { bcProvider ->
if (Security.getProvider(bcProvider.name) == null) {
Security.addProvider(bcProvider)
}
}
}
@Volatile
private var cachedMasterListCertificates: List<X509Certificate>? = null
fun verify(context: Context, seal: IdbSeal): List<SealField> {
val signatureInfo = seal.signatureInfo
?: return listOf(
SealField(
R.string.idb_verification,
context.getString(R.string.idb_verification_unsigned)
)
)
val headerCertificateReference = seal.signerCertReference
?.hexToByteArrayOrNull()
val signerCertificate = context.findCertificateByReference(
headerCertificateReference
)
val curveName = signerCertificate?.let {
curveNameFrom(it)
}
val signedBytes = seal.signedBytes
val signatureResult = if (
signerCertificate == null ||
curveName.isNullOrBlank() ||
signedBytes == null
) {
Verifier.Result.VerifyError
} else {
Verifier(
signedBytes,
signatureInfo.plainSignatureBytes,
signerCertificate.publicKey.encoded,
curveName
).verify()
}
val referenceMatches = signerCertificate != null &&
headerCertificateReference != null &&
DataEncoder.buildCertificateReference(signerCertificate.encoded)
.contentEquals(headerCertificateReference)
val certificateValidNow = signerCertificate != null && runCatching {
signerCertificate.checkValidity(Date())
true
}.getOrElse {
false
}
val verification = context.getString(
when {
signerCertificate == null ->
R.string.idb_verification_signer_certificate_issuer_not_found_in_master_list
!referenceMatches ->
R.string.idb_verification_signature_invalid
!certificateValidNow ->
R.string.idb_verification_signature_invalid
signatureResult == Verifier.Result.SignatureValid ->
R.string.idb_verification_verified
signatureResult == Verifier.Result.SignatureInvalid ->
R.string.idb_verification_signature_invalid
else -> R.string.idb_verification_error
}
)
val fields = mutableListOf(
SealField(R.string.idb_verification, verification),
SealField(
R.string.idb_certificate_reference,
signatureInfo.signerCertificateReference
),
SealField(
R.string.vds_signing_date,
signatureInfo.signingDate.toString()
),
SealField(
R.string.idb_signature_verification,
signatureResult.name
),
)
if (signerCertificate != null) {
fields.add(
SealField(
R.string.idb_signer_certificate_subject,
signerCertificate.subjectX500Principal.name
)
)
fields.add(
SealField(
R.string.idb_signer_certificate_issuer,
signerCertificate.issuerX500Principal.name
)
)
}
return fields
}
private fun String.hexToByteArrayOrNull(): ByteArray? {
if (length % 2 != 0) {
return null
}
return runCatching {
ByteArray(length / 2) { index ->
substring(index * 2, index * 2 + 2).toInt(16).toByte()
}
}.getOrNull()
}
private fun Context.loadMasterListCertificates(): List<X509Certificate> {
cachedMasterListCertificates?.let {
return it
}
synchronized(this) {
cachedMasterListCertificates?.let {
return it
}
val cmsSignedData = assets.open(MASTER_LIST_ASSET).use { input ->
CMSSignedData(input.readBytes())
}
val signedContent = cmsSignedData.signedContent?.content as? ByteArray
?: throw IllegalArgumentException("Master list does not contain signed content")
val masterList = CscaMasterList.getInstance(signedContent)
val certificates = masterList.certStructs.map { cert ->
JcaX509CertificateConverter()
.setProvider(provider)
.getCertificate(X509CertificateHolder(cert))
}
cachedMasterListCertificates = certificates
return certificates
}
}
private fun Context.findCertificateByReference(
certificateReference: ByteArray?
): X509Certificate? {
if (certificateReference == null || certificateReference.size != 5) {
return null
}
return runCatching {
loadMasterListCertificates().firstOrNull { certificate ->
DataEncoder.buildCertificateReference(certificate.encoded)
.contentEquals(certificateReference)
}
}.getOrNull()
}
private fun curveNameFrom(certificate: X509Certificate): String? {
return runCatching {
val subjectPublicKeyInfo = SubjectPublicKeyInfo.getInstance(
certificate.publicKey.encoded
)
val params = X962Parameters.getInstance(subjectPublicKeyInfo.algorithm.parameters)
val oid = params.parameters as? ASN1ObjectIdentifier
oid?.let {
ECNamedCurveTable.getName(it)
}
}.getOrNull()
}
}
@@ -96,15 +96,30 @@ object IdlParser {
val data = findSubType(s, idlInfo)
val len = data.length
var start = 0
var skipSubtype = false
for (p in 0..len) {
if (p < len && data[p] > '\u001f') {
continue
}
if (p - start > 3) {
val vp = start + 3
val key = data.substring(start, vp)
// Per spec, CR (0x0d) is the subfile boundary marker. The segment
// immediately following a CR may begin with a 2-char subfile type
// identifier (e.g. "ZV", "ZI") prepended to the first field.
// Detect this by checking that the marker matches the key prefix
// (jurisdiction fields always share their 2-char subfile prefix).
val seg = if (skipSubtype && p - start >= 4 &&
data[start] == data[start + 2] &&
data[start + 1] == data[start + 3]
) {
start + 2
} else {
start
}
if (p - seg > 3) {
val vp = seg + 3
val key = data.substring(seg, vp)
if (key.matches(Regex("[DZ][A-Z]{2}"))) {
var value = data.substring(vp, p)
@@ -114,6 +129,7 @@ object IdlParser {
idlInfo.elements[key] = value.trim()
}
}
skipSubtype = p < len && data[p] == '\r'
start = p + 1
}
@@ -0,0 +1,142 @@
package de.markusfisch.android.binaryeye.content
import android.content.Context
import de.markusfisch.android.binaryeye.R
import de.tsenger.vdstools.asn1.DerTlv
import de.tsenger.vdstools.generic.Message
import de.tsenger.vdstools.generic.MessageCoding
import de.tsenger.vdstools.generic.MessageValue
import de.tsenger.vdstools.generic.Seal
import de.tsenger.vdstools.idb.IdbSeal
import org.json.JSONArray
import org.json.JSONObject
import de.tsenger.vdstools.generic.SealParser as VdsSealParser
data class SealField(val name: Any, val value: String)
private data class IdbChildMessageDefinition(
val name: String,
val coding: MessageCoding
)
object SealParser {
private val idbChildMessagesByName: Map<String, Map<Int, IdbChildMessageDefinition>> by lazy {
loadIdbChildMessagesByName()
}
fun parse(
context: Context,
bytes: ByteArray
): List<SealField>? = try {
context.buildFields(
VdsSealParser().parse(bytes.toString(Charsets.ISO_8859_1))
)
} catch (_: Exception) {
null
}
private fun Context.buildFields(seal: Seal): List<SealField> = buildList {
add(SealField(R.string.vds_document_type, seal.documentType))
add(SealField(R.string.vds_issuing_country, seal.issuingCountry))
if (seal is IdbSeal) {
addAll(IdbVerifier.verify(this@buildFields, seal))
}
for (message in seal.messageList) {
addAll(message.toFields())
}
}
private fun Message.toFields(): List<SealField> = buildList {
add(
if (value is MessageValue.BytesValue) {
SealField(
"$name (${value.rawBytes.size} bytes)",
value.rawBytes.toHexString().uppercase()
)
} else {
SealField(name, value.toString())
}
)
for (child in childMessages()) {
addAll(child.toFields())
}
}
private fun Message.childMessages(): List<Message> {
val childDefinitionsByTag = idbChildMessagesByName[name] ?: return emptyList()
return runCatching {
DerTlv.parseAll(value.rawBytes).mapNotNull { derTlv ->
val childTag = derTlv.tag.toInt() and 0xff
val childDefinition = childDefinitionsByTag[childTag]
?: return@mapNotNull null
Message(
childTag,
childDefinition.name,
childDefinition.coding,
MessageValue.fromBytes(
derTlv.value,
childDefinition.coding
)
)
}
}.getOrElse {
emptyList()
}
}
private fun loadIdbChildMessagesByName(): Map<String, Map<Int, IdbChildMessageDefinition>> {
val resourceConstantsClass = runCatching {
Class.forName("de.tsenger.vdstools.generated.ResourceConstants")
}.getOrElse {
return emptyMap()
}
val json = runCatching {
resourceConstantsClass.getField(
"IDB_MESSAGE_TYPES_JSON"
).get(null) as String
}.getOrElse {
return emptyMap()
}
val childMessagesByName =
mutableMapOf<String, Map<Int, IdbChildMessageDefinition>>()
val messageTypes = JSONArray(json)
for (i in 0 until messageTypes.length()) {
val messageType = messageTypes.getJSONObject(i)
val messages = messageType.optJSONArray("messages") ?: continue
collectChildMessages(
messageType.getString("name"),
messages,
childMessagesByName
)
}
return childMessagesByName
}
private fun collectChildMessages(
parentName: String,
messages: JSONArray,
childMessagesByName: MutableMap<String, Map<Int, IdbChildMessageDefinition>>
) {
val childMessages = mutableMapOf<Int, IdbChildMessageDefinition>()
for (i in 0 until messages.length()) {
val message = messages.getJSONObject(i)
val childDefinition = message.toChildMessageDefinition()
childMessages[message.getInt("tag")] = childDefinition
val nestedMessages = message.optJSONArray("messages") ?: continue
collectChildMessages(
childDefinition.name,
nestedMessages,
childMessagesByName
)
}
if (childMessages.isNotEmpty()) {
childMessagesByName[parentName] = childMessages
}
}
private fun JSONObject.toChildMessageDefinition(): IdbChildMessageDefinition {
return IdbChildMessageDefinition(
getString("name"),
MessageCoding.valueOf(getString("coding"))
)
}
}
@@ -9,12 +9,13 @@ import androidx.core.content.FileProvider
import androidx.core.net.toUri
import de.markusfisch.android.binaryeye.BuildConfig
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.app.prefs
import de.markusfisch.android.binaryeye.widget.toast
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
fun Context.execShareIntent(intent: Intent): Boolean = if (
fun Context.startIntentOrToast(intent: Intent): Boolean = if (
!startIntent(intent)
) {
toast(R.string.cannot_resolve_action)
@@ -43,24 +44,27 @@ fun Context.openUrl(url: String, silent: Boolean = false): Boolean {
}
fun Context.openUri(uri: Uri, silent: Boolean = false): Boolean {
val intent = Intent(Intent.ACTION_VIEW, uri).apply {
if (uri.scheme == "content") {
val cleaned = if (prefs.stripTrackingParams) {
uri.stripTrackingParams()
} else {
uri
}
val intent = Intent(Intent.ACTION_VIEW, cleaned).apply {
if (cleaned.scheme == "content") {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}
return if (silent) {
startIntent(intent)
} else {
execShareIntent(intent)
startIntentOrToast(intent)
}
}
fun String.parseAndNormalizeUri(): Uri = toUri().let {
it.normalizeScheme()
}
fun String.parseAndNormalizeUri(): Uri = toUri().normalizeScheme()
fun Context.shareText(text: String, mimeType: String = "text/plain") {
execShareIntent(Intent(Intent.ACTION_SEND).apply {
startIntentOrToast(Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_TEXT, text)
type = mimeType
})
@@ -123,7 +127,7 @@ private fun Context.getUriForFile(file: File): Uri? = if (
}
private fun Context.shareUri(uri: Uri, mimeType: String) {
execShareIntent(Intent(Intent.ACTION_SEND).apply {
startIntentOrToast(Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
type = mimeType
@@ -0,0 +1,44 @@
package de.markusfisch.android.binaryeye.content
import android.net.Uri
private val TRACKING_PARAMS = setOf(
"utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content",
"utm_id", "utm_name", "utm_referrer", "utm_brand", "utm_social",
"fbclid", "gclid", "dclid", "gclsrc", "yclid", "msclkid",
"mc_eid", "mc_cid",
"_ga", "_gl", "ga_source", "ga_medium",
"igshid", "igsh",
"hsa_acc", "hsa_cam", "hsa_grp", "hsa_ad", "hsa_src",
"hsa_tgt", "hsa_kw", "hsa_mt", "hsa_net", "hsa_ver",
"spm", "scm", "vero_id", "vero_conv"
)
fun Uri.stripTrackingParams(): Uri {
val scheme = scheme?.lowercase()
if (scheme != "http" && scheme != "https") {
return this
}
val queryNames = runCatching {
queryParameterNames
}.getOrDefault(emptySet())
if (queryNames.isEmpty()) {
return this
}
val keep = queryNames.filter { paramName ->
paramName.lowercase() !in TRACKING_PARAMS
}
if (keep.size == queryNames.size) {
return this
}
val builder = buildUpon().clearQuery()
for (paramName in keep) {
getQueryParameters(paramName).forEach { paramValue ->
builder.appendQueryParameter(paramName, paramValue)
}
}
return builder.build()
}
@@ -29,6 +29,7 @@ private fun exportCsv(
return
}
val columns = arrayOf(
Database.SCANS_PINNED,
Database.SCANS_DATETIME,
Database.SCANS_FORMAT,
Database.SCANS_NAME,
@@ -6,33 +6,44 @@ import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import de.markusfisch.android.binaryeye.app.prefs
import de.markusfisch.android.binaryeye.content.ParsedContentType
import de.markusfisch.android.binaryeye.content.parseData
import de.markusfisch.android.binaryeye.content.parsedContentTypeFromName
import de.markusfisch.android.binaryeye.preference.Preferences
import de.markusfisch.android.binaryeye.zxingcpp.migrateBarcodeFormatName
import de.markusfisch.android.binaryeye.zxingcpp.oldToNewFormatNames
import de.markusfisch.android.zxingcpp.ZxingCpp.BarcodeFormat
class Database {
private lateinit var db: SQLiteDatabase
private lateinit var context: Context
fun open(context: Context) {
this.context = context.applicationContext
db = OpenHelper(context).writableDatabase
fillMissingContentTypes()
}
fun getScans(query: String? = null): Cursor = db.rawQuery(
"""SELECT
$SCANS_ID,
$SCANS_PINNED,
$SCANS_DATETIME,
$SCANS_NAME,
$SCANS_TEXT,
$SCANS_RAW,
$SCANS_CONTENT_TYPE,
$SCANS_FORMAT
FROM $SCANS
${getWhereClause(query)}
ORDER BY $SCANS_DATETIME DESC
ORDER BY $SCANS_PINNED DESC, $SCANS_DATETIME DESC
""".trimMargin(), getWhereArguments(query)
)
fun getScansDetailed(query: String? = null): Cursor = db.rawQuery(
"""SELECT
$SCANS_ID,
$SCANS_PINNED,
$SCANS_DATETIME,
$SCANS_NAME,
$SCANS_TEXT,
@@ -46,10 +57,11 @@ class Database {
$SCANS_GTIN_COUNTRY,
$SCANS_GTIN_ADD_ON,
$SCANS_GTIN_PRICE,
$SCANS_GTIN_ISSUE_NUMBER
$SCANS_GTIN_ISSUE_NUMBER,
$SCANS_CONTENT_TYPE
FROM $SCANS
${getWhereClause(query)}
ORDER BY $SCANS_DATETIME DESC
ORDER BY $SCANS_PINNED DESC, $SCANS_DATETIME DESC
""".trimMargin(), getWhereArguments(query)
)
@@ -58,7 +70,8 @@ class Database {
prefix: String = "WHERE"
) = if (query?.isNotEmpty() == true) {
"""$prefix $SCANS_TEXT LIKE ?
OR $SCANS_NAME LIKE ?"""
OR $SCANS_NAME LIKE ?
OR $SCANS_FORMAT LIKE ?"""
} else {
""
}
@@ -67,7 +80,7 @@ class Database {
query: String?
) = if (query?.isNotEmpty() == true) {
val instr = "%$query%"
arrayOf(instr, instr)
arrayOf(instr, instr, instr)
} else {
null
}
@@ -75,6 +88,7 @@ class Database {
fun getScansDetailed(ids: LongArray): Cursor = db.rawQuery(
"""SELECT
$SCANS_ID,
$SCANS_PINNED,
$SCANS_DATETIME,
$SCANS_NAME,
$SCANS_TEXT,
@@ -88,16 +102,18 @@ class Database {
$SCANS_GTIN_COUNTRY,
$SCANS_GTIN_ADD_ON,
$SCANS_GTIN_PRICE,
$SCANS_GTIN_ISSUE_NUMBER
$SCANS_GTIN_ISSUE_NUMBER,
$SCANS_CONTENT_TYPE
FROM $SCANS
WHERE $SCANS_ID IN (${ids.joinToString(",")})
ORDER BY $SCANS_DATETIME DESC
ORDER BY $SCANS_PINNED DESC, $SCANS_DATETIME DESC
""".trimMargin(), null
)
fun getScan(id: Long): Scan? = db.rawQuery(
"""SELECT
$SCANS_ID,
$SCANS_PINNED,
$SCANS_DATETIME,
$SCANS_NAME,
$SCANS_TEXT,
@@ -115,7 +131,8 @@ class Database {
$SCANS_GTIN_COUNTRY,
$SCANS_GTIN_ADD_ON,
$SCANS_GTIN_PRICE,
$SCANS_GTIN_ISSUE_NUMBER
$SCANS_GTIN_ISSUE_NUMBER,
$SCANS_CONTENT_TYPE
FROM $SCANS
WHERE $SCANS_ID = ?
""".trimMargin(), arrayOf("$id")
@@ -145,6 +162,8 @@ class Database {
it.getString(SCANS_DATETIME),
it.getLong(SCANS_ID),
it.getString(SCANS_NAME),
it.getInt(SCANS_PINNED) != 0,
parsedContentTypeFromName(it.getString(SCANS_CONTENT_TYPE))
)
} else {
null
@@ -200,6 +219,11 @@ class Database {
scan.addOn?.let { put(SCANS_GTIN_ADD_ON, it) }
scan.price?.let { put(SCANS_GTIN_PRICE, it) }
scan.issueNumber?.let { put(SCANS_GTIN_ISSUE_NUMBER, it) }
put(SCANS_PINNED, if (scan.pinned) 1 else 0)
put(
SCANS_CONTENT_TYPE,
getContentType(scan.text, scan.raw, scan.format.name).name
)
}
)
}
@@ -265,8 +289,58 @@ class Database {
db.update(SCANS, cv, "$SCANS_ID = ?", arrayOf("$id"))
}
fun setPinned(id: Long, pinned: Boolean) {
val cv = ContentValues()
cv.put(SCANS_PINNED, if (pinned) 1 else 0)
db.update(SCANS, cv, "$SCANS_ID = ?", arrayOf("$id"))
}
private fun fillMissingContentTypes() {
Thread {
db.rawQuery(
"""SELECT
$SCANS_ID,
$SCANS_TEXT,
$SCANS_RAW,
$SCANS_FORMAT
FROM $SCANS
WHERE $SCANS_CONTENT_TYPE IS NULL
""".trimMargin(),
null
).use {
while (it.moveToNext()) {
val id = it.getLong(SCANS_ID)
val type = getContentType(
it.getString(SCANS_TEXT),
it.getBlob(SCANS_RAW),
it.getString(SCANS_FORMAT)
)
db.update(
SCANS,
ContentValues().apply {
put(SCANS_CONTENT_TYPE, type.name)
},
"$SCANS_ID = ?",
arrayOf("$id")
)
}
}
}.start()
}
private fun getContentType(
text: String,
raw: ByteArray?,
format: String
) = parseData(
context,
text,
raw ?: text.toByteArray(),
format
).type
private class OpenHelper(context: Context) :
SQLiteOpenHelper(context, FILE_NAME, null, 9) {
SQLiteOpenHelper(context, FILE_NAME, null, 11) {
override fun onCreate(db: SQLiteDatabase) {
db.createScans()
}
@@ -300,6 +374,12 @@ class Database {
if (oldVersion < 9) {
db.migrateToNativeFormatNames()
}
if (oldVersion < 10) {
db.addPinnedColumn()
}
if (oldVersion < 11) {
db.addContentTypeColumn()
}
}
}
@@ -333,6 +413,8 @@ class Database {
const val SCANS_GTIN_ADD_ON = "gtin_add_on"
const val SCANS_GTIN_PRICE = "gtin_price"
const val SCANS_GTIN_ISSUE_NUMBER = "gtin_issue_number"
const val SCANS_PINNED = "pinned"
const val SCANS_CONTENT_TYPE = "content_type"
private fun SQLiteDatabase.createScans() {
execSQL("DROP TABLE IF EXISTS $SCANS".trimMargin())
@@ -356,7 +438,9 @@ class Database {
$SCANS_GTIN_COUNTRY TEXT,
$SCANS_GTIN_ADD_ON TEXT,
$SCANS_GTIN_PRICE TEXT,
$SCANS_GTIN_ISSUE_NUMBER TEXT
$SCANS_GTIN_ISSUE_NUMBER TEXT,
$SCANS_PINNED INTEGER NOT NULL DEFAULT 0,
$SCANS_CONTENT_TYPE TEXT NOT NULL DEFAULT '${ParsedContentType.UNKNOWN.name}'
)""".trimMargin()
)
}
@@ -398,35 +482,7 @@ class Database {
}
private fun SQLiteDatabase.migrateToNativeFormatNames() {
val mappings = listOf(
"NONE" to "None",
"AZTEC" to "Aztec",
"CODABAR" to "Codabar",
"CODE_39" to "Code39",
"CODE_39_EXT" to "Code39Ext",
"CODE_32" to "Code32",
"PZN" to "PZN",
"CODE_93" to "Code93",
"CODE_128" to "Code128",
"DATA_BAR_OMNI" to "DataBarOmni",
"DATA_BAR_STK" to "DataBarStk",
"DATA_BAR_LTD" to "DataBarLtd",
"DATA_BAR_EXPANDED" to "DataBarExp",
"DATA_BAR_EXP_STK" to "DataBarExpStk",
"DATA_MATRIX" to "DataMatrix",
"DX_FILM_EDGE" to "DXFilmEdge",
"EAN_8" to "EAN8",
"EAN_13" to "EAN13",
"ITF" to "ITF",
"MAXICODE" to "MaxiCode",
"PDF_417" to "PDF417",
"QR_CODE" to "QRCode",
"MICRO_QR_CODE" to "MicroQRCode",
"RMQR_CODE" to "RMQRCode",
"UPC_A" to "UPCA",
"UPC_E" to "UPCE"
)
for ((oldName, newName) in mappings) {
for ((oldName, newName) in oldToNewFormatNames) {
execSQL(
"UPDATE $SCANS SET $SCANS_FORMAT = ? WHERE $SCANS_FORMAT = ?",
arrayOf(newName, oldName)
@@ -449,5 +505,15 @@ class Database {
execSQL("ALTER TABLE $SCANS ADD $SCANS_SYMBOL_HEIGHT INTEGER")
execSQL("ALTER TABLE $SCANS ADD $SCANS_SYMBOL_DATA BLOB")
}
private fun SQLiteDatabase.addPinnedColumn() {
execSQL(
"ALTER TABLE $SCANS ADD COLUMN $SCANS_PINNED INTEGER NOT NULL DEFAULT 0"
)
}
private fun SQLiteDatabase.addContentTypeColumn() {
execSQL("ALTER TABLE $SCANS ADD COLUMN $SCANS_CONTENT_TYPE TEXT")
}
}
}
@@ -21,6 +21,7 @@ fun Cursor.exportJson(): String? {
return null
}
val columns = arrayOf(
Database.SCANS_PINNED,
Database.SCANS_DATETIME,
Database.SCANS_FORMAT,
Database.SCANS_NAME,
@@ -2,11 +2,13 @@ package de.markusfisch.android.binaryeye.database
import android.os.Bundle
import android.text.format.DateFormat
import de.markusfisch.android.binaryeye.content.ParsedContentType
import de.markusfisch.android.binaryeye.content.parsedContentTypeFromName
import de.markusfisch.android.binaryeye.zxingcpp.migrateBarcodeFormatName
import de.markusfisch.android.zxingcpp.ZxingCpp.BarcodeFormat
import de.markusfisch.android.zxingcpp.ZxingCpp.BitMatrix
import de.markusfisch.android.zxingcpp.ZxingCpp.ContentType
import de.markusfisch.android.zxingcpp.ZxingCpp.Result
import de.markusfisch.android.binaryeye.zxingcpp.migrateBarcodeFormatName
import java.util.Locale
data class Scan(
@@ -26,7 +28,9 @@ data class Scan(
val issueNumber: String? = null,
val dateTime: String = getDateTime(),
var id: Long = 0L,
val label: String? = null
val label: String? = null,
val pinned: Boolean = false,
val contentType: ParsedContentType = ParsedContentType.UNKNOWN
) {
// Needs to be overwritten manually, as ByteArray is an array and
// this isn't handled well by Kotlin.
@@ -56,7 +60,10 @@ data class Scan(
country == other.country &&
addOn == other.addOn &&
price == other.price &&
issueNumber == other.issueNumber
issueNumber == other.issueNumber &&
label == other.label &&
pinned == other.pinned &&
contentType == other.contentType
}
// Needs to be overwritten manually, as ByteArray is an array and
@@ -77,6 +84,9 @@ data class Scan(
result = 31 * result + (addOn?.hashCode() ?: 0)
result = 31 * result + (price?.hashCode() ?: 0)
result = 31 * result + (issueNumber?.hashCode() ?: 0)
result = 31 * result + (label?.hashCode() ?: 0)
result = 31 * result + pinned.hashCode()
result = 31 * result + contentType.hashCode()
return result
}
@@ -127,6 +137,8 @@ fun Scan.toBundle() = Bundle().apply {
putString(SCAN_DATE_TIME, dateTime)
putLong(SCAN_ID, id)
putString(SCAN_LABEL, label)
putBoolean(SCAN_PINNED, pinned)
putString(SCAN_CONTENT_TYPE, contentType.name)
}
fun Bundle.toScan(): Scan? {
@@ -152,7 +164,9 @@ fun Bundle.toScan(): Scan? {
issueNumber = getString(SCAN_ISSUE_NUMBER),
dateTime = getString(SCAN_DATE_TIME) ?: getDateTime(),
id = getLong(SCAN_ID, 0L),
label = getString(SCAN_LABEL)
label = getString(SCAN_LABEL),
pinned = getBoolean(SCAN_PINNED, false),
contentType = parsedContentTypeFromName(getString(SCAN_CONTENT_TYPE))
)
} catch (_: IllegalArgumentException) {
null
@@ -199,3 +213,5 @@ private const val SCAN_ISSUE_NUMBER = "issue_number"
private const val SCAN_DATE_TIME = "date_time"
private const val SCAN_ID = "id"
private const val SCAN_LABEL = "label"
private const val SCAN_PINNED = "pinned"
private const val SCAN_CONTENT_TYPE = "content_type"
@@ -1,29 +1,38 @@
package de.markusfisch.android.binaryeye.fragment
import android.app.Activity
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import android.database.Cursor
import android.net.Uri
import android.net.wifi.WifiManager
import android.os.Build
import android.os.Bundle
import android.provider.OpenableColumns
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.core.net.toUri
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
import androidx.preference.MultiSelectListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceGroup
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.activity.AutomatedActionsActivity
import de.markusfisch.android.binaryeye.activity.IgnoreCodesActivity
import de.markusfisch.android.binaryeye.activity.NetworkSuggestionsActivity
import de.markusfisch.android.binaryeye.activity.ProfilesActivity
import de.markusfisch.android.binaryeye.activity.SplashActivity
import de.markusfisch.android.binaryeye.adapter.prettifyFormatName
import de.markusfisch.android.binaryeye.app.hasBluetoothPermission
import de.markusfisch.android.binaryeye.app.prefs
import de.markusfisch.android.binaryeye.bluetooth.setBluetoothHosts
import de.markusfisch.android.binaryeye.media.beepConfirm
import de.markusfisch.android.binaryeye.preference.Preferences
import de.markusfisch.android.binaryeye.preference.UrlPreference
import de.markusfisch.android.binaryeye.view.setPaddingFromWindowInsets
import de.markusfisch.android.binaryeye.view.systemBarRecyclerViewScrollListener
@@ -31,6 +40,13 @@ import de.markusfisch.android.binaryeye.widget.toast
class PreferencesFragment : PreferenceFragmentCompat() {
private var lastProfile: String? = null
private val pickBeepTone = registerForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
if (uri != null) {
setCustomBeepTone(uri)
}
}
private val changeListener = object : OnSharedPreferenceChangeListener {
override fun onSharedPreferenceChanged(
@@ -43,14 +59,16 @@ class PreferencesFragment : PreferenceFragmentCompat() {
prefs.update()
}
when (preference.key) {
"custom_locale" -> {
CUSTOM_LOCALE,
DYNAMIC_COLORS -> {
activity?.restartApp()
return
}
"beep_tone_name" -> beepConfirm()
BEEP_TONE_NAME,
BEEP_STREAM_NAME -> activity?.beepConfirm()
"send_scan_bluetooth" -> if (
SEND_SCAN_BLUETOOTH -> if (
prefs.sendScanBluetooth &&
activity?.hasBluetoothPermission() == false
) {
@@ -80,11 +98,17 @@ class PreferencesFragment : PreferenceFragmentCompat() {
preferenceScreen.sharedPreferences
?.registerOnSharedPreferenceChangeListener(changeListener)
setSummaries(preferenceScreen)
setIcons()
wireBeepTonePicker()
wireProfiles()
wireAutomatedActions()
wireIgnoreCodes()
setBluetoothResources()
wireClearNetworkPreferences()
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
findPreference<PreferenceGroup>("appearance_category")?.isVisible = false
}
}
private fun wireProfiles() {
@@ -97,6 +121,27 @@ class PreferencesFragment : PreferenceFragmentCompat() {
}
}
private fun wireBeepTonePicker() {
findPreference<ListPreference>(BEEP_TONE_NAME)?.apply {
onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
val toneName = newValue as? String ?: return@OnPreferenceChangeListener false
if (toneName == Preferences.CUSTOM_BEEP_TONE_NAME) {
pickBeepTone.launch(arrayOf("audio/*"))
false
} else {
if (prefs.usesCustomBeepTone()) {
clearCustomBeepTone()
}
prefs.beepToneName = toneName
value = toneName
setSummary(this)
activity?.beepConfirm()
false
}
}
}
}
private fun wireAutomatedActions() {
findPreference<Preference>(AUTOMATED_ACTIONS)?.apply {
updateAutomatedActionsSummary(this)
@@ -109,18 +154,30 @@ class PreferencesFragment : PreferenceFragmentCompat() {
}
}
private fun wireIgnoreCodes() {
findPreference<Preference>(IGNORE_CODES)?.apply {
updateIgnoreCodesSummary(this)
onPreferenceClickListener = Preference.OnPreferenceClickListener {
startActivity(
Intent(activity, IgnoreCodesActivity::class.java)
)
true
}
}
}
private fun setBluetoothResources() {
if (prefs.sendScanBluetooth &&
activity?.hasBluetoothPermission() == true
) {
findPreference<ListPreference>("send_scan_bluetooth_host")?.let {
findPreference<ListPreference>(SEND_SCAN_BLUETOOTH_HOST)?.let {
setBluetoothHosts(it)
}
}
}
private fun wireClearNetworkPreferences() {
findPreference<Preference>("clear_network_suggestions")?.apply {
findPreference<Preference>(CLEAR_NETWORK_SUGGESTIONS)?.apply {
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
// From R+ we can query past network suggestions and
// make them editable.
@@ -135,10 +192,10 @@ class PreferencesFragment : PreferenceFragmentCompat() {
// Note that previous versions of this app allowed
// adding network suggestions on Q as well, so we
// need to keep this option.
setOnPreferenceClickListener {
context?.askToClearNetworkSuggestions()
true
}
setOnPreferenceClickListener {
context.askToClearNetworkSuggestions()
true
}
} else {
// There are no network suggestions below Q.
isVisible = false
@@ -148,7 +205,7 @@ class PreferencesFragment : PreferenceFragmentCompat() {
@RequiresApi(Build.VERSION_CODES.Q)
private fun Context.askToClearNetworkSuggestions() {
AlertDialog.Builder(this)
MaterialAlertDialogBuilder(this)
.setMessage(R.string.really_remove_all_networks)
.setPositiveButton(android.R.string.ok) { _, _ ->
clearNetworkSuggestions()
@@ -179,6 +236,9 @@ class PreferencesFragment : PreferenceFragmentCompat() {
findPreference<Preference>(AUTOMATED_ACTIONS)?.let {
updateAutomatedActionsSummary(it)
}
findPreference<Preference>(IGNORE_CODES)?.let {
updateIgnoreCodesSummary(it)
}
findPreference<Preference>(PROFILE)?.let {
updateProfileSummary(it)
}
@@ -200,7 +260,7 @@ class PreferencesFragment : PreferenceFragmentCompat() {
setTargetFragment(this@PreferencesFragment, 0)
show(fm, null)
}
} else if (preference.key == "send_scan_bluetooth_host") {
} else if (preference.key == SEND_SCAN_BLUETOOTH_HOST) {
val ac = activity ?: return
if (ac.hasBluetoothPermission()) {
setBluetoothHosts(preference as ListPreference)
@@ -219,31 +279,103 @@ class PreferencesFragment : PreferenceFragmentCompat() {
}
private fun setSummary(preference: Preference) {
if (preference.key == AUTOMATED_ACTIONS) {
updateAutomatedActionsSummary(preference)
return
when (preference.key) {
AUTOMATED_ACTIONS -> return updateAutomatedActionsSummary(preference)
IGNORE_CODES -> return updateIgnoreCodesSummary(preference)
BEEP_TONE_NAME -> {
setBeepToneSummary(preference)
return
}
}
when (preference) {
is UrlPreference -> {
preference.summary = preference.getUrl()
}
is EditTextPreference -> preference.summary = preference.text
is UrlPreference -> preference.summary = preference.getUrl()
is ListPreference -> preference.setSummary(preference.entry)
is MultiSelectListPreference -> preference.summary =
getSortedSummary(preference)
is ListPreference -> {
preference.setSummary(preference.entry)
}
is MultiSelectListPreference -> {
preference.summary = preference.values.joinToString(", ") {
it.replace(Regex("_"), " ")
}
}
is PreferenceGroup -> {
setSummaries(preference)
}
is PreferenceGroup -> setSummaries(preference)
}
}
private fun setBeepToneSummary(preference: Preference) {
preference.summary = if (prefs.usesCustomBeepTone()) {
getBeepToneLabel(prefs.beepToneUri.toUri())
} else {
findPreference<ListPreference>(BEEP_TONE_NAME)?.let {
val index = it.findIndexOfValue(prefs.beepToneName)
if (index >= 0) {
it.entries[index]
} else {
""
}
} ?: ""
}
}
private fun getSortedSummary(preference: MultiSelectListPreference): String {
val labelsByValue = mutableMapOf<String, String>()
val entries = preference.entries
val entryValues = preference.entryValues
val count = minOf(entries.size, entryValues.size)
for (i in 0 until count) {
labelsByValue[entryValues[i].toString()] = entries[i].toString()
}
return preference.values
.map { value ->
labelsByValue[value] ?: value.prettifyFormatName()
}
.sortedBy { it.lowercase() }
.joinToString(", ")
}
private fun setIcons() {
setIcon(PROFILE, R.drawable.ic_action_profile)
setIcon("formats", R.drawable.ic_action_scan)
setIcon("show_crop_handle", R.drawable.ic_action_crop)
setIcon("show_crosshairs", R.drawable.ic_action_search)
setIcon("zoom_by_swiping", R.drawable.ic_action_search)
setIcon("auto_rotate", R.drawable.ic_action_down)
setIcon("try_harder", R.drawable.ic_action_search)
setIcon("bulk_mode", R.drawable.ic_action_bulk_mode)
setIcon("bulk_mode_delay", R.drawable.ic_action_delay)
setIcon("show_toast_in_bulk_mode", R.drawable.ic_action_toast)
setIcon("vibrate", R.drawable.ic_action_vibrate)
setIcon("beep", R.drawable.ic_action_beep)
setIcon(BEEP_TONE_NAME, R.drawable.ic_action_notification_sound)
setIcon(BEEP_STREAM_NAME, R.drawable.ic_action_notification_sound)
setIcon("use_history", R.drawable.ic_action_history)
setIcon("ignore_duplicates_name", R.drawable.ic_action_remove)
setIcon(IGNORE_CODES, R.drawable.ic_action_remove)
setIcon("copy_immediately", R.drawable.ic_action_copy)
setIcon("send_scan_active", R.drawable.ic_action_forward)
setIcon("send_scan_url", R.drawable.ic_action_link)
setIcon("send_scan_type", R.drawable.ic_action_link)
setIcon("send_scan_device_id", R.drawable.ic_label)
setIcon(SEND_SCAN_BLUETOOTH, R.drawable.ic_action_bluetooth)
setIcon(SEND_SCAN_BLUETOOTH_HOST, R.drawable.ic_action_bluetooth)
setIcon("open_immediately", R.drawable.ic_action_open)
setIcon("strip_tracking_params", R.drawable.ic_action_remove)
setIcon("show_meta_data", R.drawable.ic_action_info)
setIcon("show_hex_dump", R.drawable.ic_action_info)
setIcon("show_checksum", R.drawable.ic_action_info)
setIcon("show_recreation", R.drawable.ic_action_scan)
setIcon("close_automatically", R.drawable.ic_action_back)
setIcon("default_search_url", R.drawable.ic_action_search)
setIcon("open_with_url", R.drawable.ic_action_link)
setIcon(AUTOMATED_ACTIONS, R.drawable.ic_action_create)
setIcon(CLEAR_NETWORK_SUGGESTIONS, R.drawable.ic_action_wifi)
setIcon("brighten_screen", R.drawable.ic_action_bright)
setIcon(CUSTOM_LOCALE, R.drawable.ic_action_preferences)
setIcon(DYNAMIC_COLORS, R.drawable.ic_action_preferences)
}
private fun setIcon(key: String, iconResId: Int) {
findPreference<Preference>(key)?.setIcon(iconResId)
}
private fun updateAutomatedActionsSummary(preference: Preference) {
val count = prefs.automatedActions.size
preference.summary = if (count == 0) {
@@ -257,14 +389,102 @@ class PreferencesFragment : PreferenceFragmentCompat() {
}
}
private fun updateIgnoreCodesSummary(preference: Preference) {
val count = prefs.ignoreCodes.size
preference.summary = if (count == 0) {
getString(R.string.ignore_codes_none)
} else {
resources.getQuantityString(
R.plurals.ignore_codes_count,
count,
count
)
}
}
private fun updateProfileSummary(preference: Preference) {
preference.summary = prefs.profile ?: getString(R.string.profile_default)
preference.summary = prefs.profileLabel(requireContext(), prefs.profile)
lastProfile = prefs.profile
}
private fun setCustomBeepTone(uri: Uri) {
val context = requireContext()
val resolver = context.contentResolver
clearCustomBeepTone()
try {
resolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
} catch (e: SecurityException) {
val message = e.message
if (!message.isNullOrEmpty()) {
context.toast(message)
}
return
}
prefs.beepToneUri = uri.toString()
prefs.beepToneName = Preferences.CUSTOM_BEEP_TONE_NAME
findPreference<ListPreference>(BEEP_TONE_NAME)?.apply {
value = Preferences.CUSTOM_BEEP_TONE_NAME
setSummary(this)
}
activity?.beepConfirm()
}
private fun clearCustomBeepTone(releaseSelection: Boolean = true) {
if (releaseSelection) {
releaseBeepTonePermission(prefs.beepToneUri)
}
prefs.beepToneUri = ""
}
private fun releaseBeepTonePermission(uriString: String) {
if (uriString.isEmpty()) {
return
}
try {
requireContext().contentResolver.releasePersistableUriPermission(
uriString.toUri(),
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
} catch (_: SecurityException) {
}
}
private fun getBeepToneLabel(uri: Uri): String {
var cursor: Cursor? = null
return try {
cursor = requireContext().contentResolver.query(
uri,
arrayOf(OpenableColumns.DISPLAY_NAME),
null,
null,
null
)
if (cursor?.moveToFirst() == true) {
cursor.getString(0)
} else {
uri.lastPathSegment ?: uri.toString()
}
} catch (_: Exception) {
uri.lastPathSegment ?: uri.toString()
} finally {
cursor?.close()
}
}
companion object {
private const val PROFILE = "profile"
private const val AUTOMATED_ACTIONS = "automated_actions"
private const val BEEP_TONE_NAME = "beep_tone_name"
private const val BEEP_STREAM_NAME = "beep_stream_name"
private const val CLEAR_NETWORK_SUGGESTIONS = "clear_network_suggestions"
private const val CUSTOM_LOCALE = "custom_locale"
private const val DYNAMIC_COLORS = "dynamic_colors"
private const val IGNORE_CODES = "ignore_codes"
private const val SEND_SCAN_BLUETOOTH = "send_scan_bluetooth"
private const val SEND_SCAN_BLUETOOTH_HOST = "send_scan_bluetooth_host"
}
}
@@ -1,9 +1,9 @@
package de.markusfisch.android.binaryeye.fragment
import android.os.Bundle
import androidx.preference.PreferenceDialogFragmentCompat
import android.view.View
import android.widget.TextView
import androidx.preference.PreferenceDialogFragmentCompat
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.app.prefs
import de.markusfisch.android.binaryeye.database.Scan
@@ -48,11 +48,14 @@ class UrlDialogFragment : PreferenceDialogFragmentCompat() {
}
textView ?: return
textView.text = ""
Scan(
"test",
null,
BarcodeFormat.None
).sendAsync(url, prefs.sendScanType) { code, body ->
textView.context.sendAsync(
Scan(
"test",
null,
BarcodeFormat.None
),
url
) { code, body ->
textView.text = when {
code != null -> "$code"
body != null -> body
@@ -8,7 +8,7 @@ import de.markusfisch.android.binaryeye.R
fun Context.getDashedBorderPaint() = Paint(Paint.ANTI_ALIAS_FLAG).apply {
val dp = resources.displayMetrics.density
color = ContextCompat.getColor(applicationContext, R.color.crop_bound)
color = ContextCompat.getColor(this@getDashedBorderPaint, R.color.crop_bound)
style = Paint.Style.STROKE
strokeWidth = dp * 2f
pathEffect = DashPathEffect(floatArrayOf(10f * dp, 10f * dp), 0f)
@@ -1,6 +1,7 @@
package de.markusfisch.android.binaryeye.graphics
import android.content.res.Resources
import android.content.res.Resources.Theme
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
@@ -10,8 +11,13 @@ import androidx.core.graphics.createBitmap
fun Resources.getBitmapFromDrawable(
resId: Int
): Bitmap = getBitmapFromDrawable(resId, null)
fun Resources.getBitmapFromDrawable(
resId: Int,
theme: Theme?
): Bitmap = getBitmapFromDrawable(
ResourcesCompat.getDrawable(this, resId, null) ?: throw Resources.NotFoundException(
ResourcesCompat.getDrawable(this, resId, theme) ?: throw Resources.NotFoundException(
"Drawable resource ID #0x${resId.toString(16)}"
)
)
@@ -40,6 +40,15 @@ fun Rect.setFrameRoi(
frameRoiF.bottom.roundToInt()
)
}
val clampedLeft = left.coerceIn(0, frameMetrics.width)
val clampedTop = top.coerceIn(0, frameMetrics.height)
val clampedRight = right.coerceIn(0, frameMetrics.width)
val clampedBottom = bottom.coerceIn(0, frameMetrics.height)
if (clampedLeft >= clampedRight || clampedTop >= clampedBottom) {
setEmpty()
} else {
set(clampedLeft, clampedTop, clampedRight, clampedBottom)
}
}
fun Matrix.setFrameToView(
@@ -88,3 +97,57 @@ fun Matrix.mapPosition(
mapPoints(coords)
return i
}
fun Rect.setCovered(
viewWidth: Int,
viewHeight: Int,
frameMetrics: FrameMetrics
) {
val frameWidth: Int
val frameHeight: Int
when (frameMetrics.orientation) {
90, 270 -> {
frameWidth = frameMetrics.height
frameHeight = frameMetrics.width
}
else -> {
frameWidth = frameMetrics.width
frameHeight = frameMetrics.height
}
}
if (frameWidth < 1 || frameHeight < 1) {
set(0, 0, 0, 0)
return
}
var coveredWidth = frameWidth
var coveredHeight = frameHeight
if (viewWidth.toLong() * coveredWidth <
viewHeight.toLong() * coveredHeight
) {
coveredWidth = coveredWidth * viewHeight / coveredHeight
coveredHeight = viewHeight
} else {
coveredHeight = coveredHeight * viewWidth / coveredWidth
coveredWidth = viewWidth
}
val left = (viewWidth - coveredWidth) / 2
val top = (viewHeight - coveredHeight) / 2
set(
left,
top,
left + coveredWidth,
top + coveredHeight
)
}
fun Matrix.mapViewYToFrame(viewY: Float): Float {
val values = FloatArray(9)
getValues(values)
val scaleY = values[Matrix.MSCALE_Y]
return if (scaleY != 0f) {
(viewY - values[Matrix.MTRANS_Y]) / scaleY
} else {
viewY
}
}
@@ -6,8 +6,8 @@ import android.content.Context
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.annotation.MainThread
import android.widget.EditText
import androidx.annotation.MainThread
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.app.alertDialog
import java.io.File
@@ -1,21 +1,80 @@
package de.markusfisch.android.binaryeye.media
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.MediaPlayer
import android.media.ToneGenerator
import androidx.core.net.toUri
import de.markusfisch.android.binaryeye.app.prefs
private var confirmToneGenerator: ToneGenerator? = null
private var confirmToneStream = AudioManager.STREAM_MUSIC
private var confirmMediaPlayer: MediaPlayer? = null
private var errorToneGenerator: ToneGenerator? = null
fun beepConfirm() {
val tg = confirmToneGenerator ?: ToneGenerator(
AudioManager.STREAM_NOTIFICATION,
ToneGenerator.MAX_VOLUME
)
fun Context.beepConfirm() {
val stream = prefs.beepStream()
if (prefs.usesCustomBeepTone() &&
playCustomConfirmTone(prefs.beepToneUri, stream)
) {
return
}
val tg = if (confirmToneGenerator == null || confirmToneStream != stream) {
confirmToneGenerator?.release()
ToneGenerator(stream, ToneGenerator.MAX_VOLUME).also {
confirmToneGenerator = it
confirmToneStream = stream
}
} else {
confirmToneGenerator!!
}
confirmToneGenerator = tg
tg.startTone(prefs.beepTone())
}
private fun Context.playCustomConfirmTone(
uriString: String,
stream: Int
): Boolean {
return try {
confirmToneGenerator?.release()
confirmToneGenerator = null
confirmToneStream = stream
confirmMediaPlayer?.release()
confirmMediaPlayer = MediaPlayer().also { mp ->
mp.apply {
setAudioAttributes(
AudioAttributes.Builder()
.setLegacyStreamType(stream)
.build()
)
setDataSource(this@playCustomConfirmTone, uriString.toUri())
setOnCompletionListener { completed ->
if (confirmMediaPlayer === completed) {
confirmMediaPlayer = null
}
completed.release()
}
setOnErrorListener { failed, _, _ ->
if (confirmMediaPlayer === failed) {
confirmMediaPlayer = null
}
failed.release()
true
}
prepare()
start()
}
}
true
} catch (_: Exception) {
confirmMediaPlayer?.release()
confirmMediaPlayer = null
false
}
}
fun beepError() {
val tg = errorToneGenerator ?: ToneGenerator(
AudioManager.STREAM_ALARM,
@@ -28,6 +87,9 @@ fun beepError() {
fun releaseToneGenerators() {
confirmToneGenerator?.release()
confirmToneGenerator = null
confirmToneStream = AudioManager.STREAM_MUSIC
confirmMediaPlayer?.release()
confirmMediaPlayer = null
errorToneGenerator?.release()
errorToneGenerator = null
}
@@ -1,5 +1,8 @@
package de.markusfisch.android.binaryeye.net
import android.content.Context
import de.markusfisch.android.binaryeye.app.prefs
import de.markusfisch.android.binaryeye.content.openUrl
import de.markusfisch.android.binaryeye.database.Scan
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -14,61 +17,95 @@ import java.net.HttpURLConnection
import java.net.ProtocolException
import java.net.URL
fun Scan.sendAsync(
url: String,
type: String,
private const val SEND_TYPE_GET_ADD_CONTENT = "0"
private const val SEND_TYPE_GET_QUERY_STRING = "1"
private const val SEND_TYPE_POST_FORM = "2"
private const val SEND_TYPE_POST_JSON = "3"
private const val SEND_TYPE_EXTERNAL_BROWSER = "4"
fun Context.sendAsync(
scan: Scan,
url: String = prefs.sendScanUrl,
type: String = prefs.sendScanType,
callback: (Int?, String?) -> Unit
) {
val extras = mapOf(
"deviceId" to prefs.sendScanDeviceId.trim()
)
if (type == SEND_TYPE_EXTERNAL_BROWSER) {
openUrl(buildUrl(url, scan.asUrlArguments(extras)))
return
}
CoroutineScope(Dispatchers.IO).launch(Dispatchers.IO) {
val response = send(url, type)
val response = scan.send(url, type, extras) ?: return@launch
withContext(Dispatchers.Main) {
callback(response.code, response.body)
}
}
}
private fun Scan.send(url: String, type: String): Response {
return when (type) {
"1" -> request(
url + "?" + asUrlArguments()
private fun Scan.send(
url: String,
type: String,
extras: Map<String, String>
): Response? = when (type) {
SEND_TYPE_GET_ADD_CONTENT -> request(
buildUrl(url, text.urlEncode())
)
SEND_TYPE_GET_QUERY_STRING -> request(
buildUrl(url, asUrlArguments(extras))
)
SEND_TYPE_POST_FORM -> request(url) { con ->
con.requestMethod = "POST"
con.setRequestProperty(
"Content-Type",
"application/x-www-form-urlencoded"
)
"2" -> request(url) { con ->
con.requestMethod = "POST"
con.setRequestProperty(
"Content-Type",
"application/x-www-form-urlencoded"
)
con.outputStream.apply {
write(asUrlArguments().toByteArray())
close()
}
con.outputStream.apply {
write(asUrlArguments(extras).toByteArray())
close()
}
"3" -> request(url) { con ->
con.requestMethod = "POST"
con.setRequestProperty("Content-Type", "application/json")
con.outputStream.apply {
write(asJson().toString().toByteArray())
close()
}
}
else -> request(
url + text.urlEncode()
)
}
SEND_TYPE_POST_JSON -> request(url) { con ->
con.requestMethod = "POST"
con.setRequestProperty("Content-Type", "application/json")
con.outputStream.apply {
write(asJson(extras).toString().toByteArray())
close()
}
}
else -> null
}
private fun Scan.asUrlArguments(): String = getMap().map { (k, v) ->
private fun buildUrl(url: String, arguments: String): String {
if (arguments.isEmpty()) {
return url
}
val separator = when {
url.endsWith("?") || url.endsWith("&") -> ""
url.contains("?") -> "&"
else -> "?"
}
return url + separator + arguments
}
private fun Scan.asUrlArguments(
extras: Map<String, String>
): String = getMap(extras).map { (k, v) ->
"${k}=${v.urlEncode()}"
}.joinToString("&")
private fun Scan.asJson() = JSONObject().apply {
getMap().forEach { (k, v) -> put(k, v) }
private fun Scan.asJson(
extras: Map<String, String>
) = JSONObject().apply {
getMap(extras).forEach { (k, v) -> put(k, v) }
}
private fun Scan.getMap(): Map<String, String> = mapOf(
private fun Scan.getMap(extras: Map<String, String>): Map<String, String> = (mapOf(
"content" to text,
"raw" to raw?.toHexString(),
"format" to format.name,
@@ -81,8 +118,8 @@ private fun Scan.getMap(): Map<String, String> = mapOf(
"addOn" to addOn,
"price" to price,
"issueNumber" to issueNumber,
"timestamp" to dateTime
).filterNullValues()
"timestamp" to dateTime,
) + extras).filterNullValues()
private fun Map<String, String?>.filterNullValues() =
filterValues { it != null }.mapValues { it.value as String }
@@ -0,0 +1,14 @@
package de.markusfisch.android.binaryeye.os
import android.app.Activity
import android.os.Build
import android.view.WindowManager
@Suppress("DEPRECATION")
fun Activity.showWhenLocked() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
} else {
window.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED)
}
}
@@ -0,0 +1,75 @@
package de.markusfisch.android.binaryeye.preference
import org.json.JSONArray
import org.json.JSONObject
data class IgnoreCode(
val pattern: String,
val format: String? = null
) {
private val compiledRegex: Regex? by lazy {
try {
Regex(pattern)
} catch (_: Exception) {
null
}
}
fun matches(content: String, format: String): Boolean =
(this.format == null || this.format == format) &&
compiledRegex?.containsMatchIn(content) == true
fun toJson(): JSONObject = JSONObject().apply {
put(KEY_PATTERN, pattern)
put(KEY_FORMAT, format ?: "")
}
companion object {
private const val KEY_PATTERN = "pattern"
private const val KEY_FORMAT = "format"
fun fromJson(obj: JSONObject): IgnoreCode? {
val pattern = obj.optString(KEY_PATTERN, "").trim()
if (pattern.isEmpty()) {
return null
}
val format = obj.optString(KEY_FORMAT, "").trim()
return IgnoreCode(
pattern = pattern,
format = format.ifEmpty { null }
)
}
fun fromJsonArray(json: String): MutableList<IgnoreCode> {
val items = ArrayList<IgnoreCode>()
val array = try {
JSONArray(json)
} catch (_: Exception) {
JSONArray()
}
for (i in 0 until array.length()) {
when (val item = array.opt(i)) {
is String -> {
val pattern = item.trim()
if (pattern.isNotEmpty()) {
items.add(IgnoreCode(pattern))
}
}
is JSONObject -> {
fromJson(item)?.let { items.add(it) }
}
}
}
return items
}
fun toJsonArray(items: List<IgnoreCode>): String {
val array = JSONArray()
items.forEach { item ->
array.put(item.toJson())
}
return array.toString()
}
}
}
@@ -7,12 +7,19 @@ import android.graphics.Point
import android.media.ToneGenerator
import android.preference.PreferenceManager
import androidx.core.content.edit
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.automation.AutomatedAction
import de.markusfisch.android.binaryeye.automation.AutomatedAction.Companion.fromJsonArray
import de.markusfisch.android.binaryeye.automation.AutomatedAction.Companion.toJsonArray
import de.markusfisch.android.binaryeye.zxingcpp.migrateBarcodeFormatName
import de.markusfisch.android.zxingcpp.ZxingCpp.BarcodeFormat
import org.json.JSONArray
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import de.markusfisch.android.binaryeye.preference.IgnoreCode.Companion.fromJsonArray as ignoreCodesFromJsonArray
import de.markusfisch.android.binaryeye.preference.IgnoreCode.Companion.toJsonArray as ignoreCodesToJsonArray
class Preferences {
lateinit var defaultPreferences: SharedPreferences
@@ -30,7 +37,7 @@ class Preferences {
var barcodeFormats = setOf(
BarcodeFormat.Aztec.name,
BarcodeFormat.Codabar.name,
BarcodeFormat.Code39.name,
BarcodeFormat.Code39Std.name,
BarcodeFormat.Code39Ext.name,
BarcodeFormat.Code32.name,
BarcodeFormat.PZN.name,
@@ -49,6 +56,9 @@ class Preferences {
BarcodeFormat.ITF.name,
BarcodeFormat.MaxiCode.name,
BarcodeFormat.PDF417.name,
BarcodeFormat.Telepen.name,
BarcodeFormat.TelepenAlpha.name,
BarcodeFormat.TelepenNumeric.name,
BarcodeFormat.QRCode.name,
BarcodeFormat.MicroQRCode.name,
BarcodeFormat.RMQRCode.name,
@@ -64,6 +74,11 @@ class Preferences {
apply(SHOW_CROP_HANDLE, value)
field = value
}
var showCrosshairs = false
set(value) {
apply(SHOW_CROSSHAIRS, value)
field = value
}
var zoomBySwiping = true
set(value) {
apply(ZOOM_BY_SWIPING, value)
@@ -109,6 +124,16 @@ class Preferences {
apply(BEEP_TONE_NAME, value)
field = value
}
var beepStreamName = "stream_music"
set(value) {
apply(BEEP_STREAM_NAME, value)
field = value
}
var beepToneUri = ""
set(value) {
apply(BEEP_TONE_URI, value)
field = value
}
var useHistory = false
set(value) {
apply(USE_HISTORY, value)
@@ -119,6 +144,8 @@ class Preferences {
apply(IGNORE_DUPLICATES_NAME, value)
field = value
}
var ignoreCodes = mutableListOf(IgnoreCode(DEFAULT_IGNORE_CODE_PATTERN))
private set
var copyImmediately = false
set(value) {
apply(COPY_IMMEDIATELY, value)
@@ -129,6 +156,11 @@ class Preferences {
apply(OPEN_IMMEDIATELY, value)
field = value
}
var stripTrackingParams = true
set(value) {
apply(STRIP_TRACKING_PARAMS, value)
field = value
}
var showMetaData = true
set(value) {
apply(SHOW_META_DATA, value)
@@ -179,6 +211,11 @@ class Preferences {
apply(SEND_SCAN_TYPE, value)
field = value
}
var sendScanDeviceId: String = ""
set(value) {
apply(SEND_SCAN_DEVICE_ID, value)
field = value
}
var sendScanBluetooth = false
set(value) {
apply(SEND_SCAN_BLUETOOTH, value)
@@ -231,6 +268,11 @@ class Preferences {
apply(PREVIEW_SCALE, value)
field = value
}
var dynamicColors = true
set(value) {
apply(DYNAMIC_COLORS, value)
field = value
}
var automatedActions = mutableListOf<AutomatedAction>()
private set
@@ -241,6 +283,7 @@ class Preferences {
defaultPreferences = PreferenceManager.getDefaultSharedPreferences(context)
loadProfiles()
addPredefinedProfiles(context)
load(context, defaultPreferences.getString(PROFILE, null))
}
@@ -279,27 +322,209 @@ class Preferences {
fun saveProfiles() {
defaultPreferences.edit {
putString(PROFILES, JSONArray(profiles).toString())
putString(PROFILES, org.json.JSONArray(profiles).toString())
}
}
fun loadProfiles() {
profiles.clear()
JSONArray(
org.json.JSONArray(
defaultPreferences.getString(PROFILES, "[]")
).let {
profiles.addAll(Array(it.length()) { i -> it.getString(i) })
}
}
fun profileLabel(context: Context, name: String?) = when (name) {
null -> context.getString(R.string.profile_default)
PROFILE_EXPERT -> context.getString(R.string.mode_expert)
PROFILE_SIMPLE -> context.getString(R.string.mode_simple)
else -> name
}
fun loadExpertProfile(context: Context) {
load(context, PROFILE_EXPERT)
}
fun loadSimpleProfile(context: Context) {
load(context, PROFILE_SIMPLE)
}
fun toJson(context: Context, name: String?): String {
val defaults = Preferences()
val p = if (name.isNullOrEmpty()) {
defaultPreferences
} else {
context.getSharedPreferences(name, Context.MODE_PRIVATE)
}
val json = JSONObject()
json.put("type", PROFILE_EXPORT_TYPE)
json.put("name", name ?: "")
json.put(BARCODE_FORMATS, JSONArray().apply {
(p.getStringSet(BARCODE_FORMATS, null) ?: defaults.barcodeFormats).forEach {
put(it)
}
})
json.put(SHOW_CROP_HANDLE, p, defaults.showCropHandle)
json.put(SHOW_CROSSHAIRS, p, defaults.showCrosshairs)
json.put(ZOOM_BY_SWIPING, p, defaults.zoomBySwiping)
json.put(AUTO_ROTATE, p, defaults.autoRotate)
json.put(TRY_HARDER, p, defaults.tryHarder)
json.put(BULK_MODE, p, defaults.bulkMode)
json.put(BULK_MODE_DELAY, p, defaults.bulkModeDelay)
json.put(SHOW_TOAST_IN_BULK_MODE, p, defaults.showToastInBulkMode)
json.put(VIBRATE, p, defaults.vibrate)
json.put(BEEP, p, defaults.beep)
json.put(BEEP_TONE_NAME, p, defaults.beepToneName)
json.put(BEEP_STREAM_NAME, p, defaults.beepStreamName)
json.put(BEEP_TONE_URI, p, defaults.beepToneUri)
json.put(USE_HISTORY, p, defaults.useHistory)
json.put(IGNORE_DUPLICATES_NAME, p, defaults.ignoreDuplicatesName)
json.put(IGNORE_CODES, p, ignoreCodesToJsonArray(defaults.ignoreCodes))
json.put(COPY_IMMEDIATELY, p, defaults.copyImmediately)
json.put(OPEN_IMMEDIATELY, p, defaults.openImmediately)
json.put(STRIP_TRACKING_PARAMS, p, defaults.stripTrackingParams)
json.put(SHOW_META_DATA, p, defaults.showMetaData)
json.put(SHOW_HEX_DUMP, p, defaults.showHexDump)
json.put(SHOW_CHECKSUM, p, defaults.showChecksum)
json.put(SHOW_RECREATION, p, defaults.showRecreation)
json.put(CLOSE_AUTOMATICALLY, p, defaults.closeAutomatically)
json.put(DEFAULT_SEARCH_URL, p, defaults.defaultSearchUrl)
json.put(OPEN_WITH_URL, p, defaults.openWithUrl)
json.put(SEND_SCAN_ACTIVE, p, defaults.sendScanActive)
json.put(SEND_SCAN_URL, p, defaults.sendScanUrl)
json.put(SEND_SCAN_TYPE, p, defaults.sendScanType)
json.put(SEND_SCAN_DEVICE_ID, p, defaults.sendScanDeviceId)
json.put(SEND_SCAN_BLUETOOTH, p, defaults.sendScanBluetooth)
json.put(SEND_SCAN_BLUETOOTH_HOST, p, defaults.sendScanBluetoothHost)
json.put(CUSTOM_LOCALE, p, defaults.customLocale)
json.put(INDEX_OF_LAST_SELECTED_FORMAT, p, defaults.indexOfLastSelectedFormat)
json.put(INDEX_OF_LAST_SELECTED_EC_LEVEL, p, defaults.indexOfLastSelectedEcLevel)
json.put(FREE_ROTATION, p, defaults.freeRotation)
json.put(EXPAND_ESCAPE_SEQUENCES, p, defaults.expandEscapeSequences)
json.put(ADD_QUIET_ZONE, p, defaults.addQuietZone)
json.put(BRIGHTEN_SCREEN, p, defaults.brightenScreen)
json.put(PREVIEW_SCALE, p, defaults.previewScale)
json.put(AUTOMATED_ACTIONS, p.getString(AUTOMATED_ACTIONS, null) ?: "[]")
return json.toString()
}
fun importProfile(context: Context, text: String): String? {
val json = try {
JSONObject(text)
} catch (_: Exception) {
return null
}
if (json.optString("type") != PROFILE_EXPORT_TYPE) {
return null
}
var sanitized = json.optString("name").ifEmpty {
"imported"
}.replace(Regex("[^a-zA-Z0-9_-]+"), "_")
.trim('_')
if (sanitized.isEmpty()) {
sanitized = "imported"
}
val name = if (profiles.contains(sanitized)) {
val timestamp = SimpleDateFormat(
"yyyyMMdd_HHmmss",
Locale.US
).format(Date())
"${sanitized}_$timestamp"
} else {
sanitized
}
if (!addProfile(name)) {
return null
}
val target = context.getSharedPreferences(
name,
Context.MODE_PRIVATE
)
try {
target.edit {
val formatsArray = json.optJSONArray(BARCODE_FORMATS)
if (formatsArray != null) {
val formats = mutableSetOf<String>()
for (i in 0 until formatsArray.length()) {
val format = formatsArray.getString(i)
.migrateBarcodeFormatName()
try {
formats.add(BarcodeFormat.valueOf(format).name)
} catch (_: IllegalArgumentException) {
// Ignore unknown formats.
}
}
if (formats.isNotEmpty()) {
putStringSet(BARCODE_FORMATS, formats)
}
}
putBoolean(SHOW_CROP_HANDLE, json)
putBoolean(SHOW_CROSSHAIRS, json)
putBoolean(ZOOM_BY_SWIPING, json)
putBoolean(AUTO_ROTATE, json)
putBoolean(TRY_HARDER, json)
putBoolean(BULK_MODE, json)
putString(BULK_MODE_DELAY, json)
putBoolean(SHOW_TOAST_IN_BULK_MODE, json)
putBoolean(VIBRATE, json)
putBoolean(BEEP, json)
putString(BEEP_TONE_NAME, json)
putString(BEEP_STREAM_NAME, json)
putString(BEEP_TONE_URI, json)
putBoolean(USE_HISTORY, json)
putString(IGNORE_DUPLICATES_NAME, json)
putString(IGNORE_CODES, json)
putBoolean(COPY_IMMEDIATELY, json)
putBoolean(OPEN_IMMEDIATELY, json)
putBoolean(STRIP_TRACKING_PARAMS, json)
putBoolean(SHOW_META_DATA, json)
putBoolean(SHOW_HEX_DUMP, json)
putString(SHOW_CHECKSUM, json)
putBoolean(SHOW_RECREATION, json)
putBoolean(CLOSE_AUTOMATICALLY, json)
putString(DEFAULT_SEARCH_URL, json)
putString(OPEN_WITH_URL, json)
putBoolean(SEND_SCAN_ACTIVE, json)
putString(SEND_SCAN_URL, json)
putString(SEND_SCAN_TYPE, json)
putString(SEND_SCAN_DEVICE_ID, json)
putBoolean(SEND_SCAN_BLUETOOTH, json)
putString(SEND_SCAN_BLUETOOTH_HOST, json)
putString(CUSTOM_LOCALE, json)
putInt(INDEX_OF_LAST_SELECTED_FORMAT, json)
putInt(INDEX_OF_LAST_SELECTED_EC_LEVEL, json)
putBoolean(FREE_ROTATION, json)
putBoolean(EXPAND_ESCAPE_SEQUENCES, json)
putBoolean(ADD_QUIET_ZONE, json)
putBoolean(BRIGHTEN_SCREEN, json)
putFloat(PREVIEW_SCALE, json)
putString(AUTOMATED_ACTIONS, json)
}
} catch (_: Exception) {
removeProfile(name)
target.edit {
clear()
}
return null
}
return name
}
fun update() {
preferences.getStringSet(BARCODE_FORMATS, barcodeFormats)?.let {
val migrated = migrateBarcodeFormats(it)
if (migrated != it) {
apply(BARCODE_FORMATS, migrated)
}
barcodeFormats = addFormatsOnUpdate(
migrated,
migrateCode39ToCode39Std(
migrateBarcodeFormatNames(it)
),
BarcodeFormat.Code39Std,
BarcodeFormat.Code39Ext,
BarcodeFormat.Code32,
BarcodeFormat.PZN,
@@ -309,7 +534,10 @@ class Preferences {
BarcodeFormat.DataBarExp,
BarcodeFormat.DataBarExpStk,
BarcodeFormat.RMQRCode,
BarcodeFormat.DXFilmEdge
BarcodeFormat.DXFilmEdge,
BarcodeFormat.Telepen,
BarcodeFormat.TelepenAlpha,
BarcodeFormat.TelepenNumeric
)
}
@@ -317,6 +545,10 @@ class Preferences {
SHOW_CROP_HANDLE,
showCropHandle
)
showCrosshairs = preferences.getBoolean(
SHOW_CROSSHAIRS,
showCrosshairs
)
zoomBySwiping = preferences.getBoolean(ZOOM_BY_SWIPING, zoomBySwiping)
autoRotate = preferences.getBoolean(AUTO_ROTATE, autoRotate)
tryHarder = preferences.getBoolean(TRY_HARDER, tryHarder)
@@ -334,6 +566,12 @@ class Preferences {
preferences.getString(BEEP_TONE_NAME, beepToneName)?.also {
beepToneName = it
}
preferences.getString(BEEP_STREAM_NAME, beepStreamName)?.also {
beepStreamName = it
}
preferences.getString(BEEP_TONE_URI, beepToneUri)?.also {
beepToneUri = it
}
useHistory = preferences.getBoolean(USE_HISTORY, useHistory)
// Map old setting to new one if it wasn't on default value.
@@ -351,6 +589,14 @@ class Preferences {
)?.also {
ignoreDuplicatesName = it
}
ignoreCodes = ignoreCodesFromJsonArray(
preferences.getString(
IGNORE_CODES,
ignoreCodesToJsonArray(
listOf(IgnoreCode(DEFAULT_IGNORE_CODE_PATTERN))
)
) ?: "[]"
)
copyImmediately = preferences.getBoolean(
COPY_IMMEDIATELY,
copyImmediately
@@ -359,6 +605,10 @@ class Preferences {
OPEN_IMMEDIATELY,
openImmediately
)
stripTrackingParams = preferences.getBoolean(
STRIP_TRACKING_PARAMS,
stripTrackingParams
)
showMetaData = preferences.getBoolean(SHOW_META_DATA, showMetaData)
showHexDump = preferences.getBoolean(SHOW_HEX_DUMP, showHexDump)
preferences.getString(SHOW_CHECKSUM, showChecksum)?.also {
@@ -385,6 +635,9 @@ class Preferences {
preferences.getString(SEND_SCAN_TYPE, sendScanType)?.also {
sendScanType = it
}
preferences.getString(SEND_SCAN_DEVICE_ID, sendScanDeviceId)?.also {
sendScanDeviceId = it
}
sendScanBluetooth = preferences.getBoolean(
SEND_SCAN_BLUETOOTH,
sendScanBluetooth
@@ -420,8 +673,9 @@ class Preferences {
PREVIEW_SCALE,
previewScale
)
dynamicColors = preferences.getBoolean(DYNAMIC_COLORS, dynamicColors)
automatedActions = fromJsonArray(
preferences.getString(AUTOMATED_ACTIONS, "[]") ?: "[]"
preferences.getString(AUTOMATED_ACTIONS, null) ?: "[]"
)
}
@@ -430,26 +684,13 @@ class Preferences {
apply(AUTOMATED_ACTIONS, toJsonArray(actions))
}
private fun addFormatsOnUpdate(
restored: Set<String>,
vararg formats: BarcodeFormat
) = restored.toMutableSet().apply {
for (format in formats) {
val name = "${format.name}_added"
if (!preferences.getBoolean(name, false)) {
preferences.edit {
putBoolean(name, true)
}
add(format.name)
}
}
fun setIgnoreCodes(patterns: List<IgnoreCode>) {
ignoreCodes = patterns.toMutableList()
apply(IGNORE_CODES, ignoreCodesToJsonArray(patterns))
}
private fun migrateBarcodeFormats(formats: Set<String>): Set<String> {
return formats.mapTo(mutableSetOf()) { format ->
format.migrateBarcodeFormatName()
}
}
fun shouldIgnoreHistoryContent(content: String, format: String): Boolean =
ignoreCodes.any { it.matches(content, format) }
fun restoreCropHandle(
name: String,
@@ -473,12 +714,140 @@ class Preferences {
else -> ToneGenerator.TONE_PROP_BEEP
}
fun beepStream() = when (beepStreamName) {
"stream_notification" -> android.media.AudioManager.STREAM_NOTIFICATION
else -> android.media.AudioManager.STREAM_MUSIC
}
fun usesCustomBeepTone() = beepToneName == CUSTOM_BEEP_TONE_NAME &&
beepToneUri.isNotEmpty()
fun ignoreDuplicates() = when (ignoreDuplicatesName) {
"ignore_consecutive_duplicates" -> IgnoreDuplicates.Consecutive
"ignore_any_duplicates" -> IgnoreDuplicates.Any
else -> IgnoreDuplicates.Never
}
private fun migrateCode39ToCode39Std(
restored: Set<String>
) = restored.toMutableSet().apply {
// Migrate Code39 to the more specific Code39Std.
if (contains(BarcodeFormat.Code39.name)) {
remove(BarcodeFormat.Code39.name)
if (!contains(BarcodeFormat.Code39Std.name)) {
add(BarcodeFormat.Code39Std.name)
}
}
}
private fun migrateBarcodeFormatNames(formats: Set<String>): Set<String> {
return formats.mapTo(mutableSetOf()) { format ->
format.migrateBarcodeFormatName()
}
}
private fun addFormatsOnUpdate(
restored: Set<String>,
vararg formats: BarcodeFormat
) = restored.toMutableSet().apply {
for (format in formats) {
val name = "${format.name}_added"
if (!preferences.getBoolean(name, false)) {
preferences.edit {
putBoolean(name, true)
}
add(format.name)
}
}
}
private fun addPredefinedProfiles(context: Context) {
val name = "predefined_profiles_added"
if (defaultPreferences.getBoolean(name, false)) {
return
}
defaultPreferences.edit {
putBoolean(name, true)
}
profiles.add(PROFILE_EXPERT)
context.getSharedPreferences(PROFILE_EXPERT, Context.MODE_PRIVATE)
.edit {
clear()
applyExpertProfileSettings()
}
profiles.add(PROFILE_SIMPLE)
context.getSharedPreferences(PROFILE_SIMPLE, Context.MODE_PRIVATE)
.edit {
clear()
applySimpleProfileSettings()
}
saveProfiles()
}
private fun SharedPreferences.Editor.applyExpertProfileSettings() {
val defaults = Preferences()
putStringSet(BARCODE_FORMATS, defaults.barcodeFormats)
putBoolean(SHOW_CROP_HANDLE, defaults.showCropHandle)
putBoolean(SHOW_CROSSHAIRS, defaults.showCrosshairs)
putBoolean(ZOOM_BY_SWIPING, defaults.zoomBySwiping)
putBoolean(AUTO_ROTATE, defaults.autoRotate)
putBoolean(TRY_HARDER, defaults.tryHarder)
putBoolean(BULK_MODE, defaults.bulkMode)
putString(BULK_MODE_DELAY, defaults.bulkModeDelay)
putBoolean(SHOW_TOAST_IN_BULK_MODE, defaults.showToastInBulkMode)
putBoolean(VIBRATE, defaults.vibrate)
putBoolean(BEEP, defaults.beep)
putString(BEEP_TONE_NAME, defaults.beepToneName)
putString(BEEP_STREAM_NAME, defaults.beepStreamName)
putString(BEEP_TONE_URI, defaults.beepToneUri)
putBoolean(USE_HISTORY, defaults.useHistory)
putString(IGNORE_DUPLICATES_NAME, defaults.ignoreDuplicatesName)
putString(IGNORE_CODES, ignoreCodesToJsonArray(defaults.ignoreCodes))
putBoolean(COPY_IMMEDIATELY, defaults.copyImmediately)
putBoolean(OPEN_IMMEDIATELY, defaults.openImmediately)
putBoolean(STRIP_TRACKING_PARAMS, defaults.stripTrackingParams)
putBoolean(SHOW_META_DATA, defaults.showMetaData)
putBoolean(SHOW_HEX_DUMP, defaults.showHexDump)
putString(SHOW_CHECKSUM, defaults.showChecksum)
putBoolean(SHOW_RECREATION, defaults.showRecreation)
putBoolean(CLOSE_AUTOMATICALLY, defaults.closeAutomatically)
putString(DEFAULT_SEARCH_URL, defaults.defaultSearchUrl)
putString(OPEN_WITH_URL, defaults.openWithUrl)
putBoolean(SEND_SCAN_ACTIVE, defaults.sendScanActive)
putString(SEND_SCAN_URL, defaults.sendScanUrl)
putString(SEND_SCAN_TYPE, defaults.sendScanType)
putString(SEND_SCAN_DEVICE_ID, defaults.sendScanDeviceId)
putBoolean(SEND_SCAN_BLUETOOTH, defaults.sendScanBluetooth)
putString(SEND_SCAN_BLUETOOTH_HOST, defaults.sendScanBluetoothHost)
putString(CUSTOM_LOCALE, defaults.customLocale)
putInt(INDEX_OF_LAST_SELECTED_FORMAT, defaults.indexOfLastSelectedFormat)
putInt(INDEX_OF_LAST_SELECTED_EC_LEVEL, defaults.indexOfLastSelectedEcLevel)
putBoolean(FREE_ROTATION, defaults.freeRotation)
putBoolean(EXPAND_ESCAPE_SEQUENCES, defaults.expandEscapeSequences)
putBoolean(ADD_QUIET_ZONE, defaults.addQuietZone)
putBoolean(BRIGHTEN_SCREEN, defaults.brightenScreen)
putFloat(PREVIEW_SCALE, defaults.previewScale)
putString(AUTOMATED_ACTIONS, toJsonArray(defaults.automatedActions))
}
private fun SharedPreferences.Editor.applySimpleProfileSettings() {
applyExpertProfileSettings()
putBoolean(SHOW_CROP_HANDLE, false)
putBoolean(ZOOM_BY_SWIPING, false)
putBoolean(BEEP, true)
putBoolean(COPY_IMMEDIATELY, true)
putBoolean(OPEN_IMMEDIATELY, true)
putBoolean(SHOW_META_DATA, false)
putBoolean(SHOW_HEX_DUMP, false)
putString(DEFAULT_SEARCH_URL, SIMPLE_SEARCH_URL)
putString(OPEN_WITH_URL, SIMPLE_SEARCH_URL)
putBoolean(EXPAND_ESCAPE_SEQUENCES, false)
putBoolean(BRIGHTEN_SCREEN, true)
}
private fun apply(label: String, value: Boolean) {
preferences.edit {
putBoolean(label, value)
@@ -521,10 +890,15 @@ class Preferences {
Consecutive, Any, Never
}
private const val PROFILE_EXPORT_TYPE = "binaryeye_profile"
private const val PROFILES = "profiles"
private const val PROFILE = "profile"
private const val PROFILE_EXPERT = "builtin:expert"
private const val PROFILE_SIMPLE = "builtin:simple"
private const val SIMPLE_SEARCH_URL = "https://www.google.com/search?q="
private const val BARCODE_FORMATS = "formats"
private const val SHOW_CROP_HANDLE = "show_crop_handle"
private const val SHOW_CROSSHAIRS = "show_crosshairs"
private const val ZOOM_BY_SWIPING = "zoom_by_swiping"
private const val AUTO_ROTATE = "auto_rotate"
private const val TRY_HARDER = "try_harder"
@@ -534,9 +908,13 @@ class Preferences {
private const val VIBRATE = "vibrate"
private const val BEEP = "beep"
private const val BEEP_TONE_NAME = "beep_tone_name"
private const val BEEP_STREAM_NAME = "beep_stream_name"
private const val BEEP_TONE_URI = "beep_tone_uri"
private const val USE_HISTORY = "use_history"
private const val IGNORE_DUPLICATES_NAME = "ignore_duplicates_name"
private const val IGNORE_CODES = "ignore_codes"
private const val OPEN_IMMEDIATELY = "open_immediately"
private const val STRIP_TRACKING_PARAMS = "strip_tracking_params"
private const val COPY_IMMEDIATELY = "copy_immediately"
private const val SHOW_META_DATA = "show_meta_data"
private const val SHOW_HEX_DUMP = "show_hex_dump"
@@ -548,6 +926,7 @@ class Preferences {
private const val SEND_SCAN_ACTIVE = "send_scan_active"
private const val SEND_SCAN_URL = "send_scan_url"
private const val SEND_SCAN_TYPE = "send_scan_type"
private const val SEND_SCAN_DEVICE_ID = "send_scan_device_id"
private const val SEND_SCAN_BLUETOOTH = "send_scan_bluetooth"
private const val SEND_SCAN_BLUETOOTH_HOST = "send_scan_bluetooth_host"
private const val CUSTOM_LOCALE = "custom_locale"
@@ -559,5 +938,48 @@ class Preferences {
private const val BRIGHTEN_SCREEN = "brighten_screen"
private const val PREVIEW_SCALE = "preview_scale"
private const val AUTOMATED_ACTIONS = "automated_actions"
private const val DYNAMIC_COLORS = "dynamic_colors"
private const val DEFAULT_IGNORE_CODE_PATTERN = "(?i)^fido:/.*"
const val CUSTOM_BEEP_TONE_NAME = "tone_custom_file"
}
}
private fun JSONObject.put(key: String, p: SharedPreferences, default: Boolean) {
put(key, p.getBoolean(key, default))
}
private fun JSONObject.put(key: String, p: SharedPreferences, default: String) {
put(key, p.getString(key, null) ?: default)
}
private fun JSONObject.put(key: String, p: SharedPreferences, default: Int) {
put(key, p.getInt(key, default))
}
private fun JSONObject.put(key: String, p: SharedPreferences, default: Float) {
put(key, p.getFloat(key, default))
}
private fun SharedPreferences.Editor.putBoolean(key: String, json: JSONObject) {
if (json.has(key)) {
putBoolean(key, json.getBoolean(key))
}
}
private fun SharedPreferences.Editor.putString(key: String, json: JSONObject) {
if (json.has(key)) {
putString(key, json.getString(key))
}
}
private fun SharedPreferences.Editor.putInt(key: String, json: JSONObject) {
if (json.has(key)) {
putInt(key, json.getInt(key))
}
}
private fun SharedPreferences.Editor.putFloat(key: String, json: JSONObject) {
if (json.has(key)) {
putFloat(key, json.getDouble(key).toFloat())
}
}
@@ -29,10 +29,14 @@ fun Context.errorFeedback() {
private fun Context.isSilent(): Boolean {
val am = getSystemService(Context.AUDIO_SERVICE) as AudioManager
return when (am.ringerMode) {
AudioManager.RINGER_MODE_SILENT,
AudioManager.RINGER_MODE_VIBRATE -> true
return if (prefs.beepStream() == AudioManager.STREAM_MUSIC) {
am.getStreamVolume(AudioManager.STREAM_MUSIC) == 0
} else {
when (am.ringerMode) {
AudioManager.RINGER_MODE_SILENT,
AudioManager.RINGER_MODE_VIBRATE -> true
else -> false
else -> false
}
}
}
@@ -2,16 +2,18 @@ package de.markusfisch.android.binaryeye.view
import android.content.Context
import android.content.ContextWrapper
import android.graphics.Color
import android.os.Build
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toDrawable
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import android.view.View
import android.view.WindowManager
import android.widget.AbsListView
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.core.graphics.ColorUtils
import androidx.core.graphics.drawable.toDrawable
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.color.MaterialColors
import de.markusfisch.android.binaryeye.R
val systemBarListViewScrollListener = object : AbsListView.OnScrollListener {
@@ -71,15 +73,15 @@ fun AppCompatActivity.initBars() {
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
colorSystemAndToolBars(this)
setPaddingFromWindowInsets(
findViewById(R.id.main),
(findViewById(R.id.toolbar) as Toolbar).apply {
setSupportActionBar(this)
},
findViewById<View>(R.id.navbar)?.apply {
setBackgroundColor(translucentPrimaryColor)
}
)
setPaddingFromWindowInsets(
findViewById(R.id.main),
(findViewById(R.id.toolbar) as Toolbar).apply {
setSupportActionBar(this)
},
findViewById<View>(R.id.navbar)?.apply {
setBackgroundColor(getTranslucentPrimaryColor(this@initBars))
}
)
}
private var statusBarColorLocked = false
@@ -91,18 +93,23 @@ fun unlockStatusBarColor() {
statusBarColorLocked = false
}
private var translucentPrimaryColor = 0
private fun getTranslucentPrimaryColor(context: Context): Int {
return ColorUtils.setAlphaComponent(
MaterialColors.getColor(
context,
com.google.android.material.R.attr.colorSurfaceContainerHigh,
Color.BLACK
),
0xd8
)
}
fun colorSystemAndToolBars(
context: Context,
scrolled: Boolean = false,
scrollable: Boolean = false
) {
if (translucentPrimaryColor == 0) {
translucentPrimaryColor = ContextCompat.getColor(
context,
R.color.primary_translucent
)
}
val translucentPrimaryColor = getTranslucentPrimaryColor(context)
val topColor = if (scrolled) translucentPrimaryColor else 0
val activity = getAppCompatActivity(context) ?: return
// System bars no longer have a background from SDK35+.
@@ -119,12 +126,13 @@ fun colorSystemAndToolBars(
0
}
} else {
activity.findViewById<View>(R.id.navbar)?.apply {
visibility = if (scrolled || scrollable) {
View.VISIBLE
} else {
activity.findViewById<View>(R.id.navbar)?.apply {
visibility = if (scrolled || scrollable) {
View.VISIBLE
} else {
View.GONE
}
setBackgroundColor(translucentPrimaryColor)
}
}
activity.supportActionBar?.setBackgroundDrawable(topColor.toDrawable())
@@ -20,40 +20,58 @@ fun setPaddingFromWindowInsets(
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) {
return
}
// Capture the bare toolbar height so the listener can recompute the
// padded height from scratch every time. Applying the insets only
// once (or adding them incrementally) leaves the layout stuck with
// stale values when the first callback reports wrong insets, which
// is why some devices only settled after several rotations.
val baseToolbarHeight = toolbarHeight
mainLayout.setOnApplyWindowInsetsListener { _, insets ->
val systemBarInsets = insets.getInsets(
WindowInsets.Type.systemBars()
)
val statusBarTop = systemBarInsets.top
if (toolbar.paddingTop == 0 && statusBarTop > 0) {
toolbar.setPadding(0, statusBarTop, 0, 0)
toolbar.layoutParams.height += statusBarTop
}
toolbar.setPadding(0, statusBarTop, 0, 0)
toolbar.layoutParams.height = baseToolbarHeight + statusBarTop
val navBarBottom = systemBarInsets.bottom
if (navBarBottom > 0) {
mainLayout.setPadding(0, 0, 0, navBarBottom);
navbar?.apply {
layoutParams.height = navBarBottom
}
mainLayout.setPadding(0, 0, 0, navBarBottom)
navbar?.apply {
layoutParams.height = navBarBottom
}
insets
}
}
fun View.setPaddingFromWindowInsets() {
doOnApplyWindowInsets { v, insets -> v.setPadding(insets) }
fun View.setPaddingFromWindowInsets(bottom: Boolean = true) {
doOnApplyWindowInsets { v, insets, windowInsets ->
if (!bottom) {
// On SDK 35+ the window is edge-to-edge and the parent isn't
// resized for the soft keyboard, so lift this view by the IME
// inset (above the navigation bar) here. On older versions the
// parent's fitsSystemWindows already shrinks this view above the
// keyboard, so adding the IME inset again would lift it twice.
insets.bottom = if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM
) {
getImeBottomInsetWithoutSystemBars(windowInsets)
} else {
0
}
}
v.setPadding(insets)
}
}
// A slight variation of the idea from Google's Chris Banes to avoid
// adding the toolbar height for every view that needs to be inset.
// For the original post see here:
// https://medium.com/androiddevelopers/windowinsets-listeners-to-layouts-8f9ccc8fa4d1
fun View.doOnApplyWindowInsets(f: (View, Rect) -> Unit) {
fun View.doOnApplyWindowInsets(f: (View, Rect, WindowInsetsCompat) -> Unit) {
ViewCompat.setOnApplyWindowInsetsListener(this) { v, insets ->
f(v, insetsWithToolbar(insets))
f(v, insetsWithToolbar(insets), insets)
insets
}
// It's important to explicitly request the insets (again) in
@@ -70,6 +88,11 @@ private fun insetsWithToolbar(insets: WindowInsetsCompat? = null) = Rect(
insets?.systemWindowInsetBottom ?: 0
)
private fun getImeBottomInsetWithoutSystemBars(insets: WindowInsetsCompat) =
(insets.getInsets(WindowInsetsCompat.Type.ime()).bottom -
insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom)
.coerceAtLeast(0)
private fun View.requestApplyInsetsWhenAttached() {
if (isAttachedToWindow) {
ViewCompat.requestApplyInsets(this)
@@ -3,17 +3,19 @@ package de.markusfisch.android.binaryeye.widget
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.graphics.Point
import android.graphics.Rect
import android.graphics.Region
import androidx.core.graphics.withSave
import android.os.Build
import androidx.core.content.ContextCompat
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import androidx.core.graphics.ColorUtils
import androidx.core.graphics.withSave
import com.google.android.material.color.MaterialColors
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.app.prefs
import de.markusfisch.android.binaryeye.graphics.getBitmapFromDrawable
@@ -34,13 +36,27 @@ class DetectorView : View {
coordinatesLast = 0
invalidate()
}
private val overlayColor = ColorUtils.setAlphaComponent(
MaterialColors.getColor(
context,
com.google.android.material.R.attr.colorOnSurface,
Color.WHITE
),
0x99
)
private val dotPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = ContextCompat.getColor(context, R.color.dot)
color = overlayColor
style = Paint.Style.FILL
}
private val crosshairPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = overlayColor
style = Paint.Style.STROKE
strokeWidth = context.resources.displayMetrics.density
}
private val dotRadius: Float
private val handleBitmap = resources.getBitmapFromDrawable(
R.drawable.button_crop
R.drawable.button_crop,
context.theme
)
private val handleXRadius = handleBitmap.width / 2
private val handleYRadius = handleBitmap.height / 2
@@ -192,7 +208,7 @@ class DetectorView : View {
val mn = min(center.x, center.y) * .8f
handlePos.set(
(center.x + mn).roundToInt(),
(center.y + mn).roundToInt()
min((center.y + mn).roundToInt(), handleHome.y)
)
handleActive = true
}
@@ -231,8 +247,8 @@ class DetectorView : View {
viewWidth = right - left
viewHeight = bottom - top
center.set(
left + (viewWidth / 2),
top + (viewHeight / 2)
viewWidth / 2,
viewHeight / 2
)
minY = padding * 2
maxY = viewHeight - minY
@@ -247,12 +263,21 @@ class DetectorView : View {
if (handleActive) {
canvas.drawClip()
}
if (prefs.showCrosshairs) {
canvas.drawCrosshairs()
}
canvas.drawDots()
if (prefs.showCropHandle) {
canvas.drawHandle()
}
}
fun getHorizontalCrosshairY() = if (handleActive) {
(roi.top + roi.bottom) * .5f
} else {
viewHeight * .5f
}
private fun Canvas.drawDots() {
var i = 0
while (i < coordinatesLast) {
@@ -294,6 +319,28 @@ class DetectorView : View {
)
}
private fun Canvas.drawCrosshairs() {
val left: Float
val top: Float
val right: Float
val bottom: Float
if (handleActive) {
left = roi.left.toFloat()
top = roi.top.toFloat()
right = roi.right.toFloat()
bottom = roi.bottom.toFloat()
} else {
left = 0f
top = 0f
right = viewWidth.toFloat()
bottom = viewHeight.toFloat()
}
val cx = (left + right) * .5f
val cy = (top + bottom) * .5f
drawLine(cx, top, cx, bottom, crosshairPaint)
drawLine(left, cy, right, cy, crosshairPaint)
}
private fun updateClipRect() {
clampHandlePos()
val dx = abs(handlePos.x - center.x)
@@ -1,36 +1,40 @@
package de.markusfisch.android.binaryeye.zxingcpp
val oldToNewFormatNames = mapOf(
"NONE" to "None",
"AZTEC" to "Aztec",
"CODABAR" to "Codabar",
"CODE_39" to "Code39",
"CODE_39_EXT" to "Code39Ext",
"CODE_32" to "Code32",
"PZN" to "PZN",
"CODE_93" to "Code93",
"CODE_128" to "Code128",
"DATA_BAR" to "DataBar",
"DATA_BAR_OMNI" to "DataBarOmni",
"DATA_BAR_STK" to "DataBarStk",
"DATA_BAR_LTD" to "DataBarLtd",
"DATA_BAR_EXPANDED" to "DataBarExp",
"DATA_BAR_EXP_STK" to "DataBarExpStk",
"DATA_MATRIX" to "DataMatrix",
"DX_FILM_EDGE" to "DXFilmEdge",
"EAN_8" to "EAN8",
"EAN_13" to "EAN13",
"ITF" to "ITF",
"MAXICODE" to "MaxiCode",
"PDF_417" to "PDF417",
"TELEPEN" to "Telepen",
"TELEPEN_ALPHA" to "TelepenAlpha",
"TELEPEN_NUMERIC" to "TelepenNumeric",
"QR_CODE" to "QRCode",
"MICRO_QR_CODE" to "MicroQRCode",
"RMQR_CODE" to "RMQRCode",
"UPC_A" to "UPCA",
"UPC_E" to "UPCE"
)
/**
* Translate legacy barcode format names from ZXingC++ before 3.x
* to current BarcodeFormat enum names.
*/
fun String.migrateBarcodeFormatName() = when (this) {
"NONE" -> "None"
"AZTEC" -> "Aztec"
"CODABAR" -> "Codabar"
"CODE_39" -> "Code39"
"CODE_39_EXT" -> "Code39Ext"
"CODE_32" -> "Code32"
"PZN" -> "PZN"
"CODE_93" -> "Code93"
"CODE_128" -> "Code128"
"DATA_BAR" -> "DataBar"
"DATA_BAR_OMNI" -> "DataBarOmni"
"DATA_BAR_STK" -> "DataBarStk"
"DATA_BAR_LTD" -> "DataBarLtd"
"DATA_BAR_EXPANDED" -> "DataBarExp"
"DATA_BAR_EXP_STK" -> "DataBarExpStk"
"DATA_MATRIX" -> "DataMatrix"
"DX_FILM_EDGE" -> "DXFilmEdge"
"EAN_8" -> "EAN8"
"EAN_13" -> "EAN13"
"ITF" -> "ITF"
"MAXICODE" -> "MaxiCode"
"PDF_417" -> "PDF417"
"QR_CODE" -> "QRCode"
"MICRO_QR_CODE" -> "MicroQRCode"
"RMQR_CODE" -> "RMQRCode"
"UPC_A" -> "UPCA"
"UPC_E" -> "UPCE"
else -> this
}
fun String.migrateBarcodeFormatName() = oldToNewFormatNames[this] ?: this
+3
View File
@@ -0,0 +1,3 @@
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="?attr/colorSecondaryContainer"/>
</selector>
@@ -0,0 +1,3 @@
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="?attr/colorSecondaryContainer"/>
</selector>
@@ -0,0 +1,3 @@
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="?attr/colorOnSecondaryContainer"/>
</selector>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 26 KiB

@@ -1,5 +1,5 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/primary"/>
<item android:drawable="?attr/colorSurface"/>
<item>
<bitmap
android:gravity="center"
@@ -1,5 +1,5 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/background_color"/>
<item android:drawable="?attr/colorSurface"/>
<item>
<bitmap
android:gravity="top|right"
+24 -11
View File
@@ -1,12 +1,25 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:aapt="http://schemas.android.com/aapt" android:width="56dp" android:height="56dp" android:viewportWidth="56" android:viewportHeight="56">
<path android:pathData="M4 32a24 24 0 1 1 48 0 24 24 0 1 1-48 0">
<aapt:attr name="android:fillColor">
<gradient android:gradientRadius="24" android:centerX="28" android:centerY="32" android:type="radial">
<item android:offset="0" android:color="#FF000000"/>
<item android:offset="1" android:color="#00000000"/>
</gradient>
</aapt:attr>
</path>
<path android:pathData="M4 28a24 24 0 1 1 48 0 24 24 0 1 1-48 0" android:fillColor="#B6D46F"/>
<path android:pathData="M33 31h2v-8c0-1.1-0.9-2-2-2h-8v2h8v8zm-10 2V17h-2v4h-4v2h4v10c0 1.1 0.9 2 2 2h10v4h2v-4h4v-2H23z" android:fillColor="#FFFFFF"/>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="56dp"
android:height="56dp"
android:viewportWidth="56"
android:viewportHeight="56">
<path android:pathData="M4 32a24 24 0 1 1 48 0 24 24 0 1 1-48 0">
<aapt:attr name="android:fillColor">
<gradient
android:gradientRadius="24"
android:centerX="28"
android:centerY="32"
android:type="radial">
<item android:offset="0" android:color="#FF000000"/>
<item android:offset="1" android:color="#00000000"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M4 28a24 24 0 1 1 48 0 24 24 0 1 1-48 0"
android:fillColor="@color/crop_handle_fill"/>
<path
android:pathData="M33 31h2v-8c0-1.1-0.9-2-2-2h-8v2h8v8zm-10 2V17h-2v4h-4v2h4v10c0 1.1 0.9 2 2 2h10v4h2v-4h4v-2H23z"
android:fillColor="@color/crop_handle_icon"/>
</vector>
+2 -2
View File
@@ -4,13 +4,13 @@
<item android:state_pressed="false">
<shape android:shape="rectangle">
<corners android:radius="2dp"/>
<solid android:color="@color/accent"/>
<solid android:color="?attr/colorSecondary"/>
</shape>
</item>
<item android:state_pressed="true">
<shape android:shape="rectangle">
<corners android:radius="2dp"/>
<solid android:color="@color/accent_dark"/>
<solid android:color="?attr/colorSecondaryContainer"/>
</shape>
</item>
</selector>
+5 -11
View File
@@ -1,16 +1,10 @@
<selector
<ripple
xmlns:android="http://schemas.android.com/apk/res/android"
android:constantSize="true">
<item android:state_pressed="false">
android:color="?attr/colorControlHighlight">
<item>
<shape android:shape="rectangle">
<corners android:radius="2dp"/>
<solid android:color="@color/selected_row"/>
<solid android:color="?attr/colorSecondaryContainer"/>
</shape>
</item>
<item android:state_pressed="true">
<shape android:shape="rectangle">
<corners android:radius="2dp"/>
<solid android:color="@color/pressed_row"/>
</shape>
</item>
</selector>
</ripple>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="?attr/colorControlNormal" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M313 520l224 224-57 56-320-320 320-320 57 56-224 224h487v80H313z"/>
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="?attr/colorControlNormal" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M480 340q13 0 21.5-9t8.5-21q0-13-8.5-21.5T480 280q-12 0-21 8.5t-9 21.5q0 12 9 21t21 9zM172 595q-15-26-23.5-54.5T140 480q0-32 8.5-60.5T172 365l64 50q-8 15-12 31t-4 34q0 18 4 34t12 31l-64 50zM62 681q-29-44-45.5-94.5T0 480q0-56 16.5-106.5T62 279l63 50q-21 33-33 71t-12 80q0 42 12 80.5t33 71.5l-63 49zm298 119q-33 0-56.5-23.5T280 720V240q0-33 23.5-56.5T360 160h240q33 0 56.5 23.5T680 240v480q0 33-23.5 56.5T600 800H360zm0-80h240V240H360v480zm427-125l-64-50q8-15 12.5-31t4.5-34q0-18-4.5-34T723 415l64-50q15 26 24 54.5t9 60.5q0 32-9 60.5T787 595zm111 85l-63-49q21-33 33-71t12-80q0-42-12-80.5T835 328l63-49q29 43 45.5 93.5T960 479q0 56-16.5 106.5T898 680zm-538 40V240v480z"/>
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="?attr/colorControlNormal" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M440 880V576L256 760l-56-56 224-224-224-224 56-56 184 184V80h40l228 228-172 172 172 172-228 228h-40zm80-496l76-76-76-74v150zm0 342l76-74-76-76v150z"/>
</vector>
@@ -1,3 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="?attr/colorControlNormal" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="#fff" android:pathData="M20 8.69V4h-4.69L12 0.69 8.69 4H4v4.69L0.69 12 4 15.31V20h4.69L12 23.31 15.31 20H20v-4.69L23.31 12 20 8.69zM12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6zm0-10c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4z"/>
</vector>
@@ -1,3 +1,3 @@
<vector android:height="24dp" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<vector android:height="24dp" android:tint="?attr/colorControlNormal" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FFFFFF" android:pathData="M14 10H2v2h12v-2zm0-4H2v2h12V6zm4 8v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zM2 16h8v-2H2v2z"/>
</vector>
+1 -1
View File
@@ -1,3 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24.0" android:viewportHeight="24.0">
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:tint="?attr/colorControlNormal" android:width="24dp" android:height="24dp" android:viewportWidth="24.0" android:viewportHeight="24.0">
<path android:fillColor="#FFFFFFFF" android:pathData="M16 1H4C2.9 1 2 1.9 2 3v14h2V3h12V1zm3 4H8C6.9 5 6 5.9 6 7v14c0 1.1 0.9 2 2 2h11c1.1 0 2-0.9 2-2V7c0-1.1-0.9-2-2-2zm0 16H8V7h11v14z"/>
</vector>
@@ -1,3 +1,3 @@
<vector android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<vector android:height="24dp" android:tint="?attr/colorControlNormal" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FFFFFF" android:pathData="M12.65 10C11.83 7.67 9.61 6 7 6c-3.31 0-6 2.69-6 6s2.69 6 6 6c2.61 0 4.83-1.67 5.65-4H17v4h4v-4h2v-4H12.65zM7 14c-1.1 0-2-0.9-2-2s0.9-2 2-2 2 0.9 2 2-0.9 2-2 2z"/>
</vector>
@@ -1,3 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24.0" android:viewportHeight="24.0">
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:tint="?attr/colorControlNormal" android:width="24dp" android:height="24dp" android:viewportWidth="24.0" android:viewportHeight="24.0">
<path android:fillColor="#FFFFFFFF" android:pathData="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="?attr/colorControlNormal" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M680 920V760H280q-33 0-56.5-23.5T200 680V280H40v-80h160V40h80v640h640v80H760v160h-80zm0-320V280H360v-80h320q33 0 56.5 23.5T760 280v320h-80z"/>
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="?attr/colorControlNormal" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M340.5 811.5Q275 783 226 734t-77.5-114.5Q120 554 120 480q0-44 10-85.5t29-78q19-36.5 45.5-68T264 192l272 272-56 56-216-216q-30 36-47 80.5T200 480q0 116 82 198t198 82q116 0 198-82t82-198q0-107-68.5-184.5T520 204v76h-80V120h40q74 0 139.5 28.5T734 226q49 49 77.5 114.5T840 480q0 74-28.5 139.5T734 734q-49 49-114.5 77.5T480 840q-74 0-139.5-28.5zm-89-303Q240 497 240 480t11.5-28.5Q263 440 280 440t28.5 11.5Q320 463 320 480t-11.5 28.5Q297 520 280 520t-28.5-11.5zm200 200Q440 697 440 680t11.5-28.5Q463 640 480 640t28.5 11.5Q520 663 520 680t-11.5 28.5Q497 720 480 720t-28.5-11.5zm200-200Q640 497 640 480t11.5-28.5Q663 440 680 440t28.5 11.5Q720 463 720 480t-11.5 28.5Q697 520 680 520t-28.5-11.5z"/>
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="?attr/colorControlNormal" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M440 160v487L216 423l-56 57 320 320 320-320-56-57-224 224V160h-80z"/>
</vector>
+1 -1
View File
@@ -1,3 +1,3 @@
<vector android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<vector android:height="24dp" android:tint="?attr/colorControlNormal" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c0.39-0.39 0.39-1.02 0-1.41l-2.34-2.34c-0.39-0.39-1.02-0.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/>
</vector>
@@ -1,3 +1,3 @@
<vector android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<vector android:height="24dp" android:tint="?attr/colorControlNormal" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M9 3L5 6.99h3V14h2V6.99h3L9 3zm7 14.01V10h-2v7.01h-3L15 21l4-3.99h-3z"/>
</vector>
@@ -1,3 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24.0" android:viewportHeight="24.0">
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:tint="?attr/colorControlNormal" android:width="24dp" android:height="24dp" android:viewportWidth="24.0" android:viewportHeight="24.0">
<path android:fillColor="#FFFFFFFF" android:pathData="M7 2v11h3v9l7-12h-4l4-8z"/>
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="?attr/colorControlNormal" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M640 680l-57-56 184-184-184-184 57-56 240 240-240 240zM80 760V600q0-83 58.5-141.5T280 400h247L383 256l57-56 240 240-240 240-57-56 144-144H280q-50 0-85 35t-35 85v160H80z"/>
</vector>
@@ -1,3 +1,3 @@
<vector android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<vector android:height="24dp" android:tint="?attr/colorControlNormal" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89 0.07 0.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-0.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.28 2.54 0.72-1.21-3.5-2.08V8H12z"/>
</vector>
@@ -1,3 +1,3 @@
<vector android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<vector android:height="24dp" android:tint="?attr/colorControlNormal" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M14.54 18.49v-1.98h2.97v-2.97h1.98v2.97h2.97v1.98l-2.952 0.018-0.018 2.952h-1.98l-0.018-2.952-2.952-0.018zm-1.19-3.74L11 12.4V7h2v4.6l1.4 1.4-1.05 1.75zM3 10V4h2v2.35c0.85-1.067 1.888-1.892 3.113-2.475C9.338 3.292 10.633 3 12 3c2.5 0 4.625 0.875 6.375 2.625S21 9.5 21 12h-2c0-1.95-0.679-3.604-2.037-4.963C15.604 5.679 13.95 5 12 5c-1.15 0-2.225 0.267-3.225 0.8S6.933 7.067 6.25 8H9v2H3zm9 11c-2.3 0-4.304-0.763-6.012-2.288C4.279 17.187 3.3 15.283 3.05 13H5.1c0.233 1.733 1.004 3.167 2.313 4.3C8.721 18.433 10.25 19 12 19c0.183 0 0.354-0.004 0.513-0.013 0.158-0.008 0.32-0.029 0.487-0.062v2.025c-0.167 0.017-0.329 0.029-0.487 0.037C12.354 20.996 12.183 21 12 21z"/>
</vector>
+1 -1
View File
@@ -1,3 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24.0" android:viewportHeight="24.0">
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:tint="?attr/colorControlNormal" android:width="24dp" android:height="24dp" android:viewportWidth="24.0" android:viewportHeight="24.0">
<path android:fillColor="#FFFFFFFF" android:pathData="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/>
</vector>
+2 -2
View File
@@ -1,3 +1,3 @@
<vector android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M3.9,12c0,-1.71 1.39,-3.1 3.1,-3.1h4L11,7L7,7c-2.76,0 -5,2.24 -5,5s2.24,5 5,5h4v-1.9L7,15.1c-1.71,0 -3.1,-1.39 -3.1,-3.1zM8,13h8v-2L8,11v2zM17,7h-4v1.9h4c1.71,0 3.1,1.39 3.1,3.1s-1.39,3.1 -3.1,3.1h-4L13,17h4c2.76,0 5,-2.24 5,-5s-2.24,-5 -5,-5z"/>
<vector android:height="24dp" android:tint="?attr/colorControlNormal" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/>
</vector>
+1 -1
View File
@@ -1,3 +1,3 @@
<vector android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<vector android:height="24dp" android:tint="?attr/colorControlNormal" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M20 4H4C2.9 4 2.01 4.9 2.01 6L2 18c0 1.1 0.9 2 2 2h16c1.1 0 2-0.9 2-2V6c0-1.1-0.9-2-2-2zm0 14H4V8l8 5 8-5v10zm-8-7L4 6h16l-8 5z"/>
</vector>
+1 -1
View File
@@ -1,3 +1,3 @@
<vector android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<vector android:height="24dp" android:tint="?attr/colorControlNormal" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="?attr/colorControlNormal" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M501 460zm-21 420q-33 0-56.5-23.5T400 800h160q0 33-23.5 56.5T480 880zM160 760v-80h80V400q0-83 50-147.5T420 168v-28q0-25 17.5-42.5T480 80q25 0 42.5 17.5T540 140v28q29 7 53.5 20.5T639 220l-57 57q-21-17-47-27t-55-10q-66 0-113 47t-47 113v280h480v80H160zm496-219l-56-57q10-10 15-24t5-32q0-18-5-32.5T600 371l56-56q21 21 32.5 50t11.5 63q0 34-11.5 63T656 541zm43 43q29-29 45-70t16-86q0-45-16-86t-45-70l57-56q41 41 62.5 95.5T840 428q0 62-21.5 116.5T756 640l-57-56z"/>
</vector>
+1 -1
View File
@@ -1,3 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24.0" android:viewportHeight="24.0">
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:tint="?attr/colorControlNormal" android:width="24dp" android:height="24dp" android:viewportWidth="24.0" android:viewportHeight="24.0">
<path android:fillColor="#FFFFFFFF" android:pathData="M19 4H5C3.89 4 3 4.9 3 6v12c0 1.1 0.89 2 2 2h4v-2H5V8h14v10h-4v2h4c1.1 0 2-0.9 2-2V6c0-1.1-0.89-2-2-2zm-7 6l-4 4h3v6h2v-6h3l-4-4z"/>
</vector>
@@ -1,3 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp">
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="?attr/colorControlNormal" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp">
<path android:fillColor="#FF000000" android:pathData="M14 10H2v2h12v-2zm0-4H2v2h12V6zm4 8v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zM2 16h8v-2H2v2z"/>
</vector>
@@ -1,3 +1,3 @@
<vector android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<vector android:height="24dp" android:tint="?attr/colorControlNormal" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M6 2C4.9 2 4.01 2.9 4.01 4L4 20c0 1.1 0.89 2 1.99 2H18c1.1 0 2-0.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/>
</vector>

Some files were not shown because too many files have changed in this diff Show More