Compare commits

...
459 Commits
Author SHA1 Message Date
Markus Fisch b32021a9dc Add a proper SECURITY.md 2026-07-13 20:54:31 +02:00
Markus Fisch 1aaa45d14e Advance version number to 1.75.1 2026-07-09 12:15:14 +02:00
Markus Fisch b14028f9c4 Update ZXingC++ library 2026-07-08 21:26:57 +02:00
Markus Fisch 124d8f2e6d Keep KDE Connect device picker visible
Stop result handling after launching KDE Connect so the scan
result screen does not cover its device picker.
2026-07-07 21:26:29 +02:00
Markus Fisch 0a5b9215af Restore direct URL scan concatenation
Avoid inserting a query separator when forwarding scans using
the "simply add content" request type.
2026-06-28 13:11:42 +02:00
Markus Fisch 4672839146 Advance version number to 1.75.0 2026-06-26 19:38:58 +02:00
Markus Fisch 7610fdd386 Forward scans with KDE Connect
Add a KDE Connect forwarding option that sends scan text to the
exported remote input activity via a text/x-keystrokes intent.
2026-06-24 18:29:48 +02:00
Markus Fisch fba7b0d70f Add default camera preference
Let users choose which available camera type the scanner opens with.

Keep the existing overflow camera switch temporary, while persisting
only the startup preference. Populate the setting from available
camera types and fall back to the current back-camera behavior by
default.
2026-06-23 19:27:12 +02:00
Markus Fisch cb48544896 Advance version number to 1.74.3 2026-06-13 19:36:44 +02:00
Markus Fisch 10ceb4ac4e Remove splash screen and window wallpaper
Both features used ?attr/colorSurface as the base layer of
a layer-list drawable set as android:windowBackground. That
attribute is resolved during drawable inflation, before the
theme is fully attached on certain GPU drivers. This is the
root cause of the rendering glitches reported since 1.73.0
in #655.

The proper fix for Material You is to reference ?attr/colorSurface
directly in the theme style, where the framework resolves it
at Activity level. But both backgrounds were layer-lists (color
and bitmap), so keeping them would have required moving the bitmaps
out of the window background and into every affected layout. Which
would have been a significant churn for purely decorative elements.

Removing them is the simpler and more correct outcome: the theme
now sets android:windowBackground to ?attr/colorSurface directly,
which works reliably everywhere and honours Material You dynamic
colours without workarounds.
2026-06-13 19:30:14 +02:00
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
Markus Fisch bc69f99f8b Advance version number to 1.68.6 2026-03-14 14:53:30 +01:00
Markus Fisch 6b088031fd Remove unused plural quantities
Delete plural forms that Android never selects for the affected
locales, based on the lint report.
2026-03-13 22:43:31 +01:00
Markus Fisch b27d03e9c0 Ask for confirmation before deleting a scan
From the decode view.
2026-03-13 22:37:43 +01:00
Markus Fisch 9a13838f28 Fix RTL hardcoded layout attributes
Remove redundant left/right layout attributes and keep the
start/end variants that match RTL-aware behavior.
2026-03-13 22:34:08 +01:00
Markus Fisch cce2a460f6 Update material library 2026-03-12 23:34:05 +01:00
Markus Fisch 36352ecde2 Apply KTX extensions from lint
Replace lint-flagged platform calls with their Kotlin extension
equivalents and add the core-ktx dependency they rely on.
2026-03-13 16:33:54 +01:00
Markus Fisch 9a0bc324cb Migrate support libraries to AndroidX
Replace legacy support dependencies and namespaces with AndroidX and
Material equivalents so the app builds on the modern stack again.

Update Kotlin imports, XML widget class names, and manifest provider
references, then fix AndroidX nullability fallout and missing lifecycle
super calls reported by lint.
2026-03-12 23:19:02 +01:00
Markus Fisch 6dabafd6a3 Replace fragments with standalone activities
Remove MainActivity as a single fragment host and convert each
fragment (Barcode, Decode, Encode, History, Profiles, Automated
Actions, Network Suggestions) into its own Activity.

Update CameraActivity to launch each Activity directly instead of
routing through MainActivity intent factories.
2026-03-12 23:10:18 +01:00
Markus Fisch dde4b1e0bd Update gradle wrapper 2026-03-12 22:58:58 +01:00
Markus Fisch 8a8c7bd1ff Remove values-v21 for minSDK is 21 now
No longer required. Merged the Button style with
`res/values/styles.xml`.
2026-03-12 22:16:02 +01:00
Markus Fisch a2aed42e25 Remove Scan parcel compatibility code
Stop transporting Scan as a Parcelable and keep only the
bundle-based handoff for decoded results.

This removes version-sensitive parcel decoding entirely and makes
scan navigation stable across app updates.
2026-03-12 21:51:55 +01:00
Markus Fisch a38b7b647f Use bundles for decoded scan handoff
Replace Scan parcelables in the decode navigation flow with plain
Bundle data.

This avoids BadParcelableException when Android lazily unparcels a
Scan written with a different layout or legacy ZXingCpp format names.
A bundle of primitives is stable across app updates and does not depend
on custom parcel compatibility.
2026-03-12 21:44:57 +01:00
Markus Fisch 76e82ed842 Migrate to ZXingCpp 3.x and raise minSdk to 21
Which is required (to build and run) version 3 of ZXingCpp,
because ZXingCpp requires C++20, which in turn requires an
NDK with minSdk 21.

So to use the new version, we need to raise the minSdk
and cut off old devices just because C++17 didn't cut the
mustard anymore. Well, I guess this had to happen at some
point, and because SDK 21 is 12 years old now, it's probably
even reasonable to raise the bar.
2026-03-12 20:53:23 +01:00
Markus Fisch fd54bc604e Advance version number to 1.68.5 2026-03-02 19:07:06 +01:00
Markus Fisch 59b62ecbc4 Revert ZXingC++ to v2.3.0.4
Because 3.x requires C++20, what requires minSdk 21.

While ZXingC++ 3.x compiles and runs on some devices (with the
current minSdk), the library does crash on other devices.

So updating to ZXingC++ 3.x requires more work, unfortunately.

This reverts commit
a90bdad117
4b2eb80612
7a59126312
2026-03-02 18:59:41 +01:00
Markus Fisch de6a2863f3 Advance version number to 1.68.4 2026-02-24 23:57:27 +01:00
Markus Fisch 4b2eb80612 Fix margin for 1D barcodes
Since ZXingCpp v3.0.0+ changed the default from -1 to 0.

Using -1 results in an exception in `BitMatrix::setRegion()`
because of invalid top/left coordinates.
2026-02-24 23:52:04 +01:00
Markus Fisch 2de40d2277 Advance version number to 1.68.3 2026-02-23 23:13:10 +01:00
Markus Fisch 7673b7cb0d Fallback to getParcelableExtra(name)
When getting the DECODE extra.

`getParcelableExtra(name, Class)` can throw on some versions
of Android when the ClassLoader associated with the Bundle's
lazy-decoded value is null in certain scenarios (e.g., after
an activity restart where the system re-delivers the intent).
Simply fallback to the "deprecated" `getParcelableExtra(name)`
which works in this case, because it doesn't verify the
parcelable's class against Scan::class.java.
2026-02-23 21:00:30 +01:00
Markus Fisch 097c5e4453 Improve getting fragment for MainActivity
And properly finish the MainActivity for malformed intents.
2026-02-23 17:21:30 +01:00
Markus Fisch 1fab30089e Advance version number to 1.68.2 2026-02-22 21:15:25 +01:00
Markus Fisch f9bf16dd47 Update ZXingCpp 2026-02-22 21:03:20 +01:00
Markus Fisch 7a59126312 Use 1 as the new default for margin/quiet zone
Since ZXingCpp v3.0.0+ changed the default from -1 to 0.

Using -1 results in an exception in `BitMatrix::setRegion()`
because of invalid top/left coordinates.
2026-02-22 20:58:24 +01:00
Markus Fisch 694c854650 Advance version number to 1.68.1 2026-02-21 21:50:51 +01:00
Markus Fisch b1cd12ec77 Validate frame data before giving it to ZXing
And refactor preview callback to use an early exit for clarity.

According to Vitals, `frameData` can apparently be `null` on
some devices sometimes.
2026-02-21 21:40:38 +01:00
Markus Fisch 0c27b15025 Check if fragment is still added
Before setting brightness, which may happen later because
it's called from the queue.
2026-02-21 21:20:31 +01:00
Markus Fisch 5de950bdbf Update tools version 2026-02-21 19:14:54 +01:00
Markus Fisch ae9e304ef6 Handle bad decoded intent parcelables safely
Guard reading the `decoded` Scan extra in MainActivity against
framework unparcel exceptions. Some external or malformed intents can
trigger a NullPointerException in Parcel before app-level checks run.

Return null when unparceling fails so the app falls back to the default
DecodeFragment path instead of crashing on launch.
2026-02-21 19:13:47 +01:00
Dominik StadlerandMarkus Fisch c4cc14554b Clear search-term on empty input, not on "Cancel"
Clearing search-term on cancel and not on empty input is
unexpected. "Cancel" usually does not change the state.

Rather clear the searchTerm on empty input and do not
handle the "Cancel"-button at all.
2026-02-20 13:52:50 +01:00
Dominik StadlerandMarkus Fisch d6afccf562 Apply some IDE suggestions 2026-02-20 13:52:50 +01:00
Markus Fisch a90bdad117 Update ZXingCpp
And remove the now missing `returnCodabarStartEnd` feature.
2026-02-13 19:05:35 +01:00
solokotandGitHub 52d4f082f2 Update Russian translation
Small Improvements
2026-02-12 19:45:38 +01:00
Markus Fisch 1c63f5b3ac Advance version number to 1.68.0 2026-02-07 20:18:43 +01:00
Markus Fisch 21985b4533 Remove deprecated flags from gradle.properties
Stuff only added because Google deemed it necessary.
Now the Google propeller caps have apparently changed
their minds. Don't care.
2026-02-07 20:15:41 +01:00
bovirusandGitHub 012ac644aa Italian language update 2026-02-07 12:21:26 +01:00
Markus Fisch 19f19739fd Fix up navigation for setting fragments 2026-02-06 17:42:18 +01:00
Markus Fisch f01f9fcd91 Add a dedicated fragement for managing profiles
And the missing option to remove profiles.
2026-02-06 17:42:18 +01:00
Markus Fisch 4177b248c3 Clean up AutomatedActionsFragment 2026-02-06 17:42:18 +01:00
Markus Fisch e4182a265b Add a ViewHolder to ActionsAdapter 2026-02-06 17:42:18 +01:00
Markus Fisch 98df5a447c Move adding an action into its own function
For readability.
2026-02-06 17:42:18 +01:00
Markus Fisch a0a1405359 Allow alphanumeric profile names only
Since a profile name is also used as a preference name,
it cannot contain path separators or anything like that.
So keep names alphanumeric for robustness.
2026-02-06 17:42:18 +01:00
bovirusandGitHub 76922c421c Italian language update 2026-02-06 17:41:42 +01:00
Markus Fisch cac9dd16da Add automated actions
To automatically execute actions when certain barcodes
are scanned.
2026-02-04 22:40:39 +01:00
Markus Fisch a1f9ba1056 Update gradle wrapper 2026-02-04 21:45:58 +01:00
Markus Fisch 0b67c272b6 Update robolectric to latest version 2026-02-03 19:16:54 +01:00
Markus Fisch 9b8be5305d Update tools and gradle wrapper to Gradle 9
And remove the now unnecessary kotlin-android plugin.
2026-02-03 18:15:33 +01:00
Markus Fisch a026ade13b Remove own toHexString() implementation
In favor of the new Kotlin API.
2026-01-26 20:31:07 +01:00
Markus Fisch b1771bc5ea Advance version number to 1.67.1 2026-01-14 23:19:07 +01:00
Markus Fisch 5199460669 Open file:// Uris as documents from Android N+
Because file:// can't be used with Scoped Storage anymore.
2026-01-14 23:16:05 +01:00
Markus Fisch fd040e1660 Remove lateinit from PickActivity members
Because `onCreate()` may return before the `lateinit`
members have been initialized, which would raise a
`kotlin.UninitializedPropertyAccessException`.
2026-01-13 14:53:27 +01:00
Markus Fisch 2e1528364e Fix auto closing of DecodeFragment
Some actions require the `Activity` object to stay alive, e.g.
`OpenOrSearchAction` or `WifiAction` may show a dialog first and
only then may trigger `startActivity`.

Before this commit, with the preferences `openImmediately` or
`closeAutomatically` enabled, the app crashed for content that
would show a dialog through `DecodeFragment.executeAction()`.
2026-01-13 21:20:35 +01:00
KamaleiZestriandGitHub d21af142bb Catch all exceptions for socket.connect()
`socket.connect()` can throw `IOException`s, too.

Occurs with Windows when the bluetooth service gets killed and
restarted in the background.
2026-01-13 09:52:50 +01:00
Markus Fisch 8395e135f9 Update Kotlin version 2025-12-11 20:04:25 +01:00
Markus Fisch c0caa0ce44 Update tools version 2025-12-11 20:03:10 +01:00
Markus Fisch 96927ef9b2 Add line breaks in README 2025-11-14 19:11:18 +01:00
TheRedCyclopsandGitHub 84d9a3b55a Remove double "at" in README 2025-11-14 11:14:34 +01:00
Markus Fisch ffe8e4c5ba Advance version number to 1.67.0 2025-10-10 20:06:03 +02:00
Markus Fisch bc7c5dca97 Fix restoring crop and zoom on profile changes 2025-10-09 20:45:52 +02:00
Markus Fisch d9737f4df1 Store crop handle position per view size
So we have a crop handle configuration for each view size.
2025-10-05 22:42:00 +02:00
Markus Fisch 0e8147713a Use default preferences for welcome screen marker
Since the welcome screen is intended to be shown for the first
start only.
2025-10-05 22:39:54 +02:00
Markus Fisch 3d425a097a Update robolectric version 2025-09-30 23:24:26 +02:00
Markus Fisch c26765cc63 Update Kotlin version 2025-09-30 23:22:15 +02:00
Markus Fisch fb6dc8ba55 Update kotlinx coroutines library 2025-09-30 23:21:54 +02:00
Markus Fisch 70f4d166a0 Support having multiple preference profiles
Requested in #558.
2025-09-30 23:20:30 +02:00
Markus Fisch 6c136ce719 Support regex when searching for a barcode 2025-09-30 16:36:14 +02:00
Markus Fisch c6dee80c0a Quote search term and remove comma
Between search term and barcode restriction.

Cleaner and less irritating.
2025-09-29 22:24:54 +02:00
Markus Fisch 1f43dd4827 Refactor scan_format to scan_for
Since this label is used for barcode formats and search terms.
2025-09-29 22:19:56 +02:00
Markus Fisch a366157977 Fix setting title with format and search term 2025-09-29 22:18:30 +02:00
Markus Fisch 6bb72672a3 Show a message for unmatching barcodes
So it's clear that the app recognized the barcode, but it's
not the one we're looking for.
2025-09-29 22:12:26 +02:00
Markus Fisch 04774527a0 Fix string capitalization for consistency 2025-09-29 21:36:44 +02:00
Markus Fisch 6f3e644aa8 Update tools version 2025-09-23 21:48:02 +02:00
Markus Fisch 5e3176b703 Update ZXingC++ for 16 KB ELF alignment
Because starting November 1st, 2025, all new apps and updates to
existing apps submitted to Google Play and targeting Android 15+
devices must support 16 KB page sizes on 64-bit devices.
2025-09-23 21:38:46 +02:00
zicaandGitHub 0f0aaff284 Improve zh-tw and ja translations 2025-09-12 18:39:29 +02:00
Markus Fisch f0d09e81b4 Advance version number to 1.66.2 2025-08-26 21:16:08 +02:00
Markus Fisch 771258cb5c Update tools version 2025-08-18 20:33:59 +02:00
Markus Fisch 9482086666 Reformat code 2025-08-18 20:33:21 +02:00
CertainBotandGitHub bcad50c3e8 Update Spanish translation
Few corrections over the AI translations.
2025-08-08 09:45:07 +02:00
Markus Fisch dbd055699e Remove adding ?content= to forward URL
When no other query was given.

This was meant to make it clear that the scanned content will
just be added to the given URL. But it's too restrictive as
there can be URLs without query strings.
2025-08-04 21:52:05 +02:00
Markus Fisch d67f8273d6 Update to target SDK 36 2025-07-30 21:03:48 +02:00
Markus Fisch 649be0ceb6 Update Kotlin version 2025-07-30 20:59:22 +02:00
Markus Fisch f5eb5dd8d2 Update gradle wrapper 2025-07-30 20:52:46 +02:00
Markus Fisch eb805dad2f Update tools version and gradle wrapper 2025-06-11 17:44:40 +02:00
Markus Fisch 202bb32b29 Advance version number to 1.66.1 2025-05-28 18:33:03 +02:00
Markus Fisch 50b8a4d329 Add missing curly braces
We always use curly braces around here.

When in doubt, google for "Apple's SSL Bug".
2025-05-28 18:27:26 +02:00
Markus Fisch fa15478fb4 Remove unused parameter from Bluetooth send method 2025-05-28 18:26:51 +02:00
Markus Fisch 20c2a5a8aa Catch Bluetooth SecurityExceptions explicitly
These are thrown when the user denies access to Bluetooth,
and `$ ./gradlew lintDebug` complains rightly about this.
2025-05-28 18:25:10 +02:00
Markus Fisch 00b95678ed Remove now unnecessary cast 2025-05-19 21:31:39 +02:00
Markus Fisch 5df3517563 Use an underscore for unused exception parameters
To get rid of the warnings.
2025-05-19 21:29:02 +02:00
Markus Fisch fd6946d2c2 Fix checking scan.id instead of fragment id 2025-05-19 21:05:22 +02:00
Markus Fisch c74d229e1f Update tools and gradle wrapper 2025-05-19 21:04:33 +02:00
Unknownman820andGitHub fa99a19db5 Update chinese translation 2025-05-07 19:07:44 +02:00
Markus Fisch b083fcf213 Show elements of invalid Wifi configurations too
So users can still access the elements even when the configuration
is invalid.
2025-05-07 18:47:34 +02:00
Markus Fisch 46930ddea6 Unescape Wifi elements in parseMap()
Instead of unescaping elements selectively in `SimpleDataAccessor`.

This is much simpler and this way the `Map` `parseMap()` returns has
always unescaped values. This `Map` is also layed out in the
`DecodeFragment`, which still displayed the escaped values before
this commit.
2025-05-07 18:47:34 +02:00
Markus Fisch 7bef77b96e Add test for unescaping emojis in Wifi credentials
Because of #544.
2025-05-07 18:47:34 +02:00
NathanandGitHub c5f4c088b4 Update french locale
Correction of automatic translations.
2025-05-01 21:58:36 +02:00
Yurt PageandGitHub 73f7210e18 Update translations from Toolate 2025-04-25 23:50:20 +02:00
Markus Fisch 469429e663 Advance version number to 1.66.0 2025-04-25 23:49:16 +02:00
Markus Fisch 1a7ac59740 Refactor BinaryEyeDeeplink to just Deeplink 2025-04-25 12:54:50 +02:00
Markus Fisch c13c59e728 Refactor IDLParser/ElementMap to use camel case
And match these names with other symbols in this code base
that don't use all caps for abbreviations like `Url` or
`UrlDialogFragment` and so on.
2025-04-25 12:51:30 +02:00
Markus Fisch bc17e25717 Reformat with Android Studio 2025-04-25 12:48:51 +02:00
Yurt PageandGitHub 5017bd4c8d fastlane: simplify short description
And add "QR code" to make it easier to find.
2025-04-25 12:43:58 +02:00
Anton SerebryakovandGitHub a44a913eca Support deep links to encode content via web links
To open barcode://encode via a HTTP deep link.

Also adds actions to copy the deep link to the clipboard.
2025-04-25 12:42:42 +02:00
Yurt PageandGitHub 007e83e243 fastlane: merge summary.txt into short_description.txt
summary.txt was used by Tripple-T but the default Fastlane file name is short_description.txt. It supports the summary.txt too but we have a duplication problem and some languages has both.
So I removed the duplicated summary.txt and renamed existing summary.txt into short_description.txt.
2025-04-23 21:43:47 +02:00
Markus Fisch de8f97c246 Parse International Driver Licenses
Usually from PDF417 barcodes.
2025-04-06 15:10:36 +02:00
Markus Fisch 1287591e53 Clean up Bluetooth instructions
Use proper bold syntax instead of making things headlines that
aren't headlines. Markdown is not source code for HTML but a human
readable document format. So semantics are important.

Also removes detailed install instructions for the companion apps.
These should be in the README of the corresponding application.
2025-04-03 21:44:40 +02:00
Markus Fisch 8c17238572 Only show clipboard toast before Android 13
Because there is a clipboard popup from Android 13 on.
2025-04-03 09:17:12 +02:00
Curious, aren't we?andGitHub 5dbb36e6a9 Add Bluetooth howto to the README 2025-04-02 23:33:14 +02:00
Markus Fisch ca7dfb3a33 Add new WiFi's via Settings intent
From API level 30, Android R, on.

This has the advantage that Binary Eye doesn't take ownership of
the newly added WiFi. See #482
2025-03-30 19:53:12 +02:00
Markus Fisch 419b7d5765 Give share file its own icon
So that we can clearly distinguish this action from the share
action when all actions are layed out next to each other.
2025-03-16 21:20:23 +01:00
Markus Fisch fa38866669 Update missing string resources
With a little help from the OpenAI API ;)
2025-03-27 14:19:44 +01:00
bovirusandGitHub 19ca521f59 Italian language update 2025-03-16 11:43:37 +01:00
Markus Fisch be657d86cf Show parsed data for MatMsgAction and WebAction 2025-03-14 12:27:06 +01:00
Markus Fisch 5199a4a526 Remove log messages
From debugging.
2025-03-14 12:27:06 +01:00
Markus Fisch ff48a543c9 Show parsed VType items in data view 2025-03-14 12:27:06 +01:00
Markus Fisch 2eadd01a87 Use apply scope function initialize valueView
Removes repetitions.
2025-03-14 12:27:06 +01:00
Markus Fisch dd2abe7d89 Copy password as every other content
And remove the corresponding special message.

It's probably clear enough with the generic message.
2025-03-14 12:27:06 +01:00
Markus Fisch 8e05caac70 Add click listener to copy data items
Into the clipboard for easy access.

Now, the data items should be bigger and also have some click feedback.
2025-03-14 12:27:06 +01:00
Markus Fisch 46aeced09f Separate Deep Links from Intents in README
And add some links on how to integrate a barcode scanner in an app.
2025-03-14 12:27:06 +01:00
NathanandGitHub 3681c5d011 Update french translation
Added strings for latest features.
2025-03-14 12:23:53 +01:00
Markus Fisch 20b4f7aac5 Advance version number to 1.65.2 2025-03-12 18:55:50 +01:00
Markus Fisch bc6e350271 Sanity check view, ROI and frame metrics
Because `roundToInt` in `Rect.setFrameRoi()` throws an
`IllegalArgumentException: Cannot round NaN value` for
some people out there.
2025-03-12 18:52:27 +01:00
Markus Fisch c9c1b2f5bf Advance version number to 1.65.1 2025-03-12 12:23:43 +01:00
Markus Fisch 1dbc0f0fa1 Fix fastlane changelogs
And insert the missing log for version code 141.
2025-03-12 12:20:20 +01:00
solokotandGitHub ab8170ee72 Update Russian translation
Fix machine translation
2025-03-12 12:11:05 +01:00
Markus Fisch 551464dc6f Refactor for an early return when content is empty
Easier to read and less indent.
2025-03-12 00:54:59 +01:00
Markus Fisch c3aac0a1e1 Set a default search URL for simple mode
So any (otherwise unregistered) content is always redirected to
the browser.
2025-03-12 00:53:37 +01:00
Markus Fisch d678e1cfd0 Hide keyboard when expanding recreated barcode
So the layout of `BarcodeFragment` isn't affected by it.
2025-03-12 00:49:30 +01:00
Markus Fisch a445ade877 Reduce height for 1D barcodes
1D barcodes don't need as much vertical space.
2025-03-12 00:48:20 +01:00
Markus Fisch 1fd0b7637f Add execute argument for binaryeye://encode
For immediate code generation.
2025-03-12 00:37:47 +01:00
Markus Fisch fdc88df897 Remember quiet zone settings
And use a quiet zone by default because it's recommended.
2025-03-12 00:19:41 +01:00
Markus Fisch d0f133ae00 Fix showing a scan from history
When `openImmediately` is `true`.

Of course, the action should only be executed when we're coming
from the scanner, not the history.
2025-03-12 00:11:14 +01:00
Markus Fisch 67646261e7 Remove unnecessary id field 2025-03-12 00:08:11 +01:00
Markus Fisch faeae9e904 Advance version number to 1.65.0 2025-03-11 23:23:55 +01:00
Markus Fisch 77b21a9e83 Add a welcome screen to choose a mode
To address these bad reviews when people don't understand that
they have to press this little green button.
2025-03-11 23:17:36 +01:00
Markus Fisch cbe7860a3d Add a deep link to encode a barcode 2025-03-11 22:58:45 +01:00
Markus Fisch fc29267d3d Update ZXing-C++ library 2025-03-11 22:58:10 +01:00
Markus Fisch 890506328b Fix show crop handle setting
This switch isn't part of the crop configuration, but controls if
the crop handle is actually visible or not.
2025-03-11 22:58:10 +01:00
Markus Fisch 04036b6cd6 Use apply scope function to initialize views
And remove all this repetition of names.
2025-03-11 22:58:10 +01:00
Markus Fisch 2602021c1c Remove unused import from PickActivity 2025-03-11 22:58:10 +01:00
Markus Fisch 17a0bdc0a9 Allow shrinking of image samples too
So they can fit in the crop window.
2025-03-11 22:58:10 +01:00
Markus Fisch d71a220eca Remember zoom level of barcodes in view
Solves #490.
2025-03-11 22:58:10 +01:00
Markus Fisch 0c2e711d79 Add a comment why Scan.text can be an empty string
So it's immediately clear why `Scan.text` can be empty.
2025-03-11 22:58:10 +01:00
Markus Fisch ca342a83d3 Configure ZXingCpp to return plain text only
And remove the HRI decoration because it's confusing people and
doesn't work well when the barcode is re-generated from this
content.

Alternatively, we could filter the HRI decoration for generation,
but that's probably overkill.

Solves #506.
2025-03-11 22:58:10 +01:00
Markus Fisch 3c44ff56eb Refactor SCAN_CONTENT to SCAN_TEXT
To match the refactoring of `Scan.text` in 5189fc.
2025-03-11 22:58:10 +01:00
bovirusandGitHub bcae64c0d0 Italian language update 2025-03-11 22:57:50 +01:00
Markus Fisch c2f673729b Advance version number to 1.64.2 2025-03-10 21:23:52 +01:00
Markus Fisch 2043e0f349 Use separate crop handle config for image scanning
These are two different use cases.
2025-03-10 21:11:41 +01:00
Markus Fisch b93eb5963e Use raw content for Barcode<*> when text is empty
So binary content is supported here, too.
2025-03-10 11:01:15 +01:00
Markus Fisch 5189fc9c5a Refactor Scan.content to Scan.text
Because `text` is more specific, and the term `content` is often
used for generics.
2025-03-10 10:54:44 +01:00
Markus Fisch 2d311ff92a Fix initializing dataMask
And show this item for QR Codes only.
2025-03-10 19:50:19 +01:00
solokotandGitHub 14bb08003d Update Russian translation 2025-03-10 10:31:50 +01:00
Markus Fisch 42f9887030 Advance version number to 1.64.1 2025-03-09 22:39:37 +01:00
Markus Fisch a59d241b50 Handle empty input for recreation 2025-03-09 22:35:28 +01:00
Markus Fisch 9f31160c7b Add missing symbol fields to initial db creation 2025-03-09 22:14:06 +01:00
Markus Fisch 2eb4e51c17 Advance version number to 1.64.0 2025-03-09 19:43:00 +01:00
Markus Fisch 53a554e7a2 Add a menu item to find a specific barcode
The searched barcode needs to include the given term.

Solves #516.
2025-03-09 19:09:19 +01:00
Markus Fisch 7f6887716a Use Barcode for recreation too
And move `Barcode` into its own file.

So a given `BitMatrix` can be used for `BarcodeFragment` (and sharing
and exporting) as well.
2025-03-09 14:19:27 +01:00
Markus Fisch 5ef93802ce Make margin optional for BarcodeFragment
Just like `ecLevel` and `colors`.
2025-03-09 12:57:23 +01:00
Markus Fisch c1eff08bb9 Remove unnecessary semicolons
Too much context change.
2025-03-09 12:56:48 +01:00
Markus Fisch 8798d44ec7 Fix showing QR Code data mask
The first data mask is 0.
2025-03-09 12:55:55 +01:00
Markus Fisch d4e31ff97e Update ZXing-C++ library
To fix data mask initialization.
2025-03-09 12:55:28 +01:00
solokotandGitHub ae8b08a85a Update Russian translation 2025-03-09 12:10:10 +01:00
Markus Fisch 09430d776e Replace margin with add quiet zone switch
Giving a margin in pixels doesn't scale with (the size) of the
barcode.
2025-03-08 22:18:04 +01:00
Markus Fisch ef88c3179d Use detected barcode matrix for recreation
If it is provided by ZXing-C++. Currently, this is only supported
for some 2D barcodes. But it's probably only really useful for them
because there's padding and and other stuff that 1D barcodes don't
have.

This may solve #507.
2025-03-08 21:57:13 +01:00
Markus Fisch c732ec726b Show data mask of QR Codes 2025-03-08 21:54:54 +01:00
Markus Fisch 801c768df4 Update ZXing-C++ library 2025-03-08 21:53:19 +01:00
Markus Fisch 552a2f6d57 Only color status bar in context mode before SDK35
Has no effect from SDK35 on anyway.
2025-03-04 21:31:55 +01:00
Markus Fisch 7fa8810197 Make getIdOfScanByContent() private
Because it can be.
2025-03-04 21:27:12 +01:00
Markus Fisch 254af25bbb Update database functions to non-nullable Cursors
Because `SQLiteDatabase.rawQuery` returns a non-nullable `Cursor` now.
2025-03-07 16:55:58 +01:00
Markus Fisch a27656c174 Add a translucent nav bar background on SDK35+
Because the system doesn't draw one anymore.
2025-03-05 15:24:44 +01:00
Markus Fisch 2ca55ebccc Fix setting window insets on SDK35+
The bottom padding from the navigation bar needs to be set, too.
2025-03-04 19:47:41 +01:00
Markus Fisch ec1d40942d Optimize vector drawables with avocado 2025-03-04 00:21:30 +01:00
Markus Fisch 7852e5ce9d Use color literal in vector drawable
Because resource references will not work correctly in images
generated for API < 21.
2025-03-04 00:20:31 +01:00
Markus Fisch f36a81d4a0 Update Kotlin version 2025-03-04 12:50:50 +01:00
Markus Fisch d86ea4073c Remove unused quantity for Brazilian Portuguese
Following the corresponding lint warning.
2025-03-04 12:50:37 +01:00
Markus Fisch a69530621d Add autoVerify for URL intent filter 2025-03-04 12:50:28 +01:00
Markus Fisch 3c0f29a768 Add targetApi notation for quick settings tile
Since this is only available from Android N+.
2025-03-04 00:09:12 +01:00
Markus Fisch ccf5157fa7 Mark autofocus as optional
As it isn't strictly required.
2025-03-04 00:08:58 +01:00
Markus Fisch c1ebb1c154 Specify an explicit locale for String.format()
To silent the corresponding lint warning.

Doesn't really matter here probably.
2025-03-04 00:07:59 +01:00
Markus Fisch a4778bcc02 Replace removeFirst with removeAt
Because `java.util.List#removeFirst` requires SDK35, and resolved
to a Kotlin stdlib extension function before.
2025-03-04 00:04:04 +01:00
Markus Fisch 24d2c6d8d6 Only color system bars before SDK35
And ignore deprecation warning in this branch.

System bars no longer have a background from SDK35+.
2025-03-03 23:59:14 +01:00
Markus Fisch 0f3a07f602 Name SDK that requires a change explicitly
Instead of using greater than, because this way we can search
for VANILLA_ICE_CREAM changes.
2025-03-04 12:34:33 +01:00
Markus Fisch 751d8932f8 Immediately replace toasts
Instead of showing them in sequence.

Because in this app, only the last message is important.

Speeds up continuous scanning. Especially when the scanned code is
also forwarded and the server result is displayed in the next toast
(see #523).
2025-03-03 23:47:16 +01:00
Markus Fisch 9c2d063c24 Update tools version 2025-03-03 20:55:16 +01:00
Markus Fisch 0f9bbf5b2f Extend toolbar under status bar on SDK35+
Because SDK 35 is edge-to-edge by default, and doesn't color the
status or navigation bar anymore.
2025-02-18 21:34:27 +01:00
Markus Fisch 530280981e Fix image rotation calculation 2025-02-18 21:30:46 +01:00
Markus Fisch ddfbb305c7 Check Bitmap.getConfig() for null before use 2025-02-18 21:27:30 +01:00
Markus Fisch 8d1a29922b Update to SDK 35 2025-02-18 21:26:52 +01:00
Markus Fisch c710f7c6ab Move Toolate link below the reference section
Reference-style links should be derefenced after the content.

Also add a proper header for the matter.
2025-02-14 20:40:33 +01:00
Yurt PageandGitHub 111c587eef Add link to Toolate in CONTRIBUTING 2025-02-14 20:39:35 +01:00
Markus Fisch 264e494487 Update tools version and gradle wrapper 2025-01-23 19:59:08 +01:00
Markus Fisch 8135b2a3dc Update build tools 2024-12-19 22:23:45 +01:00
kraloandGitHub dce62b70d3 Update README to explain non-clickable binaryeye:// url 2024-12-05 21:56:06 +01:00
Markus Fisch 533812c638 Revert removing http deep linking intent filter
This is required for "http(s)://markusfisch.de/BinaryEye?ret=".

This reverts commit 12554e63e2.
2024-12-05 19:52:56 +01:00
Markus Fisch 5dc5bed281 Fix indent in Portuguese (Brazil) translation 2024-12-01 17:58:36 +01:00
Too Late (bot)andGitHub 2a13561962 Update Portuguese (Brazil) translation 2024-12-01 17:57:46 +01:00
Markus Fisch 1476f41cda Advance version number to 1.63.13 2024-11-24 19:37:04 +01:00
Markus Fisch 0da1ffbe02 Use JavaVersion.VERSION_1_8 for compatibility
Instead of just the version literal. Using the literal breaks
when the host has a higher Java version installed.
2024-11-19 20:08:34 +01:00
Markus Fisch 980679b6a3 Avoid UTF-8 conversion for binary input data
Otherwise the data is modified and doesn't match the desired input.

Still, it's probably better to introduce another CheckBox to explicity
control charset conversion.
2024-11-17 16:50:02 +01:00
Markus Fisch 28cbb55299 Use early returns when handling escape sequences
Improves readability.
2024-11-17 16:47:23 +01:00
Markus Fisch 1a4e7d100a Improve readability of expanding escape sequences 2024-11-17 16:46:23 +01:00
Markus Fisch 265e43b439 Fix argument order for assertEquals()
In EscapeTest.
2024-11-17 16:44:56 +01:00
NathanandGitHub 03a58c3412 Update French translation 2024-11-13 21:37:17 +01:00
Markus Fisch 327cad48f0 Update coroutines library 2024-11-06 21:50:30 +01:00
Markus Fisch 8e64fe2f56 Update Kotlin version 2024-11-06 21:50:14 +01:00
Markus Fisch 368d4dbb66 Fix typo in german translation 2024-10-18 19:39:38 +02:00
Manuel CovasandGitHub c8c3c094db Fix chronological order of scans taken less than 1 second apart 2024-10-17 16:36:24 +02:00
Wiktor PerskawiecandGitHub aa4aefe35f Fix typos in README and PRIVACY 2024-10-17 16:34:34 +02:00
AhmedandGitHub 536b3d77c1 Update arabian zero plural translation 2024-10-02 16:14:54 +02:00
Markus Fisch bbf2a80981 Add missing plurals for arabic
To silence lint from complaining that these are missing.
2024-10-02 15:39:33 +02:00
AhmedandGitHub ae99214d6c Update plurals in arabic translation 2024-10-02 15:36:54 +02:00
AhmedandGitHub 73b5a0969c Add arabic translation 2024-10-02 12:42:07 +02:00
Markus Fisch bf5dc96ac9 Advance version number to 1.63.12 2024-09-26 21:30:48 +02:00
Markus Fisch 301d9c2513 Fix argument list of recreation in onClickListener 2024-09-26 21:24:49 +02:00
Markus Fisch 0a11611b8f Advance version number to 1.63.11 2024-09-21 20:20:24 +02:00
Markus Fisch 249e310e43 Simplify asking for a size when sharing/exporting 2024-09-20 12:10:06 +02:00
Sergiy StuparandGitHub 6279c5731a Update Ukrainian translation 2024-09-20 12:09:46 +02:00
bovirusandGitHub df98637951 Update Italian language 2024-09-18 12:09:08 +02:00
solokotandGitHub 08b3558e46 Update Russian translation 2024-09-18 12:08:45 +02:00
Markus Fisch 648ef6fdf8 Update Kotlin coroutines library 2024-09-17 22:38:04 +02:00
Markus Fisch 807f075a51 Update Kotlin version 2024-09-17 22:37:08 +02:00
Markus Fisch 27789beeed Simplify ellipsis regex 2024-09-17 22:31:42 +02:00
Markus Fisch 97bee27827 Replace View casts for generic denotation 2024-09-17 22:29:02 +02:00
Markus Fisch 3fee49301e Only ask for barcode size when exporting/sharing
Because this parameter is just relevant when exporting/sharing
a raster image format.
2024-09-17 22:28:37 +02:00
Markus Fisch 8a6073291b Improve size slider for barcode generation
Reduce size options to popular powers of two.

And add the option to generate a barcode in the smallest
possible size of one pixel per module.
2024-09-17 20:54:07 +02:00
Markus Fisch 47151de4a4 Fix indent in Portuguese (Brazil) translation 2024-09-06 16:19:41 +02:00
Too Late (bot)andGitHub 248c4edca1 Update Portuguese (Brazil) translation 2024-09-06 16:18:21 +02:00
Markus Fisch 2c7386d536 Fix indent in Chinese translation 2024-08-17 18:12:32 +02:00
Too Late (bot)andGitHub e19cad4c87 Update Chinese translation 2024-08-17 18:09:47 +02:00
ZahnstocherandGitHub 585e0f3372 Update parcel tracking URL 2024-07-25 12:04:37 +02:00
Markus Fisch 2b7f611774 Fix showing tooltip on action FAB 2024-07-24 20:21:50 +02:00
Markus Fisch 05b2a0d9c9 Support hyphens in the second level domain
To correctly recognize URLs as WebAction.
2024-07-24 20:08:57 +02:00
Markus Fisch e7287eb088 Update tools version and gradle wrapper 2024-07-24 19:41:43 +02:00
NathanandGitHub 4b45ad3fe7 Update French translation
Added translations for checksum calculation (1.63.8) and screen brightening (1.63.10) features.
2024-07-13 19:58:04 +02:00
Markus Fisch 449d8fdbbd Advance version number to 1.63.10 2024-06-30 19:41:13 +02:00
Markus Fisch f44cff9912 Use type in getParcelableExtra() on Tiramisu+
And ignore the deprecation warning for earlier Android versions.

Also use a scope function to remove the local variable in the
parent context.
2024-06-30 19:06:02 +02:00
ThetaDevandGitHub 24730dfde3 Allow file input from share menu
Updates the handleSendText method to support both types of intents.

Also extends the accepted file types to text/* to also accept VCards
and other text-based files.
2024-06-30 18:54:20 +02:00
Sergiy StuparandGitHub be6539d41f Update Ukrainian translation 2024-06-16 13:41:14 +02:00
bovirusandGitHub 60dd40554e Update Italian language 2024-06-12 09:05:24 +02:00
solokotandGitHub 837119b6ec Update Russian translation 2024-06-12 09:04:57 +02:00
Markus Fisch abe11b82e5 Fix indent in Polish translation 2024-07-11 12:49:33 +02:00
Markus Fisch 4fc1e36d04 Add a menu item to toggle screen brightness
So this setting can be enabled/disabled where it's required, too.
2024-06-11 14:22:22 +02:00
Markus Fisch 54aa15e1be Set maximum brightness when showing a barcode
To make scanning from screen easier.
2024-06-11 14:22:18 +02:00
Too Late (bot)andGitHub 876d8fc7b3 Update Polish translation 2024-06-10 20:31:07 +02:00
Markus Fisch 7bdcdaaa5d Advance version number to 1.63.9 2024-06-02 18:15:53 +02:00
Markus Fisch fd44728e89 Ignore "URL:" before URLs in barcode content
Apparently some people think it's necessary to add an "URL:" prefix
before an actual URL, so let's cut that rubbish.
2024-06-02 15:24:22 +02:00
Markus Fisch 8242520a0b Export/share generated barcode as JPG
On request.

Generally, using a lossless format like PNG makes more sense.
2024-06-02 14:00:27 +02:00
Markus Fisch 2e7e9a942b Fix indent in new translations 2024-06-02 13:51:43 +02:00
Too Late (bot)andGitHub dcc9d5b3ff Update Brazilian translation 2024-06-02 11:54:14 +02:00
Markus Fisch 607aab854c Update build tools 2024-05-27 19:39:46 +02:00
Markus Fisch cc2b2f076b Fix exporting/sharing multiple history items
By their ID.

`?` is only one argument so a list of numbers becomes just one ID,
which cannot work, of course.
2024-05-27 19:37:43 +02:00
Too Late (bot)andGitHub 90e9723de5 Update Chinese translation 2024-05-13 10:38:54 +02:00
Sergiy StuparandGitHub ab6787e60b Update Ukrainian translation 2024-05-12 10:40:14 +02:00
solokotandGitHub 698686a45a Update Russian translation 2024-05-11 15:35:51 +02:00
Markus Fisch 95bdaa1252 Reformat imports with Android Studio
Seems like wildcard imports are back in fashion.
2024-05-11 14:13:39 +02:00
Markus Fisch f0c02e5156 Reformat with Android Studio 2024-05-11 14:13:20 +02:00
Markus Fisch a8c50308d0 Add Ko-fi for donations 2024-05-11 13:20:31 +02:00
bovirusandGitHub 6b359d4c65 Update Italian language 2024-05-10 21:08:07 +02:00
Markus Fisch 863caed387 Advance version number to 1.63.8 2024-05-10 20:56:25 +02:00
Markus Fisch e4f17b9bdf Add an option to show a checksum
For the decoded content. On request of #452.
2024-05-10 20:37:32 +02:00
Markus Fisch c2071d8d81 Treat LF/CR/Tab as printable characters
When encoding "something\nlike\tthüs". This string should be encoded
as text rather than binary, of course.
2024-05-08 19:49:19 +02:00
Markus Fisch 4758c2d337 Update tools and gradle wrapper 2024-05-07 21:57:36 +02:00
Markus Fisch 91336fe15c Encode as ByteArray if text contains non-printable
To support escape sequences containing non-printable characters.
2024-05-07 21:54:57 +02:00
Markus Fisch 2c437f3771 Add Liberapay to README 2024-04-27 15:58:37 +02:00
bovirusandGitHub cb082b20fe Update Italian language 2024-04-21 21:27:57 +02:00
Markus Fisch bce3630686 Advance version number to 1.63.7 2024-04-17 21:48:31 +02:00
Markus Fisch 4feb4d066c Update ZXingCpp for Aztec Runes 2024-04-17 21:24:30 +02:00
Markus Fisch e7d84cd916 Update tools version 2024-04-17 21:22:53 +02:00
Giovanni DonisiandGitHub 84e2917694 Update Italian translation 2024-04-14 13:58:07 +02:00
Markus Fisch b87464ec40 Advance version number to 1.63.6 2024-04-13 14:38:27 +02:00
Markus Fisch 269e0f41a6 Correct spelling mistakes in Spanish translation
Following the recommendations from the linter.
2024-04-13 14:32:27 +02:00
NathanandGitHub aefbc4a296 Update French translation 2024-04-13 14:26:19 +02:00
Sergiy StuparandGitHub c64984dc6a Update Ukrainian translation 2024-03-31 12:49:47 +02:00
Markus Fisch 24c7ad0ba4 Return Codabar start/stop codes
As requested in #445.

The start/stop codes determine if a barcode is complete or if there
is a continuation code to scan.
2024-03-30 12:46:15 +01:00
Markus Fisch 643a935852 Move buildConfig from properties into build.gradle
Apparently setting 'android.defaults.buildfeatures.buildconfig=true'
is deprecated and will be removed in version 9.0 of the Android Gradle
plugin.
2024-03-30 12:46:15 +01:00
solokotandGitHub 4fcb836b53 Update Russian translation 2024-03-26 12:12:30 +01:00
Markus Fisch 48ed5f1a2c Add history shortcut 2024-03-25 21:36:53 +01:00
Markus Fisch 3aa2371eeb Advance version number to 1.63.5 2024-03-24 21:15:31 +01:00
solokotandGitHub 88f92943c0 Update Russian translation 2024-03-24 14:53:36 +01:00
Sergiy StuparandGitHub 39710d4ab9 Update Ukrainian translation 2024-03-24 14:53:03 +01:00
Markus Fisch 93f7cd3cb8 Map old ignore setting to new one
For existing installations that changed this value.
2024-03-23 20:21:08 +01:00
Markus Fisch 69baa3b824 Add an option to ignore any duplicates
As requested in issue #439.
2024-03-23 14:20:42 +01:00
Markus Fisch bb05e38278 Update tools version 2024-03-21 21:23:28 +01:00
KamaleiZestriandGitHub 554f5a5e73 Fix failing send due to invalid bluetooth connection
Try reconnect to socket once before failing.
2024-03-21 21:19:51 +01:00
Markus Fisch 2dcf7772f2 Update tools version and gradle wrapper 2024-03-17 13:52:49 +01:00
Markus Fisch 3c7012e3c4 Advance version number to 1.63.4 2024-03-17 13:35:31 +01:00
Markus Fisch 08e11c3c40 Update ZXingCpp to fix support for old devices 2024-03-16 21:38:51 +01:00
Unknownman820andGitHub 31854dab0c Update Chinese translation 2024-03-16 21:38:04 +01:00
Markus Fisch 4f12c3e270 Advance version number to 1.63.3 2024-03-04 21:06:50 +01:00
Markus Fisch 3ec43dabce Update AndroidX SCAN Intent sample 2024-03-03 18:34:42 +01:00
Markus Fisch 0d4f536f71 Fix returning barcode format in return Intent
The class `BarcodeFormat` is not known to callers, of course.
2024-03-03 18:30:55 +01:00
Markus Fisch b064a293fb Add donation options to the README 2024-02-29 20:25:50 +01:00
Markus Fisch d124c29919 Advance version number to 1.63.2 2024-02-19 22:08:22 +01:00
Markus Fisch 2e89f1530a Add DX_FILM_EDGE barcode format
And add it to the selected barcodes so it's immediately
available for existing installations too.
2024-02-17 13:41:49 +01:00
Markus Fisch ef7e2a4b0d Update build tools 2024-02-17 13:40:28 +01:00
Markus Fisch f680533819 Update ZXingCpp for DX_FILM_EDGE format 2024-02-17 13:21:06 +01:00
Oğuz ErsenandGitHub a8c977c40e Update Turkish translation 2024-01-21 17:31:20 +01:00
Markus Fisch 1e89b22953 Update ZXingCpp to latest version
For QRCode: fix crash reported in #700
2024-01-15 20:14:50 +01:00
Markus Fisch 11f028e0f3 Update Kotlin version 2024-01-15 20:13:37 +01:00
Markus Fisch 1f7f4a23b5 Update tools version 2024-01-15 20:11:50 +01:00
Sergiy StuparandGitHub 41581d07ec Update Ukrainian translation 2024-01-14 20:53:17 +01:00
373 changed files with 17779 additions and 4857 deletions
+239
View File
@@ -1,5 +1,244 @@
# Change Log
## 1.75.1
* Update ZXingCpp
* Keep KDE Connect device picker visible
* Fix URL scan concatenation
## 1.75.0
* Forward scans with KDE Connect
* Add default camera preference
## 1.74.3
* Fix layout for Material You
## 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
## 1.68.5
* Downgrade ZXingCpp for compatibilty
## 1.68.4
* Fix generating 1D barcodes
## 1.68.3
* Stability improvement
## 1.68.2
* Fix barcode regeneration
## 1.68.1
* Improve UI of search barcode dialog
* Stability improvements
* Update ZXingCpp
* Update Russian translation
## 1.68.0
* Add automated actions
* Improve managing profiles
## 1.67.1
* Fix opening file:// Uris
* Fix auto-return/immediate opening
* Improve Bluetooth stability
## 1.67.0
* Add preference profiles
* Support regex when searching for a barcode
* Improve feedback when searching for a barcode
* Update translations
## 1.66.2
* Remove forcing `?content=` on forwarding URL
* Update Spanish translation
## 1.66.1
* Show elements of invalid Wifi configurations too
* Update translations
## 1.66.0
* Support deep links to encode content via web links
* Parse International Driver Licenses (IDL) in PDF417 barcodes
* Add WiFis from system settings
* Show parsed data items
* Update translations
## 1.65.2
* Sanity check ROI metrics
## 1.65.1
* Improve simple mode
* Remember quiet zone settings
* Reduce height for 1D barcodes
* Add execute argument for binaryeye://encode
* Update Russian translation
## 1.65.0
* Add a welcome screen to choose a mode
* Add a deep link to encode a barcode
* Remember zoom level of generated barcodes
* Remove Human Readable Interpretation (HRI) decoration
* Update Italian translation
## 1.64.2
* Separate crop window settings for image scanning
* Fix initializing dataMask
* Update Russian translation
## 1.64.1
* Handle empty input for recreation
* Fix setting up database
## 1.64.0
* Find barcode by content
* Immediately replace toasts
* Use detected barcode for recreation
* Show data mask of QR Codes
* Fix deep linking
* Replace margin with quiet zone switch when generating barcodes
* Update Russian translation
* Update Portuguese (Brazil) translation
## 1.63.13
* Fix UTF-8 conversion of binary escape sequences when creating barcodes
* Fix scan order for intervals under one second
* Add arabic translation
* Update French translation
## 1.63.12
* Fix magnifying barcode recreation
## 1.63.11
* Update parcel tracking URL
* Update multiple translations
* Only ask for barcode size when exporting/sharing
## 1.63.10
* Use maximum brightness when showing a generated barcode
* Allow file input from share menu
* Update multiple translations
## 1.63.9
* Export/share as JPEG
* Ignore "URL:" before URLs in barcode content
* Fix exporting/sharing multiple history items
* Update multiple translations
## 1.63.8
* Add an option to show a checksum for the decoded content
* Add support for encoding escape sequences for non-printable characters
* Update Italian translation
## 1.63.7
* Update ZXingCpp for Aztec Runes
* Update Italian translation
## 1.63.6
* Add history shortcut
* Return Codabar start/stop codes
* Update French, Russian, Spanish and Ukrainian translations
## 1.63.5
* Add an option to ignore any duplicates
* Fix invalid bluetooth connection
* Update Russian translation
* Update Ukrainian translation
## 1.63.4
* Update Chinese translation
* Improve support for old devices
## 1.63.3
* Fix SCAN Intent
## 1.63.2
* Add DX Film Edge barcode format
* Update Turkish translation
* Update Ukrainian translation
## 1.63.1
* Include scan label in exports/shares
* Update Russian translation
+5
View File
@@ -16,5 +16,10 @@ to squash when merging into master:
Then write a [good commit message][commit_messages] to keep the history
meaningful and useful. One feature, one commit.
## Translations
For translations use [Toolate][toolate].
[android_best_practices]: https://developer.android.com/distribute/best-practices/develop/
[commit_messages]: https://git-scm.com/book/en/v2/Distributed-Git-Contributing-to-a-Project#_commit_guidelines
[toolate]: https://toolate.othing.xyz/projects/binaryeye/
+22 -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:
@@ -28,6 +28,26 @@ testscan:
-c android.intent.category.BROWSABLE \
-d 'binaryeye://scan/?ret=http%3A%2F%2Fmarkusfisch.de%2F%3Fresult%3D{RESULT}'
testencode:
adb shell am start -a android.intent.action.VIEW \
-c android.intent.category.BROWSABLE \
-d 'binaryeye://encode?content=Test\&format=QR_CODE'
testencodenotqr:
adb shell am start -a android.intent.action.VIEW \
-c android.intent.category.BROWSABLE \
-d 'binaryeye://encode?content=Test\&format=DATA_MATRIX'
testencodeexec:
adb shell am start -a android.intent.action.VIEW \
-c android.intent.category.BROWSABLE \
-d 'binaryeye://encode?content=Test\&format=QR_CODE\&execute'
testencodeurl:
adb shell am start -a android.intent.action.VIEW \
-c android.intent.category.BROWSABLE \
-d 'http://markusfisch.de/encode?content=Test\&format=QR_CODE'
testurl:
adb shell am start -a android.intent.action.VIEW \
-c android.intent.category.BROWSABLE \
+1 -1
View File
@@ -15,7 +15,7 @@ Before Android Q, this feature requires using
[WifiManager.getConfiguredNetworks][getConfiguredNetworks]
which requires `ACCESS_FINE_LOCATION`.
On Android Q an better, this permission is not requested nor required.
On Android Q and better, this permission is not requested nor required.
### [BLUETOOTH][BLUETOOTH], [BLUETOOTH_ADMIN][BLUETOOTH_ADMIN] and [BLUETOOTH_CONNECT][BLUETOOTH_CONNECT]
+75 -12
View File
@@ -2,7 +2,7 @@
Yet another barcode scanner for Android. As if there weren't [enough][play].
This one is free, without any ads and open source.
This one is free, without any ads, and open source.
Works in portrait and landscape orientation, can read inverted codes,
comes in Material Design and can also generate barcodes.
@@ -10,6 +10,23 @@ comes in Material Design and can also generate barcodes.
Binary Eye uses the [ZXing-C++][zxing_cpp] ("Zebra Crossing") barcode
scanning library.
If you find this app useful and wish to support its continued development,
you can [buy me a coffee](https://www.buymeacoffee.com/markusfisch) or
send some Bitcoin decimals to `bc1q2guk2rpll587aymrfadkdtpq32448x5khk5j8z`.
<a href="https://www.buymeacoffee.com/markusfisch" target="_blank"><img
src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png"
alt="Buy Me A Coffee"
style="height: 60px !important;width: 217px !important;"/></a>&nbsp;<a
href="https://liberapay.com/markusfisch/" target="_blank"><img
src="https://liberapay.com/assets/widgets/donate.svg"
alt="Liberapay"
style="height: 60px !important"/></a>&nbsp;<a
href="https://ko-fi.com/markusfisch" target="_blank"><img
src="https://storage.ko-fi.com/cdn/brandasset/kofi_s_tag_dark.png"
alt="Ko-fi"
style="height: 60px !important"/></a>
## Screenshots
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/screencap-scanning.png"
@@ -36,11 +53,13 @@ ZXing can read the following barcode formats:
* [CODE 93][code_93]
* [CODE 128][code_128]
* [DATA MATRIX][data_matrix]
* [DX FILM EDGE][dx_film_edge]
* [EAN 8][ean_8]
* [EAN 13][ean_13]
* [ITF][itf]
* [MAXICODE][maxicode] (partial)
* [PDF417][pdf417]
* [Telepen][telepen]
* [QR CODE][qr_code]
* [Micro QR Code][micro_qr_code]
* [rMQR Code][rmqr_code]
@@ -65,14 +84,14 @@ ZXing can generate the following barcode formats:
* [QR CODE][qr_code]
* [UPC A][upc_a]
## Deep Links and Intents
### Deep Links
## Deep Links
You can invoke Binary Eye with a web URI intent from anything that can
open URIs. There are two options:
open URIs.
1. [binaryeye://scan](binaryeye://scan)
### Decoding
1. [binaryeye://scan](binaryeye://scan) (note: GitHub does not render this as clickable link)
2. [http(s)://markusfisch.de/BinaryEye](http://markusfisch.de/BinaryEye)
If you want to get the scanned contents, you can add a `ret` query
@@ -86,10 +105,32 @@ Supported symbols are:
* `RESULT_BYTES` - raw result as a hex string
* `FORMAT` - barcode format
### Encoding
1. [binaryeye://encode](binaryeye://encode)
You can use the URL arguments `content` and `format` to automatically
preset this data. For example:
1. [binaryeye://encode?content=Test&format=QR_CODE](binaryeye://encode?content=Test&format=QR_CODE)
2. [http(s)://markusfisch.de/encode?content=Test&format=QR_CODE](http://markusfisch.de/encode?content=Test&format=QR_CODE)
3. [http(s)://markusfisch.de/encode?content=Test&format=DATA_MATRIX](http://markusfisch.de/encode?content=Test2&format=DATA_MATRIX&execute)
If you want the code to be generated immediately, add `execute=true` (or
just `execute`).
## Intents
You can also use Binary Eye from other apps by using an [Intent][intent].
If you prefer to integrate a barcode scanner into your app, take a look at
[BarcodeScannerView][barcode_scanner_view] (if you also want to use
[ZXing-C++][zxing_cpp]) or read how to
[scan barcodes with ML Kit on Android][mlkit_barcode_scanning].
### SCAN Intent
You can also use Binary Eye from other apps by using the
`com.google.zxing.client.android.SCAN` Intent with
Use the `com.google.zxing.client.android.SCAN` `Intent` with
[startActivityForResult()][start_activity] like this:
```kotlin
@@ -121,10 +162,12 @@ If you're using AndroidX, this would be the new,
[recommended way][intent_result]:
```kotlin
class YourActivity : Activity() {
private val resultLauncher = registerForActivityResult(StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val scan = result.data?.getStringExtra("SCAN_RESULT")
class YourActivity : AppCompatActivity() {
private val resultLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == RESULT_OK) {
val content = result.data?.getStringExtra("SCAN_RESULT")
}
}
@@ -135,6 +178,21 @@ class YourActivity : Activity() {
}
```
## Forwarding scans via Bluetooth
**Note:** The companion apps aren't developed nor maintained by the
author of Binary Eye.
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/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
companion application.
[play]: https://play.google.com/store/search?q=barcode%20scanner&c=apps
[zxing_cpp]: https://github.com/zxing-cpp/zxing-cpp
[kotlin]: http://kotlinlang.org/
@@ -144,11 +202,13 @@ class YourActivity : Activity() {
[code_93]: https://en.wikipedia.org/wiki/Code_93
[code_128]: https://en.wikipedia.org/wiki/Code_128
[data_matrix]: https://en.wikipedia.org/wiki/Data_Matrix
[dx_film_edge]: https://en.wikipedia.org/wiki/DX_encoding
[ean_8]: https://en.wikipedia.org/wiki/EAN-8
[ean_13]: https://en.wikipedia.org/wiki/International_Article_Number
[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
@@ -156,6 +216,9 @@ class YourActivity : Activity() {
[upc_a]: https://en.wikipedia.org/wiki/Universal_Product_Code
[upc_e]: https://en.wikipedia.org/wiki/Universal_Product_Code#UPC-E
[upc_ean]: https://en.wikipedia.org/wiki/Universal_Product_Code#EAN-13
[intent]: https://developer.android.com/reference/android/content/Intent
[barcode_scanner_view]: https://github.com/markusfisch/BarcodeScannerView
[mlkit_barcode_scanning]: https://developers.google.com/ml-kit/vision/barcode-scanning/android
[start_activity]: https://developer.android.com/reference/android/app/Activity#startActivityForResult(android.content.Intent,%20int)
[on_activity_result]: https://developer.android.com/reference/android/app/Activity#onActivityResult(int,%20int,%20android.content.Intent)
[intent_result]: https://developer.android.com/training/basics/intents/result
+19
View File
@@ -0,0 +1,19 @@
# Security Policy
## Supported Versions
Only the latest released version of Binary Eye is supported with
security updates.
## Reporting a Vulnerability
Please report security vulnerabilities by email to markusfisch.de@gmail.com.
Do not open a public GitHub issue for security reports.
Include enough detail to reproduce the issue, such as affected versions,
device and Android version, reproduction steps, impact, and any proof of
concept code or logs.
I will acknowledge valid reports as soon as practical and coordinate a fix
before public disclosure.
+30 -16
View File
@@ -1,16 +1,15 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
android {
namespace "de.markusfisch.android.binaryeye"
compileSdk sdk_version
defaultConfig {
minSdk 9
minSdk 21
targetSdk sdk_version
versionCode 129
versionName '1.63.1'
versionCode 176
versionName '1.75.1'
}
signingConfigs {
@@ -35,13 +34,17 @@ android {
}
release {
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
minifyEnabled true
shrinkResources true
signingConfig signingConfigs.release
}
}
buildFeatures {
buildConfig true
}
bundle {
language {
// To make the app bundle contain all language resources
@@ -54,29 +57,40 @@ android {
}
}
// Required since Gradle 8.
compileOptions {
// Required for Gradle 8.
sourceCompatibility = 17
targetCompatibility = 17
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
}
dependencies {
// Kotlin
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4"
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 "com.android.support:appcompat-v7:$support_version"
implementation "com.android.support:design:$support_version"
implementation "com.android.support:preference-v7:$support_version"
implementation "com.android.support:preference-v14:$support_version"
implementation "androidx.appcompat:appcompat:1.7.1"
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'
implementation 'com.github.markusfisch:ScalingImageView:1.4.1'
implementation 'com.github.markusfisch:zxing-cpp:v2.2.0.1'
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.1.0.0'
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'
}
Binary file not shown.
+2
View File
@@ -3,3 +3,5 @@
-dontwarn android.support.v8.**
-keep class android.support.v7.widget.SearchView { *; }
-keep class de.tsenger.vdstools.** { *; }
+61 -7
View File
@@ -19,8 +19,9 @@
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/>
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
<uses-feature android:name="android.hardware.camera"/>
<uses-feature android:name="android.hardware.camera.autofocus"/>
<uses-sdk tools:overrideLibrary="android.support.v14.preference"/>
<uses-feature android:name="android.hardware.camera.autofocus"
android:required="false" />
<uses-sdk tools:overrideLibrary="androidx.preference"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>
@@ -45,7 +46,7 @@
android:theme="@style/AppTheme">
<activity
android:name=".activity.SplashActivity"
android:theme="@style/SplashTheme"
android:theme="@style/AppTheme"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
@@ -62,7 +63,7 @@
<intent-filter android:label="@string/compose_barcode">
<action android:name="android.intent.action.SEND"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="text/plain"/>
<data android:mimeType="text/*"/>
</intent-filter>
<intent-filter>
<action android:name="com.google.zxing.client.android.SCAN"/>
@@ -75,6 +76,15 @@
<data android:host="details" android:scheme="market"
android:pathSuffix="id=com.xiaomi.scanner"/>
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:host="markusfisch.de"/>
<data android:pathPrefix="/BinaryEye"/>
<data android:scheme="http"/>
<data android:scheme="https"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
@@ -99,20 +109,64 @@
</intent-filter>
</activity>
<activity
android:name=".activity.MainActivity"
android:name=".activity.WelcomeActivity"
android:exported="true"/>
<activity
android:name=".activity.PreferencesActivity"
android:exported="true"/>
<activity
android:name=".activity.HistoryActivity"
android:exported="true"/>
<activity
android:name=".activity.EncodeActivity"
android:exported="true">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:host="markusfisch.de"/>
<data android:pathPrefix="/encode"/>
<data android:scheme="http"/>
<data android:scheme="https"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="binaryeye" android:host="encode"/>
</intent-filter>
</activity>
<activity
android:name=".activity.DecodeActivity"
android:exported="false"/>
<activity
android:name=".activity.BarcodeActivity"
android:exported="false"/>
<activity
android:name=".activity.ProfilesActivity"
android:exported="false"/>
<activity
android:name=".activity.AutomatedActionsActivity"
android:exported="false"/>
<activity
android:name=".activity.IgnoreCodesActivity"
android:exported="false"/>
<activity
android:name=".activity.NetworkSuggestionsActivity"
android:exported="false"/>
<service
android:name=".service.ScanTileService"
android:icon="@drawable/ic_action_scan"
android:label="@string/scan_code"
android:exported="true"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
tools:targetApi="n">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE"/>
</intent-filter>
</service>
<provider
android:name="android.support.v4.content.FileProvider"
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
Binary file not shown.
@@ -2,19 +2,21 @@ 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
interface IAction {
val iconResId: Int
val titleResId: Int
abstract class Action {
abstract val iconResId: Int
abstract val titleResId: Int
fun canExecuteOn(data: ByteArray): Boolean
suspend fun execute(context: Context, data: ByteArray)
var executed: Boolean = false
abstract fun canExecuteOn(data: ByteArray): Boolean
abstract suspend fun execute(context: Context, data: ByteArray)
}
abstract class IntentAction : IAction {
abstract class IntentAction : Action() {
abstract val errorMsg: Int
final override suspend fun execute(context: Context, data: ByteArray) {
@@ -22,7 +24,7 @@ abstract class IntentAction : IAction {
if (intent == null) {
context.toast(errorMsg)
} else {
context.execShareIntent(intent)
executed = context.startIntentOrToast(intent)
}
}
@@ -32,7 +34,7 @@ abstract class IntentAction : IAction {
): Intent?
}
abstract class SchemeAction : IAction {
abstract class SchemeAction : Action() {
abstract val scheme: String
open val buildRegex: Boolean = false
@@ -50,6 +52,6 @@ abstract class SchemeAction : IAction {
}
final override suspend fun execute(context: Context, data: ByteArray) {
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
@@ -12,11 +13,12 @@ import de.markusfisch.android.binaryeye.actions.web.WebAction
import de.markusfisch.android.binaryeye.actions.wifi.WifiAction
object ActionRegistry {
val DEFAULT_ACTION: IAction = OpenOrSearchAction
val DEFAULT_ACTION: Action = OpenOrSearchAction
private val REGISTRY: Set<IAction> = setOf(
private val REGISTRY: Set<Action> = setOf(
MailAction,
MatMsgAction,
FidoAction,
OtpauthAction,
SmsAction,
TelAction,
@@ -28,7 +30,7 @@ object ActionRegistry {
WebAction
)
fun getAction(data: ByteArray): IAction = REGISTRY.find {
fun getAction(data: ByteArray): Action = REGISTRY.find {
it.canExecuteOn(data)
} ?: DEFAULT_ACTION
}
@@ -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())
}
}
@@ -2,7 +2,7 @@ package de.markusfisch.android.binaryeye.actions.mail
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.actions.IntentAction
@@ -24,7 +24,7 @@ object MailAction : IntentAction() {
)?.groupValues?.get(1) ?: return null
return Intent(
Intent.ACTION_SENDTO,
Uri.parse("mailto:$mailWithMessage")
"mailto:$mailWithMessage".toUri()
)
}
}
@@ -2,7 +2,7 @@ package de.markusfisch.android.binaryeye.actions.mail
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.actions.IntentAction
@@ -26,7 +26,7 @@ object MatMsgAction : IntentAction() {
}
return Intent(
Intent.ACTION_SENDTO,
Uri.parse("mailto:")
"mailto:".toUri()
).apply {
mm.to?.let {
putExtra(Intent.EXTRA_EMAIL, arrayOf(mm.to))
@@ -2,62 +2,65 @@ package de.markusfisch.android.binaryeye.actions.search
import android.content.Context
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.actions.IAction
import de.markusfisch.android.binaryeye.actions.Action
import de.markusfisch.android.binaryeye.app.alertDialog
import de.markusfisch.android.binaryeye.app.prefs
import de.markusfisch.android.binaryeye.content.openUrl
import de.markusfisch.android.binaryeye.net.urlEncode
object OpenOrSearchAction : IAction {
object OpenOrSearchAction : Action() {
override val iconResId: Int = R.drawable.ic_action_search
override val titleResId: Int = R.string.search_web
override fun canExecuteOn(data: ByteArray): Boolean = false
override suspend fun execute(context: Context, data: ByteArray) {
view(context, String(data), true)
}
private suspend fun view(
context: Context,
url: String,
search: Boolean
) {
if (!context.openUrl(url, silent = true) && search) {
openSearch(context, url)
}
}
private suspend fun openSearch(context: Context, query: String) {
val defaultSearchUrl = prefs.defaultSearchUrl
if (defaultSearchUrl.isNotEmpty()) {
view(
context,
defaultSearchUrl + query.urlEncode(),
false
)
return
}
val names = context.resources.getStringArray(
R.array.search_engines_names
).toMutableList()
val urls = context.resources.getStringArray(
R.array.search_engines_values
).toMutableList()
// Remove the "Always ask" entry. The arrays search_engines_*
// are used in the preferences too.
names.removeFirst()
urls.removeFirst()
if (prefs.openWithUrl.isNotEmpty()) {
names.add(prefs.openWithUrl)
urls.add(prefs.openWithUrl)
}
val queryUri = alertDialog<String>(context) { resume ->
setTitle(R.string.pick_search_engine)
setItems(names.toTypedArray()) { _, which ->
resume(urls[which] + query.urlEncode())
}
} ?: return
view(context, queryUri, false)
executed = openUrlOrSearch(context, String(data))
}
}
private suspend fun openUrlOrSearch(
context: Context,
url: String,
search: Boolean = true
): Boolean {
if (context.openUrl(url, silent = true)) {
return true
}
if (search) {
return prependSearchUrl(context, url)?.let {
openUrlOrSearch(context, it, false)
} ?: false
}
return false
}
private suspend fun prependSearchUrl(
context: Context,
query: String
): String? {
val defaultSearchUrl = prefs.defaultSearchUrl
if (defaultSearchUrl.isNotEmpty()) {
return defaultSearchUrl + query.urlEncode()
}
val names = context.resources.getStringArray(
R.array.search_engines_names
).toMutableList()
val urls = context.resources.getStringArray(
R.array.search_engines_values
).toMutableList()
// Remove the "Always ask" entry. The arrays search_engines_*
// are used in the preferences too.
names.removeAt(0)
urls.removeAt(0)
if (prefs.openWithUrl.isNotEmpty()) {
names.add(prefs.openWithUrl)
urls.add(prefs.openWithUrl)
}
return alertDialog(context) { resume ->
setTitle(R.string.pick_search_engine)
setItems(names.toTypedArray()) { _, which ->
resume(urls[which] + query.urlEncode())
}
}
}
@@ -2,7 +2,7 @@ package de.markusfisch.android.binaryeye.actions.sms
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.actions.IntentAction
@@ -27,7 +27,7 @@ object SmsAction : IntentAction() {
} ?: return null
return Intent(
Intent.ACTION_SENDTO,
Uri.parse("smsto:$number")
"smsto:$number".toUri()
).apply {
if (message.isNotEmpty()) {
putExtra("sms_body", message)
@@ -3,7 +3,6 @@ package de.markusfisch.android.binaryeye.actions.vtype.vcard
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.os.Build
import android.provider.ContactsContract
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.actions.IntentAction
@@ -98,38 +97,36 @@ object VCardAction : IntentAction() {
}
putExtra(ContactsContract.Intents.Insert.POSTAL, addr.value.locationFormat)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
val urls = info["URL"]?.mapNotNull {
it.value.takeIf { url -> url.isNotBlank() }?.let { url ->
ContentValues(2).apply {
put(
ContactsContract.Data.MIMETYPE,
ContactsContract.CommonDataKinds.Website.CONTENT_ITEM_TYPE
)
put(ContactsContract.CommonDataKinds.Website.URL, url)
}
val urls = info["URL"]?.mapNotNull {
it.value.takeIf { url -> url.isNotBlank() }?.let { url ->
ContentValues(2).apply {
put(
ContactsContract.Data.MIMETYPE,
ContactsContract.CommonDataKinds.Website.CONTENT_ITEM_TYPE
)
put(ContactsContract.CommonDataKinds.Website.URL, url)
}
}
val bDays = info["BDAY"]?.mapNotNull {
it.value.takeIf { day -> day.isNotBlank() }?.let { day ->
ContentValues(3).apply {
put(
ContactsContract.Data.MIMETYPE,
ContactsContract.CommonDataKinds.Event.CONTENT_ITEM_TYPE
)
put(
ContactsContract.CommonDataKinds.Event.TYPE,
ContactsContract.CommonDataKinds.Event.TYPE_BIRTHDAY
)
put(ContactsContract.CommonDataKinds.Event.START_DATE, day)
}
}
}
putParcelableArrayListExtra(
ContactsContract.Intents.Insert.DATA,
ArrayList(listOfNotNull(urls, bDays).flatten())
)
}
val bDays = info["BDAY"]?.mapNotNull {
it.value.takeIf { day -> day.isNotBlank() }?.let { day ->
ContentValues(3).apply {
put(
ContactsContract.Data.MIMETYPE,
ContactsContract.CommonDataKinds.Event.CONTENT_ITEM_TYPE
)
put(
ContactsContract.CommonDataKinds.Event.TYPE,
ContactsContract.CommonDataKinds.Event.TYPE_BIRTHDAY
)
put(ContactsContract.CommonDataKinds.Event.START_DATE, day)
}
}
}
putParcelableArrayListExtra(
ContactsContract.Intents.Insert.DATA,
ArrayList(listOfNotNull(urls, bDays).flatten())
)
}
}
@@ -2,14 +2,13 @@ package de.markusfisch.android.binaryeye.actions.vtype.vevent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.provider.CalendarContract
import android.support.annotation.RequiresApi
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.actions.IntentAction
import de.markusfisch.android.binaryeye.actions.vtype.VTypeParser
import java.text.SimpleDateFormat
import java.util.*
import java.util.Date
import java.util.Locale
object VEventAction : IntentAction() {
override val iconResId: Int
@@ -20,14 +19,10 @@ object VEventAction : IntentAction() {
get() = R.string.vevent_failed
override fun canExecuteOn(data: ByteArray): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
return false
}
val type = VTypeParser.parseVType(String(data))
return type == "VEVENT" || type == "VCALENDAR"
}
@RequiresApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
override suspend fun createIntent(context: Context, data: ByteArray): Intent? {
val info = VTypeParser.parseMap(String(data))
@@ -87,6 +82,6 @@ private fun List<String>.simpleFindParse(date: String): Date? {
private fun SimpleDateFormat.simpleParse(date: String): Date? = try {
parse(date)
} catch (e: Exception) {
} catch (_: Exception) {
null
}
@@ -2,12 +2,12 @@ package de.markusfisch.android.binaryeye.actions.web
import android.content.Context
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.actions.IAction
import de.markusfisch.android.binaryeye.actions.Action
import de.markusfisch.android.binaryeye.content.openUrl
object WebAction : IAction {
object WebAction : Action() {
private val colloquialRegex =
"^(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
@@ -18,9 +18,12 @@ object WebAction : IAction {
override suspend fun execute(context: Context, data: ByteArray) {
var url = String(data).trim()
if (url.startsWith("URL:")) {
url = url.substring(4).trim()
}
if (!url.startsWith("http") && !url.startsWith("ftp")) {
url = "http://${url}"
}
context.openUrl(url)
executed = context.openUrl(url)
}
}
@@ -2,31 +2,39 @@ package de.markusfisch.android.binaryeye.actions.wifi
import android.content.Context
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.actions.IAction
import de.markusfisch.android.binaryeye.actions.Action
import de.markusfisch.android.binaryeye.widget.toast
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
object WifiAction : IAction {
object WifiAction : Action() {
override val iconResId = R.drawable.ic_action_wifi
override val titleResId = R.string.connect_to_wifi
var password: String? = null
override fun canExecuteOn(data: ByteArray): Boolean =
WifiConnector.parse(String(data)) {
password = it
} != null
override fun canExecuteOn(data: ByteArray): Boolean {
// Just check if the data can be parsed as the app should also
// show invalid configurations so users can still access them.
val inputMap = WifiConnector.parseMap(String(data)) ?: return false
password = inputMap["P"]
return !inputMap["S"].isNullOrEmpty()
}
override suspend fun execute(
context: Context,
data: ByteArray
) = withContext(Dispatchers.IO) {
val message = WifiConnector.parse(String(data))?.let {
WifiConnector.addNetwork(context, it)
} ?: R.string.wifi_config_failed
withContext(Dispatchers.Main) {
context.toast(message)
override suspend fun execute(context: Context, data: ByteArray) {
withContext(Dispatchers.IO) {
executed = WifiConnector.parse(String(data))?.let {
WifiConnector.addNetwork(context, it)
} ?: false
withContext(Dispatchers.Main) {
context.toast(
if (executed) {
R.string.wifi_added
} else {
R.string.wifi_config_failed
}
)
}
}
}
}
@@ -1,14 +1,15 @@
package de.markusfisch.android.binaryeye.actions.wifi
import android.content.Context
import android.content.Intent
import android.net.wifi.WifiConfiguration
import android.net.wifi.WifiEnterpriseConfig
import android.net.wifi.WifiManager
import android.net.wifi.WifiNetworkSuggestion
import android.os.Build
import android.support.annotation.RequiresApi
import de.markusfisch.android.binaryeye.R
import java.util.*
import android.provider.Settings
import androidx.annotation.RequiresApi
import java.util.Locale
/**
* Normal:
@@ -35,15 +36,9 @@ import java.util.*
* The fields can appear in any order. Only "S:" is required.
*/
object WifiConnector {
fun parse(
input: String,
passwordBlock: ((password: String?) -> Unit)? = null
): Any? {
fun parse(input: String): Any? {
val inputMap = parseMap(input) ?: return null
val parsedData = SimpleDataAccessor.of(inputMap) ?: return null
passwordBlock?.apply {
invoke(parsedData.password)
}
return try {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
// WifiConfiguration is deprecated in Android Q but
@@ -54,7 +49,7 @@ object WifiConnector {
} else {
WifiNetworkSuggestion.Builder().apply(parsedData)
}
} catch (e: IllegalArgumentException) {
} catch (_: IllegalArgumentException) {
// Some arguments may be out of range, e.g. SSIDs cannot
// be longer than 32 characters in some versions of Android.
// See: https://github.com/markusfisch/BinaryEye/issues/402
@@ -62,24 +57,26 @@ object WifiConnector {
}
}
fun addNetwork(context: Context, config: Any): Int {
fun addNetwork(context: Context, config: Any): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
config is WifiNetworkSuggestion.Builder
) {
context.suggestNetworkFromBuilder(config)
return true
}
val wifiManager = context.applicationContext.getSystemService(
Context.WIFI_SERVICE
) as WifiManager
// WifiConfiguration is deprecated in Android Q.
@Suppress("DEPRECATION")
return if ((config is WifiConfiguration &&
wifiManager.enableWifi() &&
wifiManager.removeOldNetwork(config) &&
wifiManager.enableNewNetwork(config)) ||
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
config is WifiNetworkSuggestion.Builder &&
wifiManager.addNetworkFromBuilder(config))
) {
R.string.wifi_added
} else {
R.string.wifi_config_failed
}
return (config is WifiConfiguration &&
wifiManager.enableWifi() &&
wifiManager.removeOldNetwork(config) &&
wifiManager.enableNewNetwork(config)) ||
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
config is WifiNetworkSuggestion.Builder &&
wifiManager.addNetworkFromBuilder(config))
}
internal fun parseMap(input: String): Map<String, String>? {
@@ -93,7 +90,7 @@ object WifiConnector {
input.trimAndTerminate()
)?.groupValues?.get(1)?.let { pairs ->
pairRegex.findAll(pairs).map { pair ->
pair.groupValues[1].uppercase(Locale.US) to pair.groupValues[2]
pair.groupValues[1].uppercase(Locale.US) to pair.groupValues[2].unescape
}.toMap()
}
}
@@ -103,47 +100,43 @@ object WifiConnector {
) {
// This should be private but because of testing it isn't possible.
internal val ssid: String
get() = inputMap.getValue("S").unescape
get() = inputMap.getValue("S")
internal val securityType: String
get() = inputMap["T"]?.unescape ?: ""
get() = inputMap["T"] ?: ""
internal val password: String?
get() = inputMap["P"]?.unescape
get() = inputMap["P"]
internal val hidden: Boolean
get() = inputMap["H"]?.unescape == "true"
get() = inputMap["H"] == "true"
internal val anonymousIdentity: String
get() = inputMap["AI"]?.unescape ?: inputMap["A"]?.unescape ?: ""
get() = inputMap["AI"] ?: inputMap["A"] ?: ""
internal val identity: String
get() = inputMap["I"]?.unescape ?: ""
get() = inputMap["I"] ?: ""
internal val eapMethod: Int?
get() = if (inputMap["E"].isNullOrEmpty()) {
requireSdk(Build.VERSION_CODES.JELLY_BEAN_MR2) {
WifiEnterpriseConfig.Eap.NONE
}
WifiEnterpriseConfig.Eap.NONE
} else when (inputMap["E"]?.uppercase()) {
"AKA" -> requireSdk(Build.VERSION_CODES.LOLLIPOP) { WifiEnterpriseConfig.Eap.AKA }
"AKA" -> WifiEnterpriseConfig.Eap.AKA
"AKA_PRIME" -> requireSdk(Build.VERSION_CODES.M) { WifiEnterpriseConfig.Eap.AKA_PRIME }
"NONE" -> requireSdk(Build.VERSION_CODES.JELLY_BEAN_MR2) { WifiEnterpriseConfig.Eap.NONE }
"PEAP" -> requireSdk(Build.VERSION_CODES.JELLY_BEAN_MR2) { WifiEnterpriseConfig.Eap.PEAP }
"PWD" -> requireSdk(Build.VERSION_CODES.JELLY_BEAN_MR2) { WifiEnterpriseConfig.Eap.PWD }
"SIM" -> requireSdk(Build.VERSION_CODES.LOLLIPOP) { WifiEnterpriseConfig.Eap.SIM }
"TLS" -> requireSdk(Build.VERSION_CODES.JELLY_BEAN_MR2) { WifiEnterpriseConfig.Eap.TLS }
"TTLS" -> requireSdk(Build.VERSION_CODES.JELLY_BEAN_MR2) { WifiEnterpriseConfig.Eap.TTLS }
"NONE" -> WifiEnterpriseConfig.Eap.NONE
"PEAP" -> WifiEnterpriseConfig.Eap.PEAP
"PWD" -> WifiEnterpriseConfig.Eap.PWD
"SIM" -> WifiEnterpriseConfig.Eap.SIM
"TLS" -> WifiEnterpriseConfig.Eap.TLS
"TTLS" -> WifiEnterpriseConfig.Eap.TTLS
"UNAUTH_TLS" -> requireSdk(Build.VERSION_CODES.N) { WifiEnterpriseConfig.Eap.UNAUTH_TLS }
else -> null
}
internal val phase2Method: Int?
get() = if (inputMap["PH2"].isNullOrEmpty()) {
requireSdk(Build.VERSION_CODES.JELLY_BEAN_MR2) {
WifiEnterpriseConfig.Phase2.NONE
}
WifiEnterpriseConfig.Phase2.NONE
} else when (inputMap["PH2"]?.uppercase()) {
"AKA" -> requireSdk(Build.VERSION_CODES.O) { WifiEnterpriseConfig.Phase2.AKA }
"AKA_PRIME" -> requireSdk(Build.VERSION_CODES.O) { WifiEnterpriseConfig.Phase2.AKA_PRIME }
"GTC" -> requireSdk(Build.VERSION_CODES.JELLY_BEAN_MR2) { WifiEnterpriseConfig.Phase2.GTC }
"MSCHAP" -> requireSdk(Build.VERSION_CODES.JELLY_BEAN_MR2) { WifiEnterpriseConfig.Phase2.MSCHAP }
"MSCHAPV2" -> requireSdk(Build.VERSION_CODES.JELLY_BEAN_MR2) { WifiEnterpriseConfig.Phase2.MSCHAPV2 }
"NONE" -> requireSdk(Build.VERSION_CODES.JELLY_BEAN_MR2) { WifiEnterpriseConfig.Phase2.NONE }
"PAP" -> requireSdk(Build.VERSION_CODES.JELLY_BEAN_MR2) { WifiEnterpriseConfig.Phase2.PAP }
"GTC" -> WifiEnterpriseConfig.Phase2.GTC
"MSCHAP" -> WifiEnterpriseConfig.Phase2.MSCHAP
"MSCHAPV2" -> WifiEnterpriseConfig.Phase2.MSCHAPV2
"NONE" -> WifiEnterpriseConfig.Phase2.NONE
"PAP" -> WifiEnterpriseConfig.Phase2.PAP
"SIM" -> requireSdk(Build.VERSION_CODES.O) { WifiEnterpriseConfig.Phase2.SIM }
else -> null
}
@@ -201,7 +194,7 @@ object WifiConnector {
})
}
}
} catch (e: IllegalArgumentException) {
} catch (_: IllegalArgumentException) {
// Don't accept passphrases that aren't ASCII encodeable.
return null
}
@@ -268,7 +261,7 @@ object WifiConnector {
allowedGroupCiphers.set(WifiConfiguration.GroupCipher.CCMP)
}
"WPA2-EAP" -> requireSdk(Build.VERSION_CODES.JELLY_BEAN_MR2) {
"WPA2-EAP" -> {
passwordWithQuotes?.let {
preSharedKey = it
} ?: return null
@@ -288,7 +281,7 @@ object WifiConnector {
data.eapMethod ?: return null // non valid eapMethod
enterpriseConfig.phase2Method =
data.phase2Method ?: return null // non valid phase2Method
} ?: return null // api isn't high enough
}
}
return this
}
@@ -313,6 +306,21 @@ private fun WifiManager.addNetworkFromBuilder(
return result == WifiManager.STATUS_NETWORK_SUGGESTIONS_SUCCESS
}
@RequiresApi(Build.VERSION_CODES.R)
private fun Context.suggestNetworkFromBuilder(
builder: WifiNetworkSuggestion.Builder
) {
val suggestion = builder.build()
startActivity(
Intent(Settings.ACTION_WIFI_ADD_NETWORKS).apply {
putParcelableArrayListExtra(
Settings.EXTRA_WIFI_NETWORK_LIST,
arrayListOf(suggestion)
)
}
)
}
// Make sure the string ends with a semicolon.
private fun String.trimAndTerminate(): String {
var trimmed = trim()
@@ -366,7 +374,7 @@ private fun WifiManager.removeOldNetwork(
}?.networkId?.also {
removeNetwork(it)
}
} catch (e: SecurityException) {
} catch (_: SecurityException) {
// The user didn't allow ACCESS_FINE_LOCATION which is
// required to access configuredNetworks and that's fine.
}
@@ -0,0 +1,65 @@
package de.markusfisch.android.binaryeye.activity
import android.content.Context
import android.content.pm.PackageManager
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.app.PERMISSION_LOCATION
import de.markusfisch.android.binaryeye.app.PERMISSION_WRITE
import de.markusfisch.android.binaryeye.app.applyLocale
import de.markusfisch.android.binaryeye.app.permissionGrantedCallback
import de.markusfisch.android.binaryeye.app.prefs
import de.markusfisch.android.binaryeye.view.initBars
abstract class AbstractBaseActivity : AppCompatActivity() {
override fun attachBaseContext(base: Context?) {
base?.applyLocale(prefs.customLocale)
super.attachBaseContext(base)
}
protected fun setScreenContentView(@LayoutRes layoutId: Int) {
setContentView(layoutId)
initBars()
supportActionBar?.setDisplayHomeAsUpEnabled(true)
}
override fun onSupportNavigateUp(): Boolean {
finish()
return true
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(
requestCode,
permissions,
grantResults
)
when (requestCode) {
PERMISSION_LOCATION, PERMISSION_WRITE -> {
if (grantResults.isNotEmpty() &&
grantResults[0] == PackageManager.PERMISSION_GRANTED
) {
permissionGrantedCallback?.invoke()
permissionGrantedCallback = null
}
}
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
onCreateOptionsMenu(menu, menuInflater)
return menu.isNotEmpty() || super.onCreateOptionsMenu(menu)
}
protected open fun onCreateOptionsMenu(
menu: Menu,
inflater: MenuInflater
) = Unit
}
@@ -0,0 +1,305 @@
package de.markusfisch.android.binaryeye.activity
import android.app.Dialog
import android.content.Context
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.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 : 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)
actions.clear()
actions.addAll(prefs.automatedActions)
val listView = findViewById<ListView>(R.id.actions)
listView.emptyView = findViewById(R.id.no_actions)
adapter = ActionsAdapter(this, actions)
listView.adapter = adapter
listView.setOnScrollListener(systemBarListViewScrollListener)
listView.setOnItemClickListener { _, _, position, _ ->
editAction(position)
}
listView.setOnItemLongClickListener { _, _, position, _ ->
confirmRemoveAction(position)
true
}
findViewById<View>(R.id.add).setOnClickListener {
editAction(null)
}
findViewById<View>(R.id.inset_layout).setPaddingFromWindowInsets(bottom = false)
listView.setPaddingFromWindowInsets()
}
private fun editAction(position: Int?) {
val action = position?.let { actions[it] }
val view = layoutInflater.inflate(
R.layout.dialog_automated_action,
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)
val intentSection = view.findViewById<View>(R.id.intent_section)
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 ?: "")
}
AutomatedAction.Type.CustomIntent -> {
intentUriView.setText(action.intentUriTemplate ?: "")
}
}
} else {
formatView.setSelection(0)
}
fun updateTypeSections(checkedId: Int) {
val isIntent = checkedId == R.id.action_type_intent
urlSection.visibility = if (isIntent) View.VISIBLE else View.GONE
intentSection.visibility = if (isIntent) View.GONE else View.VISIBLE
}
typeGroup.setOnCheckedChangeListener { _, checkedId ->
updateTypeSections(checkedId)
}
val initialTypeId = if (
action?.type == AutomatedAction.Type.CustomIntent
) {
R.id.action_type_custom_intent
} else {
R.id.action_type_intent
}
typeGroup.check(initialTypeId)
updateTypeSections(initialTypeId)
val dialog = MaterialAlertDialogBuilder(this)
.setTitle(
if (action == null) {
R.string.automated_action_add
} else {
R.string.automated_action_edit
}
)
.setView(view)
.setPositiveButton(android.R.string.ok, null)
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.create()
dialog.setOnShowListener {
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
addOrUpdateAction(
position,
regexView,
formatView,
values,
urlView,
typeGroup,
intentUriView,
dialog
)
}
}
dialog.show()
}
private fun addOrUpdateAction(
position: Int?,
regexView: EditText,
formatView: Spinner,
values: List<String>,
urlView: EditText,
typeGroup: RadioGroup,
intentUriView: EditText,
dialog: Dialog,
) {
val pattern = regexView.text.toString().trim()
if (pattern.isEmpty()) {
regexView.error = getString(R.string.automated_action_regex_required)
return
}
try {
Regex(pattern)
} catch (_: Exception) {
regexView.error = getString(R.string.automated_action_regex_invalid)
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()) {
urlView.error = getString(R.string.automated_action_url_required)
return
}
AutomatedAction(
pattern = pattern,
type = AutomatedAction.Type.Intent,
format = format,
urlTemplate = urlTemplate
)
} else {
val intentUriTemplate = intentUriView.text.toString().trim()
if (intentUriTemplate.isEmpty()) {
intentUriView.error = getString(
R.string.automated_action_intent_uri_required
)
return
}
if (!intentUriTemplate.startsWith(
"intent:",
ignoreCase = true
)
) {
intentUriView.error = getString(
R.string.automated_action_intent_uri_invalid
)
return
}
AutomatedAction(
pattern = pattern,
type = AutomatedAction.Type.CustomIntent,
format = format,
intentUriTemplate = intentUriTemplate
)
}
if (position == null) {
actions.add(newAction)
} else {
actions[position] = newAction
}
prefs.setAutomatedActions(actions)
adapter.notifyDataSetChanged()
dialog.dismiss()
}
private fun confirmRemoveAction(position: Int) {
MaterialAlertDialogBuilder(this)
.setMessage(R.string.automated_action_remove_confirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
actions.removeAt(position)
prefs.setAutomatedActions(actions)
adapter.notifyDataSetChanged()
}
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.show()
}
private class ActionsAdapter(
context: Context,
private val items: List<AutomatedAction>
) : ArrayAdapter<AutomatedAction>(
context,
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 action = items[position]
getViewHolder(view).apply {
title.text = action.pattern
subtitle.text = when (action.type) {
AutomatedAction.Type.Intent -> if (!action.urlTemplate.isNullOrEmpty()) {
context.getString(
R.string.automated_action_list_intent,
action.urlTemplate
)
} else {
context.getString(R.string.automated_action_list_intent_empty)
}
AutomatedAction.Type.CustomIntent ->
if (!action.intentUriTemplate.isNullOrEmpty()) {
context.getString(
R.string.automated_action_list_custom_intent,
action.intentUriTemplate
)
} else {
context.getString(
R.string.automated_action_list_custom_intent_empty
)
}
}
action.format?.let { format ->
subtitle.text = "${subtitle.text} (${format.prettifyFormatName()})"
}
}
return view
}
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
}
private data class ViewHolder(
val title: TextView,
val subtitle: TextView,
)
}
}
@@ -0,0 +1,594 @@
package de.markusfisch.android.binaryeye.activity
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Matrix
import android.os.Build
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
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
import de.markusfisch.android.binaryeye.app.prefs
import de.markusfisch.android.binaryeye.content.Barcode
import de.markusfisch.android.binaryeye.content.BarcodeColors
import de.markusfisch.android.binaryeye.content.BitMatrixBarcode
import de.markusfisch.android.binaryeye.content.ContentBarcode
import de.markusfisch.android.binaryeye.content.copyToClipboard
import de.markusfisch.android.binaryeye.content.shareFile
import de.markusfisch.android.binaryeye.content.shareText
import de.markusfisch.android.binaryeye.database.toScan
import de.markusfisch.android.binaryeye.io.addSuffixIfNotGiven
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.getScreenBrightness
import de.markusfisch.android.binaryeye.os.setScreenBrightness
import de.markusfisch.android.binaryeye.view.doOnApplyWindowInsets
import de.markusfisch.android.binaryeye.view.setPaddingFromWindowInsets
import de.markusfisch.android.binaryeye.widget.ConfinedScalingImageView
import de.markusfisch.android.binaryeye.widget.toast
import de.markusfisch.android.zxingcpp.ZxingCpp
import de.markusfisch.android.zxingcpp.ZxingCpp.BarcodeFormat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.OutputStream
import java.util.Locale
import kotlin.math.max
import kotlin.math.min
class BarcodeActivity : AbstractBaseActivity() {
private enum class FileType {
PNG, JPG, SVG, TXT
}
private val parentJob = Job()
private val scope = CoroutineScope(Dispatchers.IO + parentJob)
private lateinit var imageView: ConfinedScalingImageView
private lateinit var barcode: Barcode<*>
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)
contentView = findViewById(android.R.id.content)
// Make `imageView` available for `onPause()`.
imageView = findViewById(R.id.barcode)
val bitmap: Bitmap
try {
barcode = intent?.extras?.toBarcode() ?: throw IllegalArgumentException(
"Illegal arguments"
)
// Catch exceptions from encoding.
bitmap = barcode.bitmap()
} catch (e: Exception) {
var message = e.message
if (message.isNullOrEmpty()) {
message = getString(R.string.error_encoding_barcode)
}
toast(message)
finish()
return
}
imageView.setImageBitmap(bitmap)
imageView.runAfterLayout = {
val scale = prefs.previewScale
if (scale > 0) {
imageView.setScale(
scale,
bitmap.width / 2f,
bitmap.height / 2f
)
}
imageView.minWidth = min(
imageView.minWidth / 2f,
max(bitmap.width, bitmap.height).toFloat()
)
}
findViewById<View>(R.id.share).setOnClickListener {
it.context?.apply {
pickFileType(R.string.share_as) { fileType ->
shareAs(fileType)
}
}
}
findViewById<View>(R.id.inset_layout).setPaddingFromWindowInsets(bottom = false)
imageView.doOnApplyWindowInsets { _, insets, _ ->
imageView.insets.set(insets)
}
}
private fun Bundle.toBarcode(): Barcode<*> {
val content =
getString(CONTENT_TEXT) ?: getByteArray(CONTENT_RAW) ?: throw IllegalArgumentException(
"content cannot be null"
)
val barcodeFormat = BarcodeFormat.valueOf(
getString(FORMAT) ?: throw IllegalArgumentException(
"format cannot be null"
)
)
val colors = BarcodeColors.entries[getInt(COLORS)]
val data = getByteArray(BIT_MATRIX_DATA)
return if (data != null) {
BitMatrixBarcode(
ZxingCpp.BitMatrix(
getInt(BIT_MATRIX_WIDTH),
getInt(BIT_MATRIX_HEIGHT),
data
),
content,
barcodeFormat,
colors
)
} else {
ContentBarcode(
content,
barcodeFormat,
getInt(EC_LEVEL),
getBoolean(ADD_QUIET_ZONE),
colors
)
}
}
override fun onResume() {
super.onResume()
if (prefs.brightenScreen) {
// Post to make sure brightenScreenItem is initialized.
contentView?.postDelayed({
brightenScreen()
}, 500)
}
}
override fun onPause() {
super.onPause()
if (currentBrightness > -1f) {
restoreScreenBrightness()
}
if (imageView.isInBounds()) {
prefs.previewScale = imageView.getScale()
}
}
override fun onDestroy() {
super.onDestroy()
parentJob.cancel()
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.fragment_barcode, menu)
addToHistoryItem = menu.findItem(R.id.add_to_history)
brightenScreenItem = menu.findItem(R.id.brighten_screen)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.add_to_history -> {
readAndAddToHistory(
barcode.bitmap(),
barcode.format
)
addToHistoryItem?.isVisible = false
toast(R.string.added_to_history)
true
}
R.id.copy_to_clipboard -> {
copyToClipboard(barcode.text())
true
}
R.id.copy_as_deeplink -> {
copyToClipboard(deeplinkToCopy())
true
}
R.id.export_to_file -> {
pickFileType(R.string.export_as) { fileType ->
when (fileType) {
FileType.PNG,
FileType.JPG -> askForSize { size ->
askForFileNameAndSave(fileType, size)
}
else -> askForFileNameAndSave(fileType, 0)
}
}
true
}
R.id.brighten_screen -> {
prefs.brightenScreen = if (currentBrightness > -1f) {
restoreScreenBrightness()
false
} else {
brightenScreen()
true
}
true
}
else -> super.onOptionsItemSelected(item)
}
}
private fun copyToClipboard(text: String, isSensitive: Boolean = false) {
(this as android.content.Context).copyToClipboard(text, isSensitive)
toast(R.string.copied_to_clipboard)
}
private fun deeplinkToCopy() = createEncodeDeeplink(
format = barcode.format.name,
content = barcode.textOrHex(),
)
private fun Context.pickFileType(
title: Int,
action: (FileType) -> Unit
) {
val fileTypes = FileType.entries.toTypedArray()
MaterialAlertDialogBuilder(this)
.setTitle(title)
.setItems(fileTypes.map { it.name }.toTypedArray()) { _, which ->
action(fileTypes[which])
}
.show()
}
// Dialogs do not have a parent view.
@SuppressLint("InflateParams")
private fun askForFileNameAndSave(fileType: FileType, bitmapSize: Int) {
// Write permission is only required before Android Q.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q &&
!hasWritePermission {
askForFileNameAndSave(fileType, bitmapSize)
}
) {
return
}
val view = layoutInflater.inflate(R.layout.dialog_save_file, null)
val editText = view.findViewById<EditText>(R.id.file_name)
editText.setText(
encodeFileName("${barcode.format}_${barcode.content}")
)
MaterialAlertDialogBuilder(this)
.setView(view)
.setPositiveButton(android.R.string.ok) { _, _ ->
val fileName = editText.text.toString()
when (fileType) {
FileType.PNG -> saveAs(
addSuffixIfNotGiven(fileName, ".png"),
MIME_PNG
) {
barcode.bitmap(bitmapSize).saveAsPng(it)
}
FileType.JPG -> saveAs(
addSuffixIfNotGiven(fileName, ".jpg"),
MIME_JPG
) {
barcode.bitmap(bitmapSize).saveAsJpg(it)
}
FileType.SVG -> saveAs(
addSuffixIfNotGiven(fileName, ".svg"),
MIME_SVG
) {
it.write(barcode.svg().toByteArray())
}
FileType.TXT -> saveAs(
addSuffixIfNotGiven(fileName, ".txt"),
MIME_TXT
) {
it.write(barcode.text().toByteArray())
}
}
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
}
.show()
}
// Dialogs do not have a parent view.
@SuppressLint("InflateParams")
private fun askForSize(write: (size: Int) -> Unit) {
val view = layoutInflater.inflate(R.layout.dialog_size, null)
val sizeView = view.findViewById<TextView>(R.id.size_display)
val sizeBarView = view.findViewById<SeekBar>(R.id.size_bar)
sizeBarView.initSizeBar(sizeView)
MaterialAlertDialogBuilder(this)
.setView(view)
.setPositiveButton(android.R.string.ok) { _, _ ->
write(getSize(sizeBarView.progress))
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
}
.show()
}
private fun SeekBar.initSizeBar(sizeView: TextView) {
sizeView.updateSize(progress)
setOnSeekBarChangeListener(
object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(
seekBar: SeekBar,
progressValue: Int,
fromUser: Boolean
) {
sizeView.updateSize(progressValue)
}
override fun onStartTrackingTouch(seekBar: SeekBar) {}
override fun onStopTrackingTouch(seekBar: SeekBar) {}
}
)
}
private fun TextView.updateSize(power: Int) {
val size = getSize(power)
text = if (size > 0) {
getString(R.string.size_width_by_height, size, size)
} else {
getString(R.string.size_no_magnification)
}
}
private fun saveAs(
fileName: String,
mimeType: String,
write: (outputStream: OutputStream) -> Unit
) {
scope.launch(Dispatchers.IO) {
val message = writeExternalFile(
fileName, mimeType, write
).toSaveResult()
withContext(Dispatchers.Main) {
toast(message)
}
}
}
private fun Context.shareAs(fileType: FileType) {
when (fileType) {
FileType.PNG -> askForSize {
share(barcode.bitmap(it), MIME_PNG, "png")
}
FileType.JPG -> askForSize {
share(barcode.bitmap(it), MIME_JPG, "jpg")
}
FileType.SVG -> shareText(barcode.svg(), MIME_SVG)
FileType.TXT -> shareText(barcode.text())
}
}
private fun Context.share(bitmap: Bitmap, mimeType: String, ext: String) {
scope.launch(Dispatchers.IO) {
val file = File(
externalCacheDir,
"shared_barcode.$ext"
)
val success = try {
FileOutputStream(file).use {
when (mimeType) {
MIME_PNG -> bitmap.saveAsPng(it)
MIME_JPG -> bitmap.saveAsJpg(it)
else -> throw IllegalArgumentException(
"Invalid mime type"
)
}
}
true
} catch (_: IOException) {
false
}
withContext(Dispatchers.Main) {
if (success) {
shareFile(file, mimeType)
} else {
toast(R.string.error_saving_file)
}
}
}
}
private fun brightenScreen() {
if (isFinishing || isDestroyed) {
return
}
currentBrightness = getScreenBrightness()
setScreenBrightness(1f)
brightenScreenItem?.isChecked = true
}
private fun restoreScreenBrightness() {
if (currentBrightness > -1f) {
setScreenBrightness(currentBrightness)
currentBrightness = -1f
brightenScreenItem?.isChecked = false
}
}
companion object {
private const val BIT_MATRIX_WIDTH = "bit_matrix_width"
private const val BIT_MATRIX_HEIGHT = "bit_matrix_height"
private const val BIT_MATRIX_DATA = "bit_matrix_data"
private const val CONTENT_TEXT = "content_text"
private const val CONTENT_RAW = "content_raw"
private const val FORMAT = "format"
private const val EC_LEVEL = "ec_level"
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"
private const val MIME_SVG = "image/svg+xmg"
private const val MIME_TXT = "text/plain"
fun newIntent(
context: Context,
barcode: Barcode<*>,
colors: Int = 0
): Intent {
val args = Bundle()
when (barcode) {
is ContentBarcode -> args.putEcLevelQuietZone(
barcode.ecLevel,
barcode.addQuietZone
)
is BitMatrixBarcode -> {
args.putInt(BIT_MATRIX_WIDTH, barcode.bitMatrix.width)
args.putInt(BIT_MATRIX_HEIGHT, barcode.bitMatrix.height)
args.putByteArray(BIT_MATRIX_DATA, barcode.bitMatrix.data)
}
}
args.putContentFormatColors(
barcode.content,
barcode.format,
colors
)
return Intent(context, BarcodeActivity::class.java).apply {
putExtras(args)
}
}
fun <T> newIntent(
context: Context,
content: T,
format: BarcodeFormat,
ecLevel: Int = -1,
addQuietZone: Boolean = false,
colors: Int = 0
): Intent {
val args = Bundle()
args.putContentFormatColors(content, format, colors)
args.putEcLevelQuietZone(ecLevel, addQuietZone)
return Intent(context, BarcodeActivity::class.java).apply {
putExtras(args)
}
}
private fun <T> Bundle.putContentFormatColors(
content: T,
format: BarcodeFormat,
colors: Int
) {
when (content) {
is String -> {
putString(CONTENT_TEXT, content)
}
is ByteArray -> {
putByteArray(CONTENT_RAW, content)
}
else -> {
throw IllegalArgumentException(
"content must be a String of a ByteArray"
)
}
}
putString(FORMAT, format.name)
putInt(COLORS, colors)
}
private fun Bundle.putEcLevelQuietZone(
ecLevel: Int,
addQuietZone: Boolean
) {
putInt(EC_LEVEL, ecLevel)
putBoolean(ADD_QUIET_ZONE, addQuietZone)
}
}
}
private fun ImageView.setScale(scale: Float, x: Float, y: Float) {
imageMatrix = Matrix().apply {
setTranslate(-x, -y)
postScale(scale, scale)
postTranslate(x, y)
}
}
private fun ImageView.getScale(): Float {
val values = FloatArray(9)
imageMatrix.getValues(values)
return values[Matrix.MSCALE_X]
}
private fun Bitmap.saveAsPng(outputStream: OutputStream, quality: Int = 90) =
compress(Bitmap.CompressFormat.PNG, quality, outputStream)
private fun Bitmap.saveAsJpg(outputStream: OutputStream, quality: Int = 90) =
compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
private val fileNameCharacters = "[^A-Za-z0-9]".toRegex()
private fun encodeFileName(name: String): String = fileNameCharacters
.replace(name, "_")
.take(16)
.trim('_')
.lowercase(Locale.getDefault())
private fun getSize(step: Int) = when (step) {
0 -> 0
else -> 128 shl (step - 1)
}
private fun readAndAddToHistory(
bitmap: Bitmap,
format: BarcodeFormat
) {
ZxingCpp.readBitmap(
bitmap,
0, 0,
bitmap.width, bitmap.height,
0,
ZxingCpp.ReaderOptions(
tryHarder = true,
tryRotate = true,
tryInvert = true,
tryDownscale = true,
maxNumberOfSymbols = 1,
formats = setOf(format)
)
)?.first {
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
@@ -0,0 +1,904 @@
package de.markusfisch.android.binaryeye.activity
import android.app.Activity
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 android.text.Editable
import android.text.Spannable
import android.text.SpannableString
import android.text.TextWatcher
import android.text.style.TypefaceSpan
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
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.wifi.WifiAction
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.ParsedContentType
import de.markusfisch.android.binaryeye.content.copyToClipboard
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
import de.markusfisch.android.binaryeye.content.toErrorCorrectionInt
import de.markusfisch.android.binaryeye.content.wipeLastShareFile
import de.markusfisch.android.binaryeye.database.Scan
import de.markusfisch.android.binaryeye.database.toBundle
import de.markusfisch.android.binaryeye.database.toScan
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
import de.markusfisch.android.zxingcpp.ZxingCpp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
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 : AbstractBaseActivity() {
private lateinit var labelHeadlineView: TextView
private lateinit var contentView: EditText
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 formatDescriptionView: TextView
private lateinit var recreationView: ImageView
private lateinit var labelView: EditText
private lateinit var fab: FloatingActionButton
private lateinit var scan: Scan
private lateinit var format: String
private val parentJob = Job()
private val scope: CoroutineScope = CoroutineScope(
Dispatchers.Main + parentJob
)
private val dp: Float
get() = resources.displayMetrics.density
private val content: String
get() = contentView.text.toString()
private var closeAutomatically = false
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
override fun onActivityResult(
requestCode: Int,
resultCode: Int,
resultData: Intent?
) {
super.onActivityResult(requestCode, resultCode, resultData)
when (requestCode) {
OPEN_DOCUMENT -> {
if (resultCode == Activity.RESULT_OK) {
val uri = resultData?.data ?: return
openPickedFile(uri)
}
}
}
}
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 justScanned = intent.hasExtra(DECODED) || intent.hasExtra(DECODED_SCAN)
closeAutomatically = prefs.closeAutomatically && justScanned
scan = intent?.extras?.getBundle(SCAN_BUNDLE)?.toScan()
?: throw IllegalArgumentException("DecodeActivity needs a Scan")
isBinary = scan.text.isEmpty()
originalBytes = scan.raw ?: scan.text.toByteArray()
format = scan.format.name
recreationSize = (200f * dp).roundToInt()
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
}
findViewById<View>(R.id.inset_layout).setPaddingFromWindowInsets(bottom = false)
findViewById<View>(R.id.scroll_view).setPaddingFromWindowInsets()
initContentAndFab(justScanned)
updateViewsAndFab(scan.text, originalBytes)
}
override fun onPause() {
super.onPause()
val id = scan.id
if (id > 0) {
val newLabel = labelView.text.toString().trim()
if (newLabel != (label ?: "")) {
db.renameScan(id, newLabel)
label = newLabel
}
}
if (action.executed) {
maybeBackOrFinish()
}
}
override fun onDestroy() {
super.onDestroy()
parentJob.cancel()
wipeLastShareFile()
}
private fun initContentAndFab(justScanned: Boolean) {
if (isBinary) {
contentView.setText(String(originalBytes).foldNonAlNum())
contentView.isEnabled = false
fab.setImageResource(R.drawable.ic_action_save)
fab.setOnClickListener {
askForFileNameAndSave(originalBytes)
}
} else {
contentView.setText(scan.text)
contentView.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) {
updateViewsAndFab(s?.toString() ?: "")
}
override fun beforeTextChanged(
s: CharSequence?,
start: Int,
count: Int,
after: Int
) = Unit
override fun onTextChanged(
s: CharSequence?,
start: Int,
before: Int,
count: Int
) = Unit
})
fab.setOnClickListener {
executeAction(content)
}
if (justScanned && prefs.openImmediately) {
closeAutomatically = true
executeAction(content)
}
}
}
private fun updateViewsAndFab(text: String, bytes: ByteArray? = null) {
val b = bytes ?: text.toByteArray()
resolveActionAndUpdateFab(b)
updateViews(text, b)
}
private fun resolveActionAndUpdateFab(bytes: ByteArray) {
if (!isBinary) {
val prevAction = action
if (!prevAction.canExecuteOn(bytes)) {
action = ActionRegistry.getAction(bytes)
}
if (prevAction !== action) {
fab.setImageResource(action.iconResId)
}
}
// Run this for the initial action too.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
fab.setOnLongClickListener { v ->
v.context.toast(action.titleResId)
true
}
} else {
fab.tooltipText = getString(action.titleResId)
}
}
private fun updateViews(text: String, bytes: ByteArray) {
dataView.fillDataView(text, bytes, text != scan.text)
characterCountView.text = resources.getQuantityString(
R.plurals.character_count,
bytes.size,
bytes.size
)
recreationView.showIf(prefs.showRecreation) { v ->
val unmodified: Boolean
val content = if (isBinary) {
if (bytes.isEmpty()) {
clearRecreation()
return@showIf
}
unmodified = bytes.contentEquals(originalBytes)
bytes
} else {
if (text.isEmpty()) {
clearRecreation()
return@showIf
}
unmodified = text == scan.text
text
}
val barcode = if (unmodified) {
scan.toBarcode()
} else {
ContentBarcode(
content,
scan.format,
scan.errorCorrectionLevel.toErrorCorrectionInt(),
addQuietZone = true
)
}
try {
v.setImageBitmap(barcode.bitmap(recreationSize))
v.setOnClickListener {
v.context.hideSoftKeyboard(contentView)
v.post {
startActivity(
BarcodeActivity.newIntent(this, barcode)
)
}
}
} catch (_: RuntimeException) {
clearRecreation()
}
}
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() {
recreationView.apply {
setImageBitmap(null)
setOnClickListener(null)
}
}
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.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())
}
if (parsedData.type != ParsedContentType.UNKNOWN) {
items.add(
0,
Field(
R.string.parsed_type,
getString(parsedData.type.resId)
)
)
lastParsedDataItems = items
fillDataItems(items, false)
return
}
if (isEditing && lastParsedDataItems.isNotEmpty()) {
if (!showingDimmed) {
fillDataItems(lastParsedDataItems, true)
}
return
}
fillDataItems(items, false)
}
private fun TableLayout.fillMetaView(scan: Scan) {
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) {
getString(
R.string.qr_version_and_modules,
scan.version,
(scan.version.toIntOrNull() ?: 0) * 4 + 17
)
} else {
scan.version
}
items.addField(R.string.barcode_version_number, versionString)
}
if (scan.format == ZxingCpp.BarcodeFormat.QRCode &&
scan.dataMask > -1
) {
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)
}
}
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
val tr = TableRow(ctx)
val keyView = TextView(ctx).apply {
when (val key = item.key) {
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)
}
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) {
menu.findItem(R.id.remove).isVisible = true
}
if (action is WifiAction) {
menu.findItem(R.id.copy_password).isVisible = true
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.copy_password -> {
copyPasswordToClipboard()
maybeBackOrFinish()
true
}
R.id.copy_to_clipboard -> {
copyToClipboard(textOrHex())
maybeBackOrFinish()
true
}
R.id.copy_as_deeplink -> {
copyToClipboard(deeplinkToCopy())
maybeBackOrFinish()
true
}
R.id.share -> {
shareText(textOrHex())
maybeBackOrFinish()
true
}
R.id.share_file -> {
val (data, name) = if (isBinary) {
Pair(originalBytes, "barcode_content.bin")
} else {
Pair(content, "barcode_content.txt")
}
shareAsFile(data, name)
maybeBackOrFinish()
true
}
R.id.create -> {
startActivity(
EncodeActivity.newIntent(
this,
if (isBinary) originalBytes else content,
format
)
)
true
}
R.id.remove -> {
askToRemoveScan(scan.id)
true
}
else -> super.onOptionsItemSelected(item)
}
}
private fun askToRemoveScan(id: Long) {
MaterialAlertDialogBuilder(this)
.setMessage(R.string.really_remove_scan)
.setPositiveButton(android.R.string.ok) { _, _ ->
db.removeScan(id)
backOrFinish()
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
}
.show()
}
private fun textOrHex() = if (isBinary) {
originalBytes.toHexString()
} else {
content
}
private fun deeplinkToCopy() = createEncodeDeeplink(
format = format,
content = textOrHex(),
)
private fun copyPasswordToClipboard() {
val ac = action
if (ac is WifiAction) {
ac.password?.let { password ->
copyToClipboard(password, isSensitive = true)
}
}
}
private fun copyToClipboard(text: String, isSensitive: Boolean = false) {
(this as android.content.Context).copyToClipboard(text, isSensitive)
// There's a clipboard popup from Android 13 on.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
toast(R.string.copied_to_clipboard)
}
}
private fun maybeBackOrFinish() {
if (closeAutomatically) {
backOrFinish()
}
}
private fun backOrFinish() {
finish()
}
private fun executeAction(str: String) {
if (str.isEmpty() || openLocalDocument(str)) {
return
}
if (action is WifiAction &&
Build.VERSION.SDK_INT < Build.VERSION_CODES.Q &&
!hasLocationPermission { executeAction(str) }
) {
return
}
scope.launch {
action.execute(this@DecodeActivity, str.toByteArray())
}
}
private fun openLocalDocument(str: String): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N ||
!str.startsWith(SCHEME_FILE)
) {
return false
}
// Needs to be disabled so we get the result.
closeAutomatically = false
// file:// can't be used with Scoped Storage anymore.
startActivityForResult(
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val primary = str.substring(
SCHEME_FILE.length
).localDirName() ?: Environment.getExternalStorageDirectory().path
putExtra(
DocumentsContract.EXTRA_INITIAL_URI,
DocumentsContract.buildDocumentUri(
"com.android.externalstorage.documents",
"primary:$primary"
)
)
}
},
OPEN_DOCUMENT
)
return true
}
private fun askForFileNameAndSave(raw: ByteArray) {
// Write permission is only required before Android Q.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q &&
!hasWritePermission { askForFileNameAndSave(raw) }
) {
return
}
scope.launch(Dispatchers.Main) {
val name = askForFileName() ?: return@launch
withContext(Dispatchers.IO) {
val message = writeExternalFile(
name,
"application/octet-stream"
) {
it.write(raw)
}.toSaveResult()
withContext(Dispatchers.Main) {
toast(message)
}
}
}
}
companion object {
const val DECODED_SCAN = "decoded_scan"
const val DECODED = "decoded"
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,
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
) {
visibility = if (visible) {
block.invoke(this)
View.VISIBLE
} else {
View.GONE
}
}
private val nonAlNum = "[^a-zA-Z0-9]".toRegex()
private val multipleDots = "…+".toRegex()
private fun String.foldNonAlNum() = replace(nonAlNum, "")
.replace(multipleDots, "")
private fun hexDump(bytes: ByteArray, charsPerLine: Int = 33): String {
if (charsPerLine < 4 || bytes.isEmpty()) {
return ""
}
val dump = StringBuilder()
val hex = StringBuilder()
val ascii = StringBuilder()
val itemsPerLine = (charsPerLine - 1) / 4
val len = bytes.size
var i = 0
while (true) {
val ord = bytes[i]
hex.append(String.format("%02X ", ord))
ascii.append(if (ord > 31) ord.toInt().toChar() else " ")
++i
val posInLine = i % itemsPerLine
val atEnd = i >= len
var atLineEnd = posInLine == 0
if (atEnd && !atLineEnd) {
for (j in posInLine until itemsPerLine) {
hex.append(" ")
}
atLineEnd = true
}
if (atLineEnd) {
dump.append(hex.toString())
dump.append(" ")
dump.append(ascii.toString())
dump.append("\n")
hex.setLength(0)
ascii.setLength(0)
}
if (atEnd) {
break
}
}
return dump.toString()
}
private fun Int.positiveToString() = if (this > -1) this.toString() else ""
// 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
}
private fun ByteArray.md5(): ByteArray = MessageDigest.getInstance(
"MD5"
).run {
update(this@md5)
digest()
}
private fun ByteArray.sha1(): ByteArray = MessageDigest.getInstance(
"SHA-1"
).run {
update(this@sha1)
digest()
}
private fun ByteArray.sha256(): ByteArray = MessageDigest.getInstance(
"SHA-256"
).run {
update(this@sha256)
digest()
}
private fun String.fold(): CharSequence {
val l = length
return if (l > 10) {
val half = l / 2
val s = substring(0, half) + "\n" + substring(half)
SpannableString(s).apply {
setSpan(
TypefaceSpan("monospace"),
0, s.length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
} else {
this
}
}
private fun String.localDirName(): String? = when {
endsWith("/") -> this
else -> File(this).parentFile?.absolutePath
}?.replaceFirst(Regex("^/storage/emulated/0/"), "")
?.replaceFirst(Regex("^/sdcard/"), "")
?.trim('/')
private fun Activity.openPickedFile(uri: Uri) {
startActivity(
Intent.createChooser(
Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, contentResolver.getType(uri) ?: "*/*")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
},
getString(R.string.open_url)
)
)
}
@@ -0,0 +1,470 @@
package de.markusfisch.android.binaryeye.activity
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
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
import de.markusfisch.android.binaryeye.view.hideSoftKeyboard
import de.markusfisch.android.binaryeye.view.setPaddingFromWindowInsets
import de.markusfisch.android.binaryeye.widget.toast
import de.markusfisch.android.zxingcpp.ZxingCpp.BarcodeFormat
import java.io.InputStream
import kotlin.math.min
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
private lateinit var colorsSpinner: Spinner
private lateinit var addQuietZoneSwitch: SwitchCompat
private lateinit var contentView: EditText
private lateinit var unescapeCheckBox: CheckBox
private val writeableFormats = arrayListOf(
BarcodeFormat.Aztec,
BarcodeFormat.Codabar,
BarcodeFormat.Code39Std,
BarcodeFormat.Code39Ext,
BarcodeFormat.Code32,
BarcodeFormat.PZN,
BarcodeFormat.Code93,
BarcodeFormat.Code128,
BarcodeFormat.DataBar,
BarcodeFormat.DataBarOmni,
BarcodeFormat.DataBarStk,
BarcodeFormat.DataBarLtd,
BarcodeFormat.DataBarExp,
BarcodeFormat.DataBarExpStk,
BarcodeFormat.DataMatrix,
BarcodeFormat.DXFilmEdge,
BarcodeFormat.EAN8,
BarcodeFormat.EAN13,
BarcodeFormat.ITF,
BarcodeFormat.MaxiCode,
BarcodeFormat.PDF417,
BarcodeFormat.QRCode,
BarcodeFormat.MicroQRCode,
BarcodeFormat.RMQRCode,
BarcodeFormat.UPCA,
BarcodeFormat.UPCE,
)
private var bytes: ByteArray? = null
override fun onActivityResult(
requestCode: Int,
resultCode: Int,
resultData: Intent?
) {
super.onActivityResult(requestCode, resultCode, resultData)
when (requestCode) {
PICK_FILE_RESULT_CODE -> {
if (resultCode == Activity.RESULT_OK &&
resultData != null &&
resultData.data != null
) {
hideSoftKeyboard(contentView)
val uri = resultData.data ?: return
bytes = contentResolver?.openInputStream(uri)?.use {
it.readBytesMax(4096)
} ?: return
setEncodeByteArray()
}
}
}
}
override fun onCreate(state: Bundle?) {
super.onCreate(state)
setScreenContentView(R.layout.activity_encode)
setTitle(R.string.compose_barcode)
formatView = findViewById(R.id.format)
formatDescriptionView = findViewById(R.id.format_description)
val formatAdapter = ArrayAdapter(
this,
android.R.layout.simple_spinner_item,
writeableFormats.map { it.name.prettifyFormatName() }
)
formatAdapter.setDropDownViewResource(
android.R.layout.simple_spinner_dropdown_item
)
formatView.adapter = formatAdapter
formatView.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(
parentView: AdapterView<*>?,
selectedItemView: View?,
position: Int,
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
BarcodeFormat.MicroQRCode -> R.array.mqr_error_correction_levels
BarcodeFormat.RMQRCode -> R.array.rmqr_error_correction_levels
BarcodeFormat.PDF417 -> R.array.pdf417_error_correction_levels
else -> 0
}
if (arrayId > 0) {
ecSpinner.setEntries(arrayId)
val idx = format.unpackEcLevel(
prefs.indexOfLastSelectedEcLevel
)
if (idx < ecSpinner.adapter.count) {
ecSpinner.setSelection(idx)
}
true
} else {
false
}.setVisibilityFor(ecLabel, ecSpinner)
format.canBeInverted().setVisibilityFor(
colorsLabel, colorsSpinner
)
}
override fun onNothingSelected(parentView: AdapterView<*>?) {}
}
ecLabel = findViewById(R.id.error_correction_label)
ecSpinner = findViewById(R.id.error_correction_level)
ecSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(
parentView: AdapterView<*>?,
selectedItemView: View?,
position: Int,
id: Long
) {
val format = writeableFormats[formatView.selectedItemPosition]
prefs.indexOfLastSelectedEcLevel = format.packEcLevel(
prefs.indexOfLastSelectedEcLevel,
position
)
}
override fun onNothingSelected(parentView: AdapterView<*>?) {}
}
colorsLabel = findViewById(R.id.colors_label)
colorsSpinner = findViewById(R.id.colors)
addQuietZoneSwitch = findViewById(R.id.add_quiet_zone)
addQuietZoneSwitch.isChecked = prefs.addQuietZone
contentView = findViewById(R.id.content)
unescapeCheckBox = findViewById(R.id.unescape)
unescapeCheckBox.isChecked = prefs.expandEscapeSequences
applyIntent(state == null)
findViewById<View>(R.id.encode).setOnClickListener {
it.context.encode()
}
findViewById<View>(R.id.inset_layout).setPaddingFromWindowInsets(bottom = false)
findViewById<View>(R.id.scroll_view).setPaddingFromWindowInsets()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
applyIntent(true)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.fragment_encode, menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.pick_file -> {
startActivityForResult(
Intent.createChooser(
Intent(Intent.ACTION_GET_CONTENT).apply {
type = "*/*"
},
getString(R.string.pick_file)
),
PICK_FILE_RESULT_CODE
)
true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onPause() {
super.onPause()
prefs.indexOfLastSelectedFormat = formatView.selectedItemPosition
prefs.expandEscapeSequences = unescapeCheckBox.isChecked
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
contentView.post {
startActivity(newBarcodeIntent(content))
}
}
private fun newBarcodeIntent(content: Any): Intent {
val format = writeableFormats[formatView.selectedItemPosition]
return BarcodeActivity.newIntent(
this,
content,
format,
format.getErrorCorrectionLevel(ecSpinner.selectedItemPosition),
addQuietZoneSwitch.isChecked,
if (format.canBeInverted()) {
colorsSpinner.selectedItemPosition
} else 0
)
}
private fun Context.getContent(): Any? {
if (bytes != null) {
return bytes
}
var text = contentView.text.toString()
if (text.isEmpty()) {
toast(R.string.error_no_content)
return null
}
if (!unescapeCheckBox.isChecked) {
return text
}
try {
text = text.unescape()
} catch (e: IllegalArgumentException) {
toast(e.message ?: "Invalid escape sequence")
return null
}
// Return a ByteArray if there were escape sequences for
// non-printable characters.
return if (text.any { it.code < 32 && it.code !in setOf(9, 10, 13) }) {
// Do not expand UTF-8 sequences in binary data.
text.toByteArray(Charsets.ISO_8859_1)
} else {
text
}
}
private fun setEncodeByteArray() {
contentView.text = null
contentView.hint = getString(R.string.binary_data)
contentView.isEnabled = false
unescapeCheckBox.isEnabled = false
}
private fun getExtrasFromDeeplink(): Bundle? {
val data = intent?.dataString ?: return null
if (!isEncodeDeeplink(data)) {
return null
}
val uri = data.toUri()
return Bundle().apply {
uri.getQueryParameter("content")?.let {
putString(CONTENT_TEXT, it)
}
uri.getQueryParameter("format")?.let {
putString(FORMAT, it)
}
putBoolean(
EXECUTE,
uri.getQueryParameter("execute").let {
it == "" || it?.toBoolean() == true
}
)
}
}
companion object {
private const val CONTENT_TEXT = "content_text"
private const val CONTENT_RAW = "content_raw"
private const val FORMAT = "format"
private const val EXECUTE = "execute"
private const val PICK_FILE_RESULT_CODE = 1
fun <T> newIntent(
context: Context,
content: T? = null,
format: String? = null,
execute: Boolean = false
): Intent {
val args = Bundle()
content?.let {
when (content) {
is String -> args.putString(CONTENT_TEXT, content)
is ByteArray -> args.putByteArray(CONTENT_RAW, content)
else -> throw IllegalArgumentException(
"content must be a String or a ByteArray"
)
}
args.putBoolean(EXECUTE, execute)
}
format?.let { args.putString(FORMAT, it) }
return Intent(context, EncodeActivity::class.java).apply {
putExtras(args)
}
}
}
}
private fun Boolean.setVisibilityFor(vararg views: View) {
val visibility = if (this) View.VISIBLE else View.GONE
for (view in views) {
view.visibility = visibility
}
}
private fun BarcodeFormat.packEcLevel(packed: Int, level: Int): Int {
val s = ecLevelShift()
return (level shl s) or (packed and (15 shl s).inv())
}
private fun BarcodeFormat.unpackEcLevel(packed: Int) =
(packed shr ecLevelShift()) and 15
private fun BarcodeFormat.ecLevelShift() = when (this) {
BarcodeFormat.Aztec -> 0
BarcodeFormat.QRCode -> 4
BarcodeFormat.MicroQRCode -> 3
BarcodeFormat.RMQRCode -> 2
BarcodeFormat.PDF417 -> 8
else -> throw IllegalArgumentException("$this does not have error levels")
}
private fun BarcodeFormat.canBeInverted() = when (this) {
BarcodeFormat.Aztec,
BarcodeFormat.DataMatrix,
BarcodeFormat.QRCode,
BarcodeFormat.MicroQRCode,
BarcodeFormat.RMQRCode -> true
else -> false
}
private fun String.toFormat(
default: BarcodeFormat = BarcodeFormat.QRCode
): BarcodeFormat = try {
BarcodeFormat.valueOf(this)
} catch (_: IllegalArgumentException) {
default
}
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
}.coerceIn(0, 8)
private fun Spinner.setEntries(resId: Int) = ArrayAdapter.createFromResource(
this.context,
resId,
android.R.layout.simple_spinner_item
).also { aa ->
aa.setDropDownViewResource(
android.R.layout.simple_spinner_dropdown_item
)
adapter = aa
}
private fun InputStream.readBytesMax(max: Int): ByteArray {
var offset = 0
var remaining = min(available(), max)
val result = ByteArray(remaining)
while (remaining > 0) {
val read = read(result, offset, remaining)
if (read < 0) {
break
}
remaining -= read
offset += read
}
return result
}
@@ -1,38 +1,54 @@
package de.markusfisch.android.binaryeye.fragment
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
import android.support.v4.app.ActivityCompat
import android.support.v4.app.Fragment
import android.support.v4.content.ContextCompat
import android.support.v7.app.AppCompatActivity
import android.support.v7.view.ActionMode
import android.support.v7.widget.SearchView
import android.support.v7.widget.SwitchCompat
import android.view.*
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.widget.EditText
import android.widget.ListView
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.SearchView
import androidx.appcompat.widget.SwitchCompat
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.*
import de.markusfisch.android.binaryeye.app.alertDialog
import de.markusfisch.android.binaryeye.app.db
import de.markusfisch.android.binaryeye.app.hasWritePermission
import de.markusfisch.android.binaryeye.app.prefs
import de.markusfisch.android.binaryeye.content.copyToClipboard
import de.markusfisch.android.binaryeye.content.shareAsFile
import de.markusfisch.android.binaryeye.content.shareText
import de.markusfisch.android.binaryeye.content.wipeLastShareFile
import de.markusfisch.android.binaryeye.database.*
import de.markusfisch.android.binaryeye.database.Database
import de.markusfisch.android.binaryeye.database.exportCsv
import de.markusfisch.android.binaryeye.database.exportDatabase
import de.markusfisch.android.binaryeye.database.exportJson
import de.markusfisch.android.binaryeye.io.askForFileName
import de.markusfisch.android.binaryeye.io.toSaveResult
import de.markusfisch.android.binaryeye.view.*
import de.markusfisch.android.binaryeye.view.lockStatusBarColor
import de.markusfisch.android.binaryeye.view.setPaddingFromWindowInsets
import de.markusfisch.android.binaryeye.view.systemBarListViewScrollListener
import de.markusfisch.android.binaryeye.view.unlockStatusBarColor
import de.markusfisch.android.binaryeye.view.useVisibility
import de.markusfisch.android.binaryeye.widget.toast
import kotlinx.coroutines.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class HistoryFragment : Fragment() {
class HistoryActivity : AbstractBaseActivity() {
private lateinit var useHistorySwitch: SwitchCompat
private lateinit var listView: ListView
private lateinit var fab: View
@@ -49,12 +65,15 @@ class HistoryFragment : Fragment() {
R.menu.fragment_history_edit,
menu
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
// SDK35+ doesn't draw a status bar background anymore.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) {
lockStatusBarColor()
val ac = activity ?: return false
ac.window.statusBarColor = ContextCompat.getColor(
val ac = this@HistoryActivity
@Suppress("DEPRECATION")
ac.window.statusBarColor = MaterialColors.getColor(
ac,
R.color.accent_dark
com.google.android.material.R.attr.colorSecondaryContainer,
Color.TRANSPARENT
)
}
return true
@@ -71,7 +90,7 @@ class HistoryFragment : Fragment() {
mode: ActionMode,
item: MenuItem
): Boolean {
val ac = activity ?: return false
val ac = this@HistoryActivity
return when (item.itemId) {
R.id.copy_scan -> {
scansAdapter?.getSelectedContent("\n")?.let {
@@ -123,29 +142,15 @@ class HistoryFragment : Fragment() {
override fun onCreate(state: Bundle?) {
super.onCreate(state)
setHasOptionsMenu(true)
}
setScreenContentView(R.layout.activity_history)
setTitle(R.string.history)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
state: Bundle?
): View? {
val ac = activity ?: return null
ac.setTitle(R.string.history)
val view = inflater.inflate(
R.layout.fragment_history,
container,
false
)
useHistorySwitch = view.findViewById(
useHistorySwitch = findViewById(
R.id.use_history
) as SwitchCompat
)
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)
@@ -155,8 +160,8 @@ class HistoryFragment : Fragment() {
}
listView.setOnItemLongClickListener { _, v, position, id ->
scansAdapter?.select(v, id, position)
if (actionMode == null && ac is AppCompatActivity) {
actionMode = ac.delegate.startSupportActionMode(
if (actionMode == null) {
actionMode = delegate.startSupportActionMode(
actionModeCallback
)
}
@@ -164,19 +169,15 @@ class HistoryFragment : Fragment() {
}
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(R.id.inset_layout) as View).setPaddingFromWindowInsets()
findViewById<View>(R.id.inset_layout).setPaddingFromWindowInsets(bottom = false)
listView.setPaddingFromWindowInsets()
update()
return view
}
override fun onDestroy() {
@@ -186,6 +187,11 @@ class HistoryFragment : Fragment() {
wipeLastShareFile()
}
override fun onResume() {
super.onResume()
update()
}
override fun onPause() {
super.onPause()
listViewState = listView.onSaveInstanceState()
@@ -210,35 +216,31 @@ class HistoryFragment : Fragment() {
private fun initSearchView(item: MenuItem?) {
item ?: return
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB) {
item.isVisible = false
} else {
val ac = activity ?: return
val searchView = item.actionView as SearchView
val searchManager = ac.getSystemService(
Context.SEARCH_SERVICE
) as SearchManager
searchView.setSearchableInfo(
searchManager.getSearchableInfo(ac.componentName)
)
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String): Boolean {
update(query)
return false
}
val ac = this
val searchView = item.actionView as SearchView
val searchManager = ac.getSystemService(
Context.SEARCH_SERVICE
) as SearchManager
searchView.setSearchableInfo(
searchManager.getSearchableInfo(ac.componentName)
)
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String): Boolean {
update(query)
return false
}
override fun onQueryTextChange(query: String): Boolean {
update(query)
return false
}
})
}
override fun onQueryTextChange(query: String): Boolean {
update(query)
return false
}
})
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.clear -> {
context.askToRemoveScans()
this.askToRemoveScans()
true
}
@@ -248,7 +250,7 @@ class HistoryFragment : Fragment() {
}
R.id.share_as_file -> {
context.pickListSeparatorAndShare(true)
this.pickListSeparatorAndShare(true)
true
}
@@ -275,13 +277,13 @@ class HistoryFragment : Fragment() {
scope.launch {
val cursor = db.getScans(filter)
withContext(Dispatchers.Main) {
val ac = activity ?: return@withContext
val hasScans = cursor != null && cursor.count > 0
val ac = this@HistoryActivity
val hasScans = cursor.count > 0
if (filter == null) {
if (!hasScans) {
listView.emptyView = useHistorySwitch
}
ActivityCompat.invalidateOptionsMenu(ac)
ac.invalidateOptionsMenu()
}
enableMenuItems(hasScans)
fab.visibility = if (hasScans) {
@@ -289,10 +291,19 @@ class HistoryFragment : Fragment() {
} else {
View.GONE
}
cursor?.let { cursor ->
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)
@@ -319,8 +330,8 @@ class HistoryFragment : Fragment() {
private fun showScan(id: Long) = db.getScan(id)?.also { scan ->
closeActionMode()
try {
fragmentManager?.addFragment(DecodeFragment.newInstance(scan))
} catch (e: IllegalArgumentException) {
startActivity(DecodeActivity.newIntent(this, scan))
} catch (_: IllegalArgumentException) {
// Ignore, can never happen.
}
}
@@ -333,7 +344,7 @@ class HistoryFragment : Fragment() {
)
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 HistoryFragment : Fragment() {
}
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 HistoryFragment : Fragment() {
}
private fun Context.askToRemoveScans() {
AlertDialog.Builder(this)
MaterialAlertDialogBuilder(this)
.setMessage(
if (filter == null) {
R.string.really_remove_all_scans
@@ -393,7 +404,7 @@ class HistoryFragment : Fragment() {
private fun askToExportToFile() {
scope.launch {
val ac = activity ?: return@launch
val ac = this@HistoryActivity
progressView.useVisibility {
// Write permission is only required before Android Q.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q &&
@@ -421,12 +432,12 @@ class HistoryFragment : Fragment() {
} ?: return@useVisibility
val message = when (delimiter) {
"db" -> ac.exportDatabase(name)
else -> db.getScansDetailed(filter)?.use {
else -> db.getScansDetailed(filter).use {
when (delimiter) {
"json" -> ac.exportJson(name, it)
else -> ac.exportCsv(name, it, delimiter)
}
} ?: false
}
}.toSaveResult()
withContext(Dispatchers.Main) {
ac.toast(message)
@@ -439,7 +450,7 @@ class HistoryFragment : Fragment() {
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)
@@ -454,23 +465,23 @@ class HistoryFragment : Fragment() {
db.getScansDetailed(selectedIds.toLongArray())
} else {
db.getScansDetailed(filter)
}?.use { cursor ->
}.use { cursor ->
val details = format.split(":")
when (details[0]) {
"text" -> Pair(cursor.exportText(details[1]), "txt")
"csv" -> Pair(cursor.exportCsv(details[1]), "csv")
else -> Pair(cursor.exportJson(), "json")
}
}?.let { (text, ext) ->
}.let { (text, ext) ->
text?.let {
withContext(Dispatchers.Main) {
if (asFile) {
context.shareAsFile(
this@HistoryActivity.shareAsFile(
it,
String.format("scans.%s", ext)
)
} else {
context.shareText(it)
this@HistoryActivity.shareText(it)
}
}
}
@@ -481,7 +492,7 @@ class HistoryFragment : Fragment() {
private fun Cursor.exportText(separator: String): String {
val sb = StringBuilder()
val contentIndex = getColumnIndex(Database.SCANS_CONTENT)
val contentIndex = getColumnIndex(Database.SCANS_TEXT)
if (contentIndex > -1 && moveToFirst()) {
do {
val content = getString(contentIndex)
@@ -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
}
}
}
@@ -1,148 +0,0 @@
package de.markusfisch.android.binaryeye.activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.support.v4.app.Fragment
import android.support.v7.app.AppCompatActivity
import android.support.v7.widget.Toolbar
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
import de.markusfisch.android.binaryeye.app.permissionGrantedCallback
import de.markusfisch.android.binaryeye.app.prefs
import de.markusfisch.android.binaryeye.app.setFragment
import de.markusfisch.android.binaryeye.database.Scan
import de.markusfisch.android.binaryeye.fragment.DecodeFragment
import de.markusfisch.android.binaryeye.fragment.EncodeFragment
import de.markusfisch.android.binaryeye.fragment.HistoryFragment
import de.markusfisch.android.binaryeye.fragment.PreferencesFragment
import de.markusfisch.android.binaryeye.view.colorSystemAndToolBars
import de.markusfisch.android.binaryeye.view.initSystemBars
import de.markusfisch.android.binaryeye.view.recordToolbarHeight
class MainActivity : AppCompatActivity() {
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
when (requestCode) {
PERMISSION_LOCATION, PERMISSION_WRITE -> if (grantResults.isNotEmpty() &&
grantResults[0] == PackageManager.PERMISSION_GRANTED
) {
permissionGrantedCallback?.invoke()
permissionGrantedCallback = null
}
}
}
override fun onSupportNavigateUp(): Boolean {
val fm = supportFragmentManager
if (fm != null && fm.backStackEntryCount > 0) {
fm.popBackStack()
} else {
finish()
}
return true
}
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)
initSystemBars(this)
val toolbar = findViewById(R.id.toolbar) as Toolbar
recordToolbarHeight(toolbar)
setSupportActionBar(toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportFragmentManager.addOnBackStackChangedListener {
colorSystemAndToolBars(this@MainActivity)
}
if (state == null) {
supportFragmentManager?.setFragment(getFragmentForIntent(intent))
}
}
companion object {
private const val PREFERENCES = "preferences"
private const val HISTORY = "history"
private const val ENCODE = "encode"
const val DECODED = "decoded"
private fun getFragmentForIntent(intent: Intent?): Fragment {
intent ?: return PreferencesFragment()
return when {
intent.hasExtra(PREFERENCES) -> PreferencesFragment()
intent.hasExtra(HISTORY) -> HistoryFragment()
intent.hasExtra(ENCODE) -> EncodeFragment.newInstance(
intent.getStringExtra(ENCODE)
)
intent.hasExtra(DECODED) -> intent.getScanExtra(DECODED)?.let {
DecodeFragment.newInstance(it)
} ?: DecodeFragment()
else -> PreferencesFragment()
}
}
fun getPreferencesIntent(context: Context): Intent {
val intent = Intent(context, MainActivity::class.java)
intent.putExtra(PREFERENCES, true)
return intent
}
fun getHistoryIntent(context: Context): Intent {
val intent = Intent(context, MainActivity::class.java)
intent.putExtra(HISTORY, true)
return intent
}
fun getEncodeIntent(
context: Context,
text: String? = "",
isExternal: Boolean = false
): Intent {
val intent = Intent(context, MainActivity::class.java)
intent.putExtra(ENCODE, text)
if (isExternal) {
val flagActivityClearTask =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
Intent.FLAG_ACTIVITY_CLEAR_TASK
} else 0
intent.addFlags(
Intent.FLAG_ACTIVITY_NO_HISTORY or
flagActivityClearTask or
Intent.FLAG_ACTIVITY_NEW_TASK
)
}
return intent
}
fun getDecodeIntent(context: Context, scan: Scan): Intent {
val intent = Intent(context, MainActivity::class.java)
intent.putExtra(DECODED, scan)
return intent
}
}
}
private fun Intent.getScanExtra(name: String): Scan? = if (
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
) {
@Suppress("DEPRECATION")
getParcelableExtra(name)
} else {
getParcelableExtra(name, Scan::class.java)
}
@@ -1,41 +1,32 @@
package de.markusfisch.android.binaryeye.fragment
package de.markusfisch.android.binaryeye.activity
import android.content.Context
import android.net.wifi.WifiManager
import android.net.wifi.WifiNetworkSuggestion
import android.os.Build
import android.os.Bundle
import android.support.annotation.RequiresApi
import android.support.v4.app.Fragment
import android.view.*
import android.view.View
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 NetworkSuggestionsFragment : Fragment() {
class NetworkSuggestionsActivity : AbstractBaseActivity() {
@RequiresApi(Build.VERSION_CODES.R)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
state: Bundle?
): View? {
val ac = activity ?: return null
ac.setTitle(R.string.network_suggestions)
override fun onCreate(state: Bundle?) {
super.onCreate(state)
setScreenContentView(R.layout.activity_network_suggestions)
setTitle(R.string.network_suggestions)
val view = inflater.inflate(
R.layout.fragment_network_suggestions,
container,
false
)
val wm = ac.applicationContext.getSystemService(
val wm = applicationContext.getSystemService(
Context.WIFI_SERVICE
) as WifiManager
val suggestionArrayAdapter = ArrayAdapter(
ac,
this,
android.R.layout.simple_list_item_checked,
wm.networkSuggestions.map {
Suggestion(
@@ -45,18 +36,18 @@ class NetworkSuggestionsFragment : Fragment() {
}
)
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(R.id.inset_layout) as View).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
val size = checked.size()
val size = checked.size
for (i in 0 until size) {
if (checked.valueAt(i)) {
val pos = checked.keyAt(i)
@@ -74,13 +65,11 @@ class NetworkSuggestionsFragment : Fragment() {
}
wm.removeNetworkSuggestions(removeList)
} else {
ac.toast(
toast(
R.string.clear_network_suggestions_nothing_to_remove
)
}
}
return view
}
}
@@ -9,10 +9,10 @@ import android.graphics.RectF
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.support.v7.app.AppCompatActivity
import android.support.v7.widget.Toolbar
import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.app.applyLocale
import de.markusfisch.android.binaryeye.app.prefs
@@ -22,8 +22,7 @@ import de.markusfisch.android.binaryeye.graphics.loadImageUri
import de.markusfisch.android.binaryeye.graphics.mapPosition
import de.markusfisch.android.binaryeye.media.releaseToneGenerators
import de.markusfisch.android.binaryeye.view.colorSystemAndToolBars
import de.markusfisch.android.binaryeye.view.initSystemBars
import de.markusfisch.android.binaryeye.view.recordToolbarHeight
import de.markusfisch.android.binaryeye.view.initBars
import de.markusfisch.android.binaryeye.view.scanFeedback
import de.markusfisch.android.binaryeye.view.setPaddingFromWindowInsets
import de.markusfisch.android.binaryeye.widget.CropImageView
@@ -55,10 +54,9 @@ class PickActivity : AppCompatActivity() {
formats = prefs.barcodeFormats.toFormatSet()
)
private lateinit var cropImageView: CropImageView
private lateinit var detectorView: DetectorView
private lateinit var freeRotationItem: MenuItem
private var cropImageView: CropImageView? = null
private var detectorView: DetectorView? = null
private var freeRotationItem: MenuItem? = null
private var result: Result? = null
override fun attachBaseContext(base: Context?) {
@@ -74,11 +72,7 @@ class PickActivity : AppCompatActivity() {
// locale.
setTitle(R.string.pick_code_to_scan)
initSystemBars(this)
val toolbar = findViewById(R.id.toolbar) as Toolbar
recordToolbarHeight(toolbar)
setSupportActionBar(toolbar)
initBars()
supportFragmentManager.addOnBackStackChangedListener {
colorSystemAndToolBars(this@PickActivity)
}
@@ -87,25 +81,36 @@ class PickActivity : AppCompatActivity() {
if (bitmap == null) {
applicationContext.toast(R.string.error_no_content)
finish()
// This is why cropImageView and detectorView aren't lateinit.
// If they were, a kotlin.UninitializedPropertyAccessException
// would happen here.
return
}
cropImageView = findViewById(R.id.image) as CropImageView
cropImageView.restrictTranslation = false
cropImageView.freeRotation = prefs.freeRotation
cropImageView.setImageBitmap(bitmap)
cropImageView.onScan = {
scanWithinBounds(bitmap)
cropImageView = (findViewById(R.id.image) as CropImageView).apply {
restrictTranslation = false
freeRotation = prefs.freeRotation
setImageBitmap(bitmap)
onScan = {
scanWithinBounds(bitmap)
}
runAfterLayout = {
minWidth = min(
minWidth / 2f,
max(bitmap.width, bitmap.height).toFloat()
)
}
}
detectorView = findViewById(R.id.detector_view) as DetectorView
detectorView.onRoiChanged = {
scanWithinBounds(bitmap)
detectorView = (findViewById(R.id.detector_view) as DetectorView).apply {
onRoiChanged = {
scanWithinBounds(bitmap)
}
setPaddingFromWindowInsets()
cropHandleName = "picker_crop_handle"
}
detectorView.setPaddingFromWindowInsets()
detectorView.restoreCropHandlePos()
findViewById(R.id.scan).setOnClickListener {
findViewById<View>(R.id.scan).setOnClickListener {
showResult()
}
}
@@ -125,17 +130,19 @@ class PickActivity : AppCompatActivity() {
}
private fun scanWithinBounds(bitmap: Bitmap) {
val dv = detectorView ?: return
val civ = cropImageView ?: return
val viewRoi = Rect(
if (detectorView.roi.width() < 1) {
cropImageView.getBoundsRect()
if (dv.roi.width() < 1) {
civ.getBoundsRect()
} else {
detectorView.roi
dv.roi
}
)
val mappedRect = RectF(cropImageView.mappedRect)
val imageRotation = cropImageView.imageRotation
val pivotX = cropImageView.pivotX
val pivotY = cropImageView.pivotY
val mappedRect = RectF(civ.mappedRect)
val imageRotation = civ.imageRotation
val pivotX = civ.pivotX
val pivotY = civ.pivotY
scope.launch {
val cropped = bitmap.crop(
getNormalizedRoi(mappedRect, viewRoi),
@@ -166,10 +173,10 @@ class PickActivity : AppCompatActivity() {
}
result = it
scanFeedback()
detectorView.update(
dv.update(
matrix.mapPosition(
it.position,
detectorView.coordinates
dv.coordinates
)
)
}
@@ -199,7 +206,7 @@ class PickActivity : AppCompatActivity() {
override fun onDestroy() {
super.onDestroy()
detectorView.saveCropHandlePos()
detectorView?.storeCropHandlePos()
parentJob.cancel()
releaseToneGenerators()
}
@@ -221,8 +228,8 @@ class PickActivity : AppCompatActivity() {
R.id.toggle_free -> {
prefs.freeRotation = prefs.freeRotation xor true
cropImageView.freeRotation = prefs.freeRotation
freeRotationItem.updateFreeRotationIcon()
cropImageView?.freeRotation = prefs.freeRotation
freeRotationItem?.updateFreeRotationIcon()
true
}
@@ -241,7 +248,8 @@ class PickActivity : AppCompatActivity() {
}
private fun rotateClockwise() {
cropImageView.imageRotation = cropImageView.imageRotation + 90 % 360
val civ = cropImageView ?: return
civ.imageRotation = (civ.imageRotation + 90) % 360
}
private fun loadSentImage(intent: Intent): Bitmap? {
@@ -0,0 +1,34 @@
package de.markusfisch.android.binaryeye.activity
import android.content.Context
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.app.applyLocale
import de.markusfisch.android.binaryeye.app.prefs
import de.markusfisch.android.binaryeye.fragment.PreferencesFragment
import de.markusfisch.android.binaryeye.view.initBars
class PreferencesActivity : 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)
initBars()
supportActionBar?.setDisplayHomeAsUpEnabled(true)
if (state == null) {
supportFragmentManager.beginTransaction()
.replace(R.id.content_frame, PreferencesFragment())
.commit()
}
}
override fun onSupportNavigateUp(): Boolean {
finish()
return true
}
}
@@ -0,0 +1,180 @@
package de.markusfisch.android.binaryeye.activity
import android.content.Context
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.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 : 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)
refreshProfiles()
val listView = findViewById<ListView>(R.id.profiles)
listView.emptyView = findViewById(R.id.no_profiles)
adapter = ProfilesAdapter(this, profiles)
listView.adapter = adapter
listView.setOnScrollListener(systemBarListViewScrollListener)
listView.setOnItemClickListener { _, _, position, _ ->
selectProfile(position)
}
listView.setOnItemLongClickListener { _, _, position, _ ->
confirmRemoveProfile(position)
true
}
findViewById<View>(R.id.add).setOnClickListener {
addProfile()
}
findViewById<View>(R.id.inset_layout).setPaddingFromWindowInsets(bottom = false)
listView.setPaddingFromWindowInsets()
}
private fun refreshProfiles() {
profiles.clear()
profiles.add(
ProfileItem(
null,
prefs.profileLabel(this, null)
)
)
prefs.profiles.forEach { name ->
profiles.add(ProfileItem(name, prefs.profileLabel(this, name)))
}
if (this::adapter.isInitialized) {
adapter.notifyDataSetChanged()
}
}
private fun selectProfile(position: Int) {
val profile = profiles[position].name
if (prefs.profile == profile) {
return
}
prefs.load(this, profile)
adapter.notifyDataSetChanged()
}
// Dialogs do not have a parent view.
@Suppress("InflateParams")
private fun addProfile() {
val view = layoutInflater.inflate(
R.layout.dialog_profile_name, null
)
val editText = view.findViewById<EditText>(R.id.name)
MaterialAlertDialogBuilder(this)
.setView(view)
.setPositiveButton(android.R.string.ok) { _, _ ->
val name = editText.text.toString().trim()
if (name.isNotEmpty() && prefs.addProfile(name)) {
prefs.load(this, name)
refreshProfiles()
} else {
toast(R.string.profile_invalid_name)
}
}
.show()
}
private fun confirmRemoveProfile(position: Int) {
val profile = profiles[position]
val profileName = profile.name ?: return
MaterialAlertDialogBuilder(this)
.setMessage(R.string.profile_remove_confirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
val isCurrent = prefs.profile == profileName
prefs.removeProfile(profileName)
if (isCurrent) {
prefs.load(this, null)
}
refreshProfiles()
}
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.show()
}
private class ProfilesAdapter(
context: Context,
private val items: List<ProfileItem>
) : ArrayAdapter<ProfileItem>(
context,
R.layout.row_profile,
R.id.title,
items
) {
override fun getView(
position: Int,
convertView: View?,
parent: ViewGroup
): View {
val view = super.getView(position, convertView, parent)
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)
subtitle.visibility = View.VISIBLE
} else {
subtitle.text = ""
subtitle.visibility = View.GONE
}
}
return view
}
private fun getViewHolder(
view: View
): ViewHolder = view.tag as ViewHolder? ?: ViewHolder(
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 class ViewHolder(
val title: TextView,
val subtitle: TextView,
val shareQr: View,
) {
var current: ProfileItem? = null
}
}
}
private data class ProfileItem(
val name: String?,
val label: String
)
@@ -1,10 +1,10 @@
package de.markusfisch.android.binaryeye.activity
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.support.v7.app.AppCompatActivity
class SplashActivity : AppCompatActivity() {
class SplashActivity : Activity() {
override fun onCreate(state: Bundle?) {
super.onCreate(state)
@@ -0,0 +1,22 @@
package de.markusfisch.android.binaryeye.activity
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.app.prefs
class WelcomeActivity : AppCompatActivity() {
override fun onCreate(state: Bundle?) {
super.onCreate(state)
setContentView(R.layout.activity_welcome)
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 android.support.v4.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 contentIndex = cursor.getColumnIndex(Database.SCANS_CONTENT)
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) {
@@ -74,7 +83,7 @@ class ScansAdapter(context: Context, cursor: Cursor) :
fun getContent(
position: Int
) = (getItem(position) as Cursor?)?.getString(contentIndex)
) = (getItem(position) as Cursor?)?.getString(textIndex)
override fun newView(
context: Context,
@@ -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 content = cursor.getString(contentIndex)
val text = cursor.getString(textIndex)
val raw = cursor.getBlob(rawIndex)
var icon = 0
contentView.text = when {
name?.isNotEmpty() == true -> {
@@ -100,18 +121,39 @@ class ScansAdapter(context: Context, cursor: Cursor) :
name
}
content.isNullOrEmpty() -> context.getString(
R.string.binary_data
text.isNullOrEmpty() -> getContentPreview(
context,
text ?: "",
raw ?: ByteArray(0),
format
)
else -> content
else -> text
}
contentView.setCompoundDrawablesWithIntrinsicBounds(
icon, 0, 0, 0
)
formatView.text = prettifyFormatName(cursor.getString(formatIndex))
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,27 +161,122 @@ 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
)
}
fun prettifyFormatName(format: String) = format.replace("_", " ")
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 {
@@ -149,7 +286,7 @@ private fun formatDateTime(rfc: String): String {
DateFormat.SHORT
).format(it)
}
} catch (e: ParseException) {
} catch (_: ParseException) {
// Ignore and fall through.
}
return rfc
@@ -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)
}
}
@@ -1,19 +0,0 @@
package de.markusfisch.android.binaryeye.app
import android.annotation.SuppressLint
import android.support.v4.app.Fragment
import android.support.v4.app.FragmentManager
import de.markusfisch.android.binaryeye.R
fun FragmentManager.setFragment(fragment: Fragment) {
getTransaction(fragment).commit()
}
fun FragmentManager.addFragment(fragment: Fragment) {
getTransaction(fragment).addToBackStack(null).commit()
}
@SuppressLint("CommitTransaction")
private fun FragmentManager.getTransaction(
fragment: Fragment
) = beginTransaction().replace(R.id.content_frame, fragment)
@@ -4,8 +4,8 @@ import android.Manifest
import android.app.Activity
import android.content.pm.PackageManager
import android.os.Build
import android.support.v4.app.ActivityCompat
import android.support.v4.content.ContextCompat
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
var permissionGrantedCallback: (() -> Any)? = null
@@ -0,0 +1,111 @@
package de.markusfisch.android.binaryeye.automation
import de.markusfisch.android.binaryeye.database.Scan
import de.markusfisch.android.binaryeye.net.urlEncode
import org.json.JSONArray
import org.json.JSONObject
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
) {
enum class Type {
Intent, CustomIntent
}
fun matches(content: String, format: String): Boolean = try {
(this.format == null || this.format == format) &&
Regex(pattern).matches(content)
} catch (_: Exception) {
false
}
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 ?: "")
}
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"
fun fromJson(obj: JSONObject): AutomatedAction? {
val pattern = obj.optString(KEY_PATTERN, "").trim()
if (pattern.isEmpty()) {
return null
}
val type = when (obj.optString(KEY_TYPE, "")) {
"custom_intent" -> Type.CustomIntent
"intent" -> Type.Intent
"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,
""
).trim()
return AutomatedAction(
pattern = pattern,
type = type,
format = format.ifEmpty { null },
urlTemplate = urlTemplate.ifEmpty { null },
intentUriTemplate = intentUriTemplate.ifEmpty { null }
)
}
fun fromJsonArray(json: String): MutableList<AutomatedAction> {
val actions = ArrayList<AutomatedAction>()
val array = try {
JSONArray(json)
} catch (_: Exception) {
JSONArray()
}
for (i in 0 until array.length()) {
val obj = array.optJSONObject(i) ?: continue
fromJson(obj)?.let { actions.add(it) }
}
return actions
}
fun toJsonArray(actions: List<AutomatedAction>): String {
val array = JSONArray()
actions.forEach { array.put(it.toJson()) }
return array.toString()
}
}
}
fun String.buildUrl(scan: Scan): String {
return buildTemplate(scan, encodeResult = true)
}
fun String.buildTemplate(scan: Scan, encodeResult: Boolean): String {
val text = scan.text
val raw = scan.raw ?: text.toByteArray()
val result = if (encodeResult) {
text.urlEncode()
} else {
text
}
val format = if (encodeResult) {
scan.format.name.urlEncode()
} else {
scan.format.name
}
return this
.replace("{RESULT}", result)
.replace("{RESULT_BYTES}", raw.toHexString())
.replace("{FORMAT}", format)
}
@@ -0,0 +1,59 @@
package de.markusfisch.android.binaryeye.automation
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.openUrl
import de.markusfisch.android.binaryeye.content.startIntentOrToast
import de.markusfisch.android.binaryeye.database.Scan
import de.markusfisch.android.binaryeye.widget.toast
fun Context.runAutomatedActions(scan: Scan): Boolean {
val content = scan.text
if (content.isEmpty()) {
return false
}
val actions = prefs.automatedActions
if (actions.isEmpty()) {
return false
}
for (action in actions) {
if (action.matches(content, scan.format.name)) {
return when (action.type) {
AutomatedAction.Type.Intent -> {
val url = action.urlTemplate?.buildUrl(scan)
if (url.isNullOrEmpty()) {
toast(R.string.automated_action_invalid_url)
false
} else {
openUrl(url)
}
}
AutomatedAction.Type.CustomIntent ->
execCustomIntent(action, scan)
}
}
}
return false
}
private fun Context.execCustomIntent(
action: AutomatedAction,
scan: Scan
): Boolean {
val template = action.intentUriTemplate
if (template.isNullOrEmpty()) {
toast(R.string.automated_action_intent_uri_required)
return false
}
val intentUri = template.buildTemplate(scan, encodeResult = false)
val intent = try {
Intent.parseUri(intentUri, Intent.URI_INTENT_SCHEME)
} catch (_: Exception) {
toast(R.string.automated_action_intent_uri_invalid)
return false
}
return startIntentOrToast(intent)
}
@@ -3,7 +3,7 @@ package de.markusfisch.android.binaryeye.bluetooth
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import android.support.v7.preference.ListPreference
import androidx.preference.ListPreference
import de.markusfisch.android.binaryeye.database.Scan
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -21,10 +21,10 @@ fun Scan.sendBluetoothAsync(
val connected = if (isConnected) {
true
} else {
connect(host)
connect(host, true)
}
val sent = if (connected) {
send(content)
send(text, host)
} else {
false
}
@@ -35,21 +35,20 @@ fun Scan.sendBluetoothAsync(
}
fun setBluetoothHosts(listPref: ListPreference) {
val devices = try {
BluetoothAdapter.getDefaultAdapter().bondedDevices
} catch (e: SecurityException) {
try {
val devices = blue.bondedDevices ?: return
listPref.entries = devices.map {
it.name
}.toTypedArray()
listPref.entryValues = devices.map {
it.address
}.toTypedArray()
listPref.callChangeListener(listPref.value)
} catch (_: SecurityException) {
// Do nothing, either the user has denied Bluetooth access
// or the permission was removed by the system. We're catching
// the exception to keep the app from crashing.
null
} ?: return
listPref.entries = devices.map {
it.name
}.toTypedArray()
listPref.entryValues = devices.map {
it.address
}.toTypedArray()
listPref.callChangeListener(listPref.value)
}
}
private var socket: BluetoothSocket? = null
@@ -62,7 +61,7 @@ private val uuid = UUID.fromString(
private var isConnected = false
private fun connect(deviceName: String): Boolean = try {
private fun connect(deviceName: String, onceMore: Boolean): Boolean = try {
val device = findByName(deviceName) ?: throw RuntimeException(
"Bluetooth device not found"
)
@@ -71,32 +70,41 @@ private fun connect(deviceName: String): Boolean = try {
writer = socket?.outputStream?.writer()
isConnected = true
true
} catch (e: Exception) {
close()
} catch (_: Exception) {
if (onceMore) {
connect(deviceName, false)
} else {
close()
}
false
}
private fun send(message: String): Boolean = try {
private fun send(message: String, host: String): Boolean = try {
writer?.apply {
write(message)
write("\n")
flush()
}
true
} catch (e: Exception) {
} catch (_: Exception) {
close()
false
if (connect(host, false)) {
send(message, host)
} else {
false
}
}
private fun close() {
try {
writer?.close()
} catch (e: IOException) {
} catch (_: IOException) {
// Catch exception if writer wasn't initialized.
}
try {
socket?.close()
} catch (e: IOException) {
} catch (_: IOException) {
// Nothing we can do about this but keep
// the app from crashing.
}
@@ -106,11 +114,15 @@ private fun close() {
}
private fun findByName(findableName: String): BluetoothDevice? {
val deviceList = blue.bondedDevices
for (device in deviceList) {
if (device.address == findableName) {
return device
try {
val deviceList = blue.bondedDevices
for (device in deviceList) {
if (device.address == findableName) {
return device
}
}
} catch (_: SecurityException) {
// Fall through.
}
return null
}
@@ -0,0 +1,59 @@
package de.markusfisch.android.binaryeye.camera
import android.annotation.SuppressLint
import android.content.Context
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraManager
import androidx.camera.core.CameraSelector
import de.markusfisch.android.binaryeye.R
const val DEFAULT_CAMERA_BACK = "back"
const val DEFAULT_CAMERA_FRONT = "front"
const val DEFAULT_CAMERA_OTHER = "other"
fun availableDefaultCameras(context: Context): List<String> {
val lensFacings = try {
(context.getSystemService(Context.CAMERA_SERVICE) as CameraManager)
.run {
cameraIdList.mapNotNull { id ->
getCameraCharacteristics(id)
.get(CameraCharacteristics.LENS_FACING)
}
}.toSet()
} catch (_: Exception) {
emptySet()
}
return buildList {
if (lensFacings.isEmpty() ||
lensFacings.contains(CameraCharacteristics.LENS_FACING_BACK)
) {
add(DEFAULT_CAMERA_BACK)
}
if (lensFacings.contains(CameraCharacteristics.LENS_FACING_FRONT)) {
add(DEFAULT_CAMERA_FRONT)
}
if (lensFacings.contains(CameraCharacteristics.LENS_FACING_EXTERNAL)) {
add(DEFAULT_CAMERA_OTHER)
}
}
}
fun defaultCameraEntries(context: Context, values: List<String>) =
values.map {
when (it) {
DEFAULT_CAMERA_FRONT -> context.getString(R.string.default_camera_front)
DEFAULT_CAMERA_OTHER -> context.getString(R.string.default_camera_other)
else -> context.getString(R.string.default_camera_back)
}
}.toTypedArray()
@SuppressLint("UnsafeOptInUsageError")
fun cameraSelector(camera: String) = CameraSelector.Builder()
.requireLensFacing(
when (camera) {
DEFAULT_CAMERA_FRONT -> CameraSelector.LENS_FACING_FRONT
DEFAULT_CAMERA_OTHER -> CameraSelector.LENS_FACING_EXTERNAL
else -> CameraSelector.LENS_FACING_BACK
}
)
.build()
@@ -0,0 +1,221 @@
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
import de.markusfisch.android.zxingcpp.ZxingCpp
import de.markusfisch.android.zxingcpp.ZxingCpp.BarcodeFormat
import de.markusfisch.android.zxingcpp.ZxingCpp.BitMatrix
import de.markusfisch.android.zxingcpp.ZxingCpp.toBitmap
import de.markusfisch.android.zxingcpp.ZxingCpp.toSvg
import de.markusfisch.android.zxingcpp.ZxingCpp.toText
import java.util.Arrays
enum class BarcodeColors {
BLACK_ON_WHITE,
WHITE_ON_BLACK,
BLACK_ON_TRANSPARENT,
WHITE_ON_TRANSPARENT;
fun foregroundColor(): Int = when (this) {
BLACK_ON_WHITE,
BLACK_ON_TRANSPARENT -> COLOR_BLACK
WHITE_ON_BLACK,
WHITE_ON_TRANSPARENT -> COLOR_WHITE
}
fun backgroundColor(): Int = when (this) {
BLACK_ON_WHITE -> COLOR_WHITE
WHITE_ON_BLACK -> COLOR_BLACK
BLACK_ON_TRANSPARENT,
WHITE_ON_TRANSPARENT -> 0
}
}
fun Scan.toBarcode(): Barcode<*> {
val content = text.ifEmpty {
raw
}
return if (symbol != null) {
BitMatrixBarcode(
symbol,
content,
format
)
} else {
ContentBarcode(
content,
format,
errorCorrectionLevel.toErrorCorrectionInt(),
addQuietZone = true
)
}
}
fun String?.toErrorCorrectionInt() = when (this) {
"L" -> 0
"M" -> 4
"Q" -> 6
"H" -> 8
else -> -1
}
sealed class Barcode<T>(
val content: T,
val format: BarcodeFormat,
val colors: BarcodeColors
) {
private var _bitmap: Bitmap? = null
fun bitmap(): Bitmap {
val b = _bitmap ?: bitmap(512)
_bitmap = b
return b
}
fun bitmap(size: Int): Bitmap {
return toBitmap(size)
}
protected abstract fun toBitmap(size: Int): Bitmap
private var _svg: String? = null
fun svg(): String {
val s = _svg ?: toSvg()
_svg = s
return s
}
protected abstract fun toSvg(): String
private var _text: String? = null
fun text(): String {
val t = _text ?: toText()
_text = t
return t
}
protected abstract fun toText(): String
fun textOrHex(): String = when (content) {
is String -> content
is ByteArray -> content.toHexString()
else -> throw IllegalArgumentException("Illegal arguments")
}
}
class ContentBarcode<T>(
content: T,
format: BarcodeFormat,
val ecLevel: Int = -1,
val addQuietZone: Boolean = false,
colors: BarcodeColors = BarcodeColors.BLACK_ON_WHITE
) : Barcode<T>(content, format, colors) {
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, addQuietZone, ecLevel
)
override fun toText() = ZxingCpp.encodeAsText(
content, format, addQuietZone, ecLevel,
inverted = colors == BarcodeColors.BLACK_ON_WHITE
)
}
class BitMatrixBarcode<T>(
val bitMatrix: BitMatrix,
content: T,
format: BarcodeFormat,
colors: BarcodeColors = BarcodeColors.BLACK_ON_WHITE
) : Barcode<T>(content, format, colors) {
override fun toBitmap(size: Int) = bitMatrix.inflate(size).toBitmap(
setColor = colors.foregroundColor(),
unsetColor = colors.backgroundColor()
)
override fun toSvg() = bitMatrix.inflate().toSvg()
override fun toText() = bitMatrix.inflate().toText(
inverted = colors == BarcodeColors.BLACK_ON_WHITE
)
}
private fun BitMatrix.inflate(
size: Int = 0,
quietZone: Boolean = true
): BitMatrix {
val extraModules = if (quietZone) 2 else 0
val codeWidth = width + extraModules
val codeHeight = height + extraModules
var scale = size / codeWidth
if (scale < 1) {
if (size > 0 && !quietZone) {
return this
}
scale = 1
}
val outWidth = codeWidth * scale
val outHeight = codeHeight * scale
val scaled = BitMatrix(
outWidth,
outHeight,
ByteArray(outWidth * outHeight)
)
Arrays.fill(scaled.data, 1)
val padding = extraModules / 2
val left = padding * scale
val top = padding * scale
val right = outWidth - left
val bottom = outHeight - top
for (y in top until bottom) {
var dst = y * outWidth + left
val offset = (y - top) / scale * width
for (x in left until right) {
scaled.data[dst++] = data[
offset + (x - left) / scale
]
}
}
return scaled
}
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,
BarcodeFormat.Code93,
BarcodeFormat.Code128,
BarcodeFormat.DataBarOmni,
BarcodeFormat.DataBarStk,
BarcodeFormat.DataBarLtd,
BarcodeFormat.DataBarExp,
BarcodeFormat.DataBarExpStk,
BarcodeFormat.DXFilmEdge,
BarcodeFormat.EAN8,
BarcodeFormat.EAN13,
BarcodeFormat.ITF,
BarcodeFormat.Telepen,
BarcodeFormat.TelepenAlpha,
BarcodeFormat.TelepenNumeric,
BarcodeFormat.UPCA,
BarcodeFormat.UPCE -> Size(size, size / 3)
// PDF417 requires more horizontal space.
BarcodeFormat.PDF417 -> Size(size * 3, size / 2)
else -> Size(size, size)
}
@@ -8,16 +8,11 @@ import android.os.PersistableBundle
fun Context.copyToClipboard(text: String, isSensitive: Boolean = false) {
val service = getSystemService(Context.CLIPBOARD_SERVICE)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB) {
@Suppress("DEPRECATION")
(service as android.text.ClipboardManager).text = text
} else {
(service as android.content.ClipboardManager).setPrimaryClip(
ClipData.newPlainText("plain text", text).apply {
setSensitive(isSensitive)
}
)
}
(service as android.content.ClipboardManager).setPrimaryClip(
ClipData.newPlainText("plain text", text).apply {
setSensitive(isSensitive)
}
)
}
private fun ClipData.setSensitive(isSensitive: Boolean) {
@@ -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
)
}
}
@@ -1,3 +0,0 @@
package de.markusfisch.android.binaryeye.content
fun ByteArray.toHexString() = joinToString("") { "%02X".format(it) }
@@ -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()
}
}
@@ -0,0 +1,67 @@
package de.markusfisch.android.binaryeye.content
import android.content.Context
import de.markusfisch.android.binaryeye.R
private val idToLabel = hashMapOf(
"DCA" to R.string.idl_vehicle_class,
"DCB" to R.string.idl_restriction_codes,
"DCD" to R.string.idl_endorsement_codes,
"DBA" to R.string.idl_date_of_expiry,
"DCS" to R.string.idl_customer_family_name,
"DAC" to R.string.idl_customer_first_name,
"DCT" to R.string.idl_customer_first_name_alt,
"DAD" to R.string.idl_customer_middle_name,
"DBD" to R.string.idl_date_of_issue,
"DBB" to R.string.idl_date_of_birth,
"DBC" to R.string.idl_sex,
"DAY" to R.string.idl_eye_color,
"DAU" to R.string.idl_body_height,
"DAG" to R.string.idl_address_street,
"DAI" to R.string.idl_address_city,
"DAJ" to R.string.idl_address_jurisdiction_code,
"DAK" to R.string.idl_address_postal_code,
"DAQ" to R.string.idl_customer_id_number,
"DCF" to R.string.idl_document_discriminator,
"DCG" to R.string.idl_country,
"DDE" to R.string.idl_family_name_truncation,
"DDF" to R.string.idl_first_name_truncation,
"DDG" to R.string.idl_middle_name_truncation,
"DAH" to R.string.idl_address_street_2,
"DAZ" to R.string.idl_hair_color,
"DCI" to R.string.idl_place_of_birth,
"DCJ" to R.string.idl_audit_information,
"DCK" to R.string.idl_inventory_control_number,
"DBN" to R.string.idl_alias_family_name,
"DBG" to R.string.idl_alias_given_name,
"DBS" to R.string.idl_alias_suffix_name,
"DCU" to R.string.idl_name_suffix,
"DCE" to R.string.idl_weight_range,
"DCL" to R.string.idl_ethnicity,
"DCM" to R.string.idl_standard_vehicle_classification,
"DCN" to R.string.idl_standard_endorsement_code,
"DCO" to R.string.idl_standard_restriction_code,
"DCP" to R.string.idl_vehicle_classification_description,
"DCQ" to R.string.idl_endorsement_code_description,
"DCR" to R.string.idl_restriction_code_description,
"DDA" to R.string.idl_compliance_type,
"DDB" to R.string.idl_card_revision_date,
"DDC" to R.string.idl_hazmat_endorsement_expiration_date,
"DDD" to R.string.idl_limited_duration_document_indicator,
"DAW" to R.string.idl_weight_pounds,
"DAX" to R.string.idl_weight_kilograms,
"DDH" to R.string.idl_under_18_until_date,
"DDI" to R.string.idl_under_19_until_date,
"DDJ" to R.string.idl_under_21_until_date,
"DDK" to R.string.idl_organ_donor_indicator,
"DDL" to R.string.idl_veteran_indicator
)
fun Context.idlToRes(id: String): String {
val resId = idToLabel[id]
return if (resId != null) {
getString(resId)
} else {
id
}
}
@@ -0,0 +1,138 @@
package de.markusfisch.android.binaryeye.content
data class IdlInfo(
var iin: String? = null,
val elements: MutableMap<String, String> = linkedMapOf()
)
object IdlParser {
private fun resolveSex(value: String): String = when (value.firstOrNull()) {
'1' -> "M"
'2' -> "F"
'9' -> "X"
else -> value
}
private fun skipWhitespace(s: String, p: Int): Int {
var i = p
while (i < s.length && s[i].isWhitespace()) {
++i;
}
return i
}
private fun findSubType(s: String, idlInfo: IdlInfo): String {
val len = s.length
var p = skipWhitespace(s, 0)
if (p < len && s[p] == '@') {
while (p < len) {
if (!s[p].isDigit()) {
++p
continue
}
var d = p
while (d < len && s[d].isDigit()) {
++d
}
if (d - p < 8 || len - d < 3) {
p = d
continue
}
val code = s.substring(d, d + 2)
if (code != "DL" && code != "ID") {
p = d + 2
continue
}
idlInfo.elements["DL"] = code
d += 2
while (d < len) {
while (d < len && s[d] !in "DI") {
++d
}
if (len - d < 2) break
val sec = s.substring(d, d + 2)
if (sec == "DL" || sec == "ID") {
return s.substring(d + 2)
}
++d
}
break
}
}
return s
}
private fun findIIN(s: String): String? {
val ansi = s.indexOf("ANSI").takeIf { it >= 0 } ?: return null
var p = ansi + 4
val len = s.length
val whitespaceSkipped = skipWhitespace(s, p)
if (whitespaceSkipped == p) {
return null
}
p = whitespaceSkipped
val iinStart = p
var digits = 0
while (digits < 6 && p < len && s[p].isDigit()) {
++p
++digits
}
return s.substring(iinStart, p)
}
fun parse(s: String?): IdlInfo? {
if (s.isNullOrEmpty()) {
return null
}
val idlInfo = IdlInfo().apply {
iin = findIIN(s)
}
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
}
// 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)
if (key == "DBC") {
value = resolveSex(value)
}
idlInfo.elements[key] = value.trim()
}
}
skipSubtype = p < len && data[p] == '\r'
start = p + 1
}
return if (idlInfo.elements.isEmpty()) null else idlInfo
}
}
@@ -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"))
)
}
}
@@ -5,15 +5,17 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.support.v4.content.FileProvider
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)
@@ -33,29 +35,38 @@ fun Context.startIntent(intent: Intent): Boolean = try {
// necessary.
startActivity(intent)
true
} catch (e: ActivityNotFoundException) {
} catch (_: ActivityNotFoundException) {
false
} catch (_: SecurityException) {
false
}
fun Context.openUrl(url: String, silent: Boolean = false): Boolean {
val intent = Intent(Intent.ACTION_VIEW, url.parseAndNormalizeUri())
return openUri(url.parseAndNormalizeUri(), silent)
}
fun Context.openUri(uri: Uri, silent: Boolean = false): Boolean {
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 = Uri.parse(this).let {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
it.normalizeScheme()
} else {
it
}
}
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
})
@@ -91,7 +102,7 @@ fun <T> Context.shareAsFile(content: T, fileName: String) {
FileOutputStream(file).use {
it.write(bytes)
}
} catch (e: IOException) {
} catch (_: IOException) {
toast(R.string.error_saving_file)
return
}
@@ -118,7 +129,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()
}
@@ -2,7 +2,6 @@ package de.markusfisch.android.binaryeye.database
import android.content.Context
import android.database.Cursor
import de.markusfisch.android.binaryeye.content.toHexString
import de.markusfisch.android.binaryeye.io.writeExternalFile
import java.io.ByteArrayOutputStream
import java.io.OutputStream
@@ -30,10 +29,11 @@ private fun exportCsv(
return
}
val columns = arrayOf(
Database.SCANS_PINNED,
Database.SCANS_DATETIME,
Database.SCANS_FORMAT,
Database.SCANS_NAME,
Database.SCANS_CONTENT,
Database.SCANS_TEXT,
Database.SCANS_ERROR_CORRECTION_LEVEL,
Database.SCANS_VERSION,
Database.SCANS_SEQUENCE_SIZE,
@@ -47,7 +47,7 @@ private fun exportCsv(
val indices = columns.map {
cursor.getColumnIndex(it)
}
val contentIndex = cursor.getColumnIndex(Database.SCANS_CONTENT)
val contentIndex = cursor.getColumnIndex(Database.SCANS_TEXT)
val rawIndex = cursor.getColumnIndex(Database.SCANS_RAW)
outputStream.write(
columns.joinToString(
@@ -1,15 +1,7 @@
package de.markusfisch.android.binaryeye.database
import android.database.Cursor
// Overwrite Kotlin's ".use" function for Cursor because Cursor cannot
// be cast to Closeable below API level 16. This should be removed when
// the minSDK is increased to at least JELLY_BEAN (16).
inline fun <R> Cursor.use(block: (Cursor) -> R): R = try {
block.invoke(this)
} finally {
close()
}
import de.markusfisch.android.zxingcpp.ZxingCpp
fun Cursor.getInt(name: String): Int {
val idx = getColumnIndex(name)
@@ -30,3 +22,18 @@ fun Cursor.getBlob(name: String): ByteArray? {
val idx = getColumnIndex(name)
return if (idx < 0) null else getBlob(idx)
}
fun Cursor.getBitMatrix(
widthName: String,
heightName: String,
dataName: String
): ZxingCpp.BitMatrix? {
val width = getInt(widthName)
val height = getInt(heightName)
val data = getBlob(dataName)
return if (width > 0 && height > 0 && data != null) {
ZxingCpp.BitMatrix(width, height, data)
} else {
null
}
}
@@ -6,34 +6,47 @@ 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(
fun getScans(query: String? = null): Cursor = db.rawQuery(
"""SELECT
$SCANS_ID,
$SCANS_PINNED,
$SCANS_DATETIME,
$SCANS_NAME,
$SCANS_CONTENT,
$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(
fun getScansDetailed(query: String? = null): Cursor = db.rawQuery(
"""SELECT
$SCANS_ID,
$SCANS_PINNED,
$SCANS_DATETIME,
$SCANS_NAME,
$SCANS_CONTENT,
$SCANS_TEXT,
$SCANS_RAW,
$SCANS_FORMAT,
$SCANS_ERROR_CORRECTION_LEVEL,
@@ -44,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)
)
@@ -55,8 +69,9 @@ class Database {
query: String?,
prefix: String = "WHERE"
) = if (query?.isNotEmpty() == true) {
"""$prefix $SCANS_CONTENT LIKE ?
OR $SCANS_NAME LIKE ?"""
"""$prefix $SCANS_TEXT LIKE ?
OR $SCANS_NAME LIKE ?
OR $SCANS_FORMAT LIKE ?"""
} else {
""
}
@@ -65,17 +80,18 @@ class Database {
query: String?
) = if (query?.isNotEmpty() == true) {
val instr = "%$query%"
arrayOf(instr, instr)
arrayOf(instr, instr, instr)
} else {
null
}
fun getScansDetailed(ids: LongArray): Cursor? = db.rawQuery(
fun getScansDetailed(ids: LongArray): Cursor = db.rawQuery(
"""SELECT
$SCANS_ID,
$SCANS_PINNED,
$SCANS_DATETIME,
$SCANS_NAME,
$SCANS_CONTENT,
$SCANS_TEXT,
$SCANS_RAW,
$SCANS_FORMAT,
$SCANS_ERROR_CORRECTION_LEVEL,
@@ -86,41 +102,56 @@ 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 (?)
ORDER BY $SCANS_DATETIME DESC
""".trimMargin(), arrayOf(ids.joinToString(","))
WHERE $SCANS_ID IN (${ids.joinToString(",")})
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_CONTENT,
$SCANS_TEXT,
$SCANS_RAW,
$SCANS_FORMAT,
$SCANS_ERROR_CORRECTION_LEVEL,
$SCANS_VERSION,
$SCANS_DATA_MASK,
$SCANS_SYMBOL_WIDTH,
$SCANS_SYMBOL_HEIGHT,
$SCANS_SYMBOL_DATA,
$SCANS_SEQUENCE_SIZE,
$SCANS_SEQUENCE_INDEX,
$SCANS_SEQUENCE_ID,
$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")
)?.use {
).use {
if (it.moveToFirst()) {
Scan(
it.getString(SCANS_CONTENT),
it.getString(SCANS_TEXT),
it.getBlob(SCANS_RAW),
BarcodeFormat.valueOf(it.getString(SCANS_FORMAT)),
BarcodeFormat.valueOf(
it.getString(SCANS_FORMAT).migrateBarcodeFormatName()
),
it.getString(SCANS_ERROR_CORRECTION_LEVEL),
it.getString(SCANS_VERSION),
it.getInt(SCANS_DATA_MASK),
it.getBitMatrix(
SCANS_SYMBOL_WIDTH,
SCANS_SYMBOL_HEIGHT,
SCANS_SYMBOL_DATA
),
it.getInt(SCANS_SEQUENCE_SIZE),
it.getInt(SCANS_SEQUENCE_INDEX),
it.getString(SCANS_SEQUENCE_ID),
@@ -131,45 +162,71 @@ 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
}
}
fun insertScan(scan: Scan): Long = db.insert(
SCANS,
null,
ContentValues().apply {
put(SCANS_DATETIME, scan.dateTime)
put(SCANS_CONTENT, scan.content)
if (scan.raw != null) {
put(SCANS_RAW, scan.raw)
}
put(SCANS_FORMAT, scan.format.name)
scan.errorCorrectionLevel?.let {
put(SCANS_ERROR_CORRECTION_LEVEL, it)
}
put(SCANS_VERSION, scan.version)
put(SCANS_SEQUENCE_SIZE, scan.sequenceSize)
put(SCANS_SEQUENCE_INDEX, scan.sequenceIndex)
put(SCANS_SEQUENCE_ID, scan.sequenceId)
scan.country?.let { put(SCANS_GTIN_COUNTRY, it) }
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) }
if (prefs.ignoreConsecutiveDuplicates) {
fun insertScan(scan: Scan): Long {
when (prefs.ignoreDuplicates()) {
Preferences.Companion.IgnoreDuplicates.Consecutive -> {
val id = getIdOfLastScan(
get(SCANS_CONTENT) as String,
get(SCANS_RAW) as ByteArray?,
scan.text,
scan.raw,
scan.format
)
if (id > 0L) {
return id
}
}
Preferences.Companion.IgnoreDuplicates.Any -> {
val id = getIdOfScanByContent(scan.text, scan.format)
if (id > 0L) {
return id
}
}
else -> Unit
}
)
return db.insert(
SCANS,
null,
ContentValues().apply {
put(SCANS_DATETIME, scan.dateTime)
put(SCANS_TEXT, scan.text)
if (scan.raw != null) {
put(SCANS_RAW, scan.raw)
}
put(SCANS_FORMAT, scan.format.name)
scan.errorCorrectionLevel?.let {
put(SCANS_ERROR_CORRECTION_LEVEL, it)
}
put(SCANS_VERSION, scan.version)
put(SCANS_DATA_MASK, scan.dataMask)
if (scan.symbol != null) {
put(SCANS_SYMBOL_WIDTH, scan.symbol.width)
put(SCANS_SYMBOL_HEIGHT, scan.symbol.height)
put(SCANS_SYMBOL_DATA, scan.symbol.data)
}
put(SCANS_SEQUENCE_SIZE, scan.sequenceSize)
put(SCANS_SEQUENCE_INDEX, scan.sequenceIndex)
put(SCANS_SEQUENCE_ID, scan.sequenceId)
scan.country?.let { put(SCANS_GTIN_COUNTRY, it) }
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
)
}
)
}
private fun getIdOfLastScan(
content: String,
@@ -178,17 +235,17 @@ class Database {
): Long = db.rawQuery(
"""SELECT
$SCANS_ID,
$SCANS_CONTENT,
$SCANS_TEXT,
$SCANS_RAW,
$SCANS_FORMAT
FROM $SCANS
ORDER BY $SCANS_ID DESC
LIMIT 1
""".trimMargin(), null
)?.use {
).use {
if (it.count > 0 &&
it.moveToFirst() &&
it.getString(SCANS_CONTENT) == content &&
it.getString(SCANS_TEXT) == content &&
(raw == null || it.getBlob(SCANS_RAW)
?.contentEquals(raw) == true) &&
it.getString(SCANS_FORMAT) == format.name
@@ -197,7 +254,26 @@ class Database {
} else {
0L
}
} ?: 0L
}
private fun getIdOfScanByContent(
content: String,
format: BarcodeFormat
): Long = db.rawQuery(
"""SELECT
$SCANS_ID
FROM $SCANS
WHERE $SCANS_TEXT = ?
AND $SCANS_FORMAT = ?
LIMIT 1
""".trimMargin(), arrayOf(content, format.name)
).use {
if (it.moveToFirst()) {
it.getLong(SCANS_ID)
} else {
0L
}
}
fun removeScan(id: Long) {
db.delete(SCANS, "$SCANS_ID = ?", arrayOf("$id"))
@@ -213,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, 6) {
SQLiteOpenHelper(context, FILE_NAME, null, 11) {
override fun onCreate(db: SQLiteDatabase) {
db.createScans()
}
@@ -239,6 +365,21 @@ class Database {
if (oldVersion < 6) {
db.migrateToVersionString()
}
if (oldVersion < 7) {
db.addDataMaskColumn()
}
if (oldVersion < 8) {
db.addSymbolColumns()
}
if (oldVersion < 9) {
db.migrateToNativeFormatNames()
}
if (oldVersion < 10) {
db.addPinnedColumn()
}
if (oldVersion < 11) {
db.addContentTypeColumn()
}
}
}
@@ -248,12 +389,16 @@ class Database {
const val SCANS_ID = "_id"
const val SCANS_DATETIME = "_datetime"
const val SCANS_NAME = "name"
const val SCANS_CONTENT = "content"
const val SCANS_TEXT = "content"
const val SCANS_RAW = "raw"
const val SCANS_FORMAT = "format"
const val SCANS_ERROR_CORRECTION_LEVEL = "error_correction_level"
const val SCANS_VERSION_NUMBER = "version_number"
const val SCANS_VERSION = "version"
const val SCANS_DATA_MASK = "data_mask"
const val SCANS_SYMBOL_WIDTH = "symbol_width"
const val SCANS_SYMBOL_HEIGHT = "symbol_height"
const val SCANS_SYMBOL_DATA = "symbol_data"
const val SCANS_ISSUE_NUMBER = "issue_number"
const val SCANS_ORIENTATION = "orientation"
const val SCANS_OTHER_META_DATA = "other_meta_data"
@@ -268,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())
@@ -276,18 +423,24 @@ class Database {
$SCANS_ID INTEGER PRIMARY KEY AUTOINCREMENT,
$SCANS_DATETIME DATETIME NOT NULL,
$SCANS_NAME TEXT,
$SCANS_CONTENT TEXT NOT NULL,
$SCANS_TEXT TEXT NOT NULL,
$SCANS_RAW BLOB,
$SCANS_FORMAT TEXT NOT NULL,
$SCANS_ERROR_CORRECTION_LEVEL TEXT,
$SCANS_VERSION TEXT,
$SCANS_DATA_MASK INTEGER,
$SCANS_SYMBOL_WIDTH INTEGER,
$SCANS_SYMBOL_HEIGHT INTEGER,
$SCANS_SYMBOL_DATA BLOB,
$SCANS_SEQUENCE_SIZE INTEGER,
$SCANS_SEQUENCE_INDEX INTEGER,
$SCANS_SEQUENCE_ID TEXT,
$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()
)
}
@@ -308,7 +461,7 @@ class Database {
}
private fun SQLiteDatabase.addNameColumn() {
execSQL("ALTER TABLE $SCANS ADD COLUMN $SCANS_NAME TEXT".trimMargin())
execSQL("ALTER TABLE $SCANS ADD COLUMN $SCANS_NAME TEXT")
}
private fun SQLiteDatabase.migrateToZxingCpp() {
@@ -328,9 +481,39 @@ class Database {
execSQL("UPDATE $SCANS SET $SCANS_GTIN_ISSUE_NUMBER = $SCANS_ISSUE_NUMBER")
}
private fun SQLiteDatabase.migrateToNativeFormatNames() {
for ((oldName, newName) in oldToNewFormatNames) {
execSQL(
"UPDATE $SCANS SET $SCANS_FORMAT = ? WHERE $SCANS_FORMAT = ?",
arrayOf(newName, oldName)
)
}
}
private fun SQLiteDatabase.migrateToVersionString() {
execSQL("ALTER TABLE $SCANS ADD $SCANS_VERSION TEXT")
execSQL("UPDATE $SCANS SET $SCANS_VERSION = $SCANS_VERSION_NUMBER")
}
private fun SQLiteDatabase.addDataMaskColumn() {
execSQL("ALTER TABLE $SCANS ADD $SCANS_DATA_MASK INTEGER")
execSQL("UPDATE $SCANS SET $SCANS_DATA_MASK = -1")
}
private fun SQLiteDatabase.addSymbolColumns() {
execSQL("ALTER TABLE $SCANS ADD $SCANS_SYMBOL_WIDTH INTEGER")
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")
}
}
}
@@ -2,7 +2,6 @@ package de.markusfisch.android.binaryeye.database
import android.content.Context
import android.database.Cursor
import de.markusfisch.android.binaryeye.content.toHexString
import de.markusfisch.android.binaryeye.io.writeExternalFile
import org.json.JSONArray
import org.json.JSONException
@@ -22,10 +21,11 @@ fun Cursor.exportJson(): String? {
return null
}
val columns = arrayOf(
Database.SCANS_PINNED,
Database.SCANS_DATETIME,
Database.SCANS_FORMAT,
Database.SCANS_NAME,
Database.SCANS_CONTENT,
Database.SCANS_TEXT,
Database.SCANS_ERROR_CORRECTION_LEVEL,
Database.SCANS_VERSION,
Database.SCANS_SEQUENCE_SIZE,
@@ -39,7 +39,7 @@ fun Cursor.exportJson(): String? {
val indices = columns.map {
Pair(getColumnIndex(it), it)
}
val contentIndex = getColumnIndex(Database.SCANS_CONTENT)
val contentIndex = getColumnIndex(Database.SCANS_TEXT)
val rawIndex = getColumnIndex(Database.SCANS_RAW)
val root = JSONArray()
do {
@@ -70,7 +70,7 @@ private fun Cursor.toJsonObject(
obj.put(it.second, value ?: "")
}
obj
} catch (e: JSONException) {
} catch (_: JSONException) {
obj
}
}
@@ -1,21 +1,24 @@
package de.markusfisch.android.binaryeye.database
import android.graphics.Bitmap
import android.os.Build
import android.os.Parcel
import android.os.Parcelable
import android.os.Bundle
import android.text.format.DateFormat
import de.markusfisch.android.zxingcpp.ZxingCpp
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 java.util.Locale
data class Scan(
val content: String,
val text: String,
val raw: ByteArray?,
val format: BarcodeFormat,
val errorCorrectionLevel: String? = null,
val version: String? = null,
val dataMask: Int = -1,
val symbol: BitMatrix? = null,
val sequenceSize: Int = -1,
val sequenceIndex: Int = -1,
val sequenceId: String = "",
@@ -25,8 +28,10 @@ data class Scan(
val issueNumber: String? = null,
val dateTime: String = getDateTime(),
var id: Long = 0L,
val label: String? = null
) : Parcelable {
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.
override fun equals(other: Any?): Boolean {
@@ -41,19 +46,24 @@ data class Scan(
return id == other.id &&
dateTime == other.dateTime &&
content == other.content &&
text == other.text &&
((raw == null && other.raw == null) ||
(raw != null && other.raw != null && raw.contentEquals(other.raw))) &&
(raw != null && other.raw != null &&
raw.contentEquals(other.raw))) &&
format == other.format &&
errorCorrectionLevel == other.errorCorrectionLevel &&
version == other.version &&
dataMask == other.dataMask &&
sequenceSize == other.sequenceSize &&
sequenceIndex == other.sequenceIndex &&
sequenceId == other.sequenceId &&
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
@@ -61,11 +71,12 @@ data class Scan(
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + dateTime.hashCode()
result = 31 * result + content.hashCode()
result = 31 * result + text.hashCode()
result = 31 * result + (raw?.contentHashCode() ?: 0)
result = 31 * result + format.hashCode()
result = 31 * result + (errorCorrectionLevel?.hashCode() ?: 0)
result = 31 * result + version.hashCode()
result = 31 * result + dataMask
result = 31 * result + sequenceSize
result = 31 * result + sequenceIndex
result = 31 * result + sequenceId.hashCode()
@@ -73,56 +84,12 @@ 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
}
private constructor(parcel: Parcel) : this(
content = parcel.readString() ?: "",
raw = parcel.readSizedByteArray(),
format = BarcodeFormat.valueOf(parcel.readString() ?: ""),
errorCorrectionLevel = parcel.readString(),
version = parcel.readString(),
sequenceSize = parcel.readInt(),
sequenceIndex = parcel.readInt(),
sequenceId = parcel.readString() ?: "",
country = parcel.readString(),
addOn = parcel.readString(),
price = parcel.readString(),
issueNumber = parcel.readString(),
dateTime = parcel.readString() ?: "",
id = parcel.readLong(),
label = parcel.readString()
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.apply {
writeString(content)
writeSizedByteArray(raw)
writeString(format.name)
writeString(errorCorrectionLevel)
writeString(version)
writeInt(sequenceSize)
writeInt(sequenceIndex)
writeString(sequenceId)
writeString(country)
writeString(addOn)
writeString(price)
writeString(issueNumber)
writeString(dateTime)
writeLong(id)
writeString(label)
}
}
override fun describeContents() = 0
companion object {
@JvmField
val CREATOR = object : Parcelable.Creator<Scan> {
override fun createFromParcel(parcel: Parcel) = Scan(parcel)
override fun newArray(size: Int) = arrayOfNulls<Scan>(size)
}
}
}
fun Result.toScan(): Scan {
@@ -132,12 +99,14 @@ fun Result.toScan(): Scan {
ContentType.ISO15434,
ContentType.TEXT -> text
else -> ""
else -> "" // Show as binary.
},
rawBytes,
format,
ecLevel,
version,
dataMask,
symbol,
sequenceSize,
sequenceIndex,
sequenceId,
@@ -148,65 +117,101 @@ fun Result.toScan(): Scan {
)
}
data class Recreation(
val format: BarcodeFormat,
val ecLevel: Int,
val size: Int,
val margin: Int
) {
fun <T> encode(content: T): Bitmap? = try {
ZxingCpp.encodeAsBitmap(
content, format, size, size, margin, ecLevel
fun Scan.toBundle() = Bundle().apply {
putString(SCAN_TEXT, text)
putByteArray(SCAN_RAW, raw)
putString(SCAN_FORMAT, format.name)
putString(SCAN_ERROR_CORRECTION_LEVEL, errorCorrectionLevel)
putString(SCAN_VERSION, version)
putInt(SCAN_DATA_MASK, dataMask)
putInt(SCAN_SYMBOL_WIDTH, symbol?.width ?: 0)
putInt(SCAN_SYMBOL_HEIGHT, symbol?.height ?: 0)
putByteArray(SCAN_SYMBOL_DATA, symbol?.data)
putInt(SCAN_SEQUENCE_SIZE, sequenceSize)
putInt(SCAN_SEQUENCE_INDEX, sequenceIndex)
putString(SCAN_SEQUENCE_ID, sequenceId)
putString(SCAN_COUNTRY, country)
putString(SCAN_ADD_ON, addOn)
putString(SCAN_PRICE, price)
putString(SCAN_ISSUE_NUMBER, issueNumber)
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? {
val formatName = getString(SCAN_FORMAT)?.migrateBarcodeFormatName()
if (formatName.isNullOrEmpty()) {
return null
}
return try {
Scan(
text = getString(SCAN_TEXT) ?: "",
raw = getByteArray(SCAN_RAW),
format = BarcodeFormat.valueOf(formatName),
errorCorrectionLevel = getString(SCAN_ERROR_CORRECTION_LEVEL),
version = getString(SCAN_VERSION),
dataMask = getInt(SCAN_DATA_MASK, -1),
symbol = getBitMatrix(),
sequenceSize = getInt(SCAN_SEQUENCE_SIZE, -1),
sequenceIndex = getInt(SCAN_SEQUENCE_INDEX, -1),
sequenceId = getString(SCAN_SEQUENCE_ID) ?: "",
country = getString(SCAN_COUNTRY),
addOn = getString(SCAN_ADD_ON),
price = getString(SCAN_PRICE),
issueNumber = getString(SCAN_ISSUE_NUMBER),
dateTime = getString(SCAN_DATE_TIME) ?: getDateTime(),
id = getLong(SCAN_ID, 0L),
label = getString(SCAN_LABEL),
pinned = getBoolean(SCAN_PINNED, false),
contentType = parsedContentTypeFromName(getString(SCAN_CONTENT_TYPE))
)
} catch (_: RuntimeException) {
} catch (_: IllegalArgumentException) {
null
}
}
fun Scan.toRecreation(
size: Int = 128,
margin: Int = -1
) = Recreation(
format,
when (errorCorrectionLevel) {
"L" -> 0
"M" -> 4
"Q" -> 6
"H" -> 8
else -> -1
},
size,
margin
)
private fun getDateTime(
time: Long = System.currentTimeMillis()
) = DateFormat.format(
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) {
// Up to API level 17, 'k' was interpreted as 'H', which wasn't
// implemented until API level 18.
"yyyy-MM-dd kk:mm:ss"
} else {
"yyyy-MM-dd HH:mm:ss"
},
"yyyy-MM-dd HH:mm:ss",
time
).toString()
).toString() + String.format(
Locale.getDefault(),
":%03d",
time % 1000
)
private fun Parcel.writeSizedByteArray(array: ByteArray?) {
val size = array?.size ?: 0
writeInt(size)
if (size > 0) {
writeByteArray(array)
private fun Bundle.getBitMatrix(): BitMatrix? {
val width = getInt(SCAN_SYMBOL_WIDTH, 0)
val height = getInt(SCAN_SYMBOL_HEIGHT, 0)
val data = getByteArray(SCAN_SYMBOL_DATA)
if (width < 1 || height < 1 || data == null) {
return null
}
return BitMatrix(width, height, data)
}
private fun Parcel.readSizedByteArray(): ByteArray? {
val size = readInt()
return if (size > 0) {
val array = ByteArray(size)
readByteArray(array)
array
} else {
null
}
}
private const val SCAN_TEXT = "text"
private const val SCAN_RAW = "raw"
private const val SCAN_FORMAT = "format"
private const val SCAN_ERROR_CORRECTION_LEVEL = "error_correction_level"
private const val SCAN_VERSION = "version"
private const val SCAN_DATA_MASK = "data_mask"
private const val SCAN_SYMBOL_WIDTH = "symbol_width"
private const val SCAN_SYMBOL_HEIGHT = "symbol_height"
private const val SCAN_SYMBOL_DATA = "symbol_data"
private const val SCAN_SEQUENCE_SIZE = "sequence_size"
private const val SCAN_SEQUENCE_INDEX = "sequence_index"
private const val SCAN_SEQUENCE_ID = "sequence_id"
private const val SCAN_COUNTRY = "country"
private const val SCAN_ADD_ON = "add_on"
private const val SCAN_PRICE = "price"
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,414 +0,0 @@
package de.markusfisch.android.binaryeye.fragment
import android.annotation.SuppressLint
import android.app.AlertDialog
import android.content.Context
import android.graphics.Bitmap
import android.os.Build
import android.os.Bundle
import android.support.v4.app.Fragment
import android.view.*
import android.widget.EditText
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.app.db
import de.markusfisch.android.binaryeye.app.hasWritePermission
import de.markusfisch.android.binaryeye.content.copyToClipboard
import de.markusfisch.android.binaryeye.content.shareFile
import de.markusfisch.android.binaryeye.content.shareText
import de.markusfisch.android.binaryeye.database.toScan
import de.markusfisch.android.binaryeye.graphics.COLOR_BLACK
import de.markusfisch.android.binaryeye.graphics.COLOR_WHITE
import de.markusfisch.android.binaryeye.io.addSuffixIfNotGiven
import de.markusfisch.android.binaryeye.io.toSaveResult
import de.markusfisch.android.binaryeye.io.writeExternalFile
import de.markusfisch.android.binaryeye.view.doOnApplyWindowInsets
import de.markusfisch.android.binaryeye.view.setPaddingFromWindowInsets
import de.markusfisch.android.binaryeye.widget.ConfinedScalingImageView
import de.markusfisch.android.binaryeye.widget.toast
import de.markusfisch.android.zxingcpp.ZxingCpp
import de.markusfisch.android.zxingcpp.ZxingCpp.BarcodeFormat
import kotlinx.coroutines.*
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.OutputStream
import java.util.*
import kotlin.math.min
class BarcodeFragment : Fragment() {
private enum class FileType {
PNG, SVG, TXT
}
private val parentJob = Job()
private val scope = CoroutineScope(Dispatchers.IO + parentJob)
private lateinit var barcode: Barcode<*>
private lateinit var addToHistoryItem: MenuItem
override fun onCreate(state: Bundle?) {
super.onCreate(state)
setHasOptionsMenu(true)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
state: Bundle?
): View? {
val ac = activity ?: return null
ac.setTitle(R.string.view_barcode)
val view = inflater.inflate(
R.layout.fragment_barcode,
container,
false
)
val bitmap: Bitmap
try {
barcode = arguments?.toBarcode() ?: throw IllegalArgumentException(
"Illegal arguments"
)
// Catch exceptions from encoding.
bitmap = barcode.bitmap()
} catch (e: Exception) {
var message = e.message
if (message.isNullOrEmpty()) {
message = getString(R.string.error_encoding_barcode)
}
message?.let {
ac.toast(message)
}
fragmentManager.popBackStack()
return null
}
val imageView = view.findViewById<ConfinedScalingImageView>(
R.id.barcode
)
imageView.setImageBitmap(bitmap)
imageView.post {
// Make sure to invoke this after ScalingImageView.onLayout().
imageView.minWidth = min(
imageView.minWidth / 2f,
barcode.size.toFloat()
)
}
view.findViewById<View>(R.id.share).setOnClickListener {
it.context?.apply {
pickFileType(R.string.share_as) { fileType ->
shareAs(fileType)
}
}
}
(view.findViewById(R.id.inset_layout) as View).setPaddingFromWindowInsets()
imageView.doOnApplyWindowInsets { v, insets ->
(v as ConfinedScalingImageView).insets.set(insets)
}
return view
}
private fun Bundle.toBarcode() = Barcode(
getString(CONTENT_TEXT) ?: getByteArray(CONTENT_RAW) ?: throw IllegalArgumentException(
"content cannot be null"
),
BarcodeFormat.valueOf(
getString(FORMAT) ?: throw IllegalArgumentException(
"format cannot be null"
)
),
getInt(SIZE),
getInt(MARGIN),
getInt(EC_LEVEL),
Colors.entries[getInt(COLORS)]
)
override fun onDestroyView() {
super.onDestroyView()
parentJob.cancel()
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.fragment_barcode, menu)
addToHistoryItem = menu.findItem(R.id.add_to_history)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.add_to_history -> {
readAndAddToHistory(barcode.bitmap(), barcode.format)
addToHistoryItem.isVisible = false
context.toast(R.string.added_to_history)
true
}
R.id.copy_to_clipboard -> {
context.apply {
copyToClipboard(barcode.text())
toast(R.string.copied_to_clipboard)
}
true
}
R.id.export_to_file -> {
context.pickFileType(R.string.export_as) {
askForFileNameAndSave(it)
}
true
}
else -> super.onOptionsItemSelected(item)
}
}
private fun Context.pickFileType(
title: Int,
action: (FileType) -> Unit
) {
val fileTypes = FileType.entries.toTypedArray()
AlertDialog.Builder(this)
.setTitle(title)
.setItems(fileTypes.map { it.name }.toTypedArray()) { _, which ->
action(fileTypes[which])
}
.show()
}
// Dialogs do not have a parent view.
@SuppressLint("InflateParams")
private fun askForFileNameAndSave(fileType: FileType) {
val ac = activity ?: return
// Write permission is only required before Android Q.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q &&
!ac.hasWritePermission { askForFileNameAndSave(fileType) }
) {
return
}
val view = ac.layoutInflater.inflate(R.layout.dialog_save_file, null)
val editText = view.findViewById<EditText>(R.id.file_name)
editText.setText(
encodeFileName("${barcode.format}_${barcode.content}")
)
AlertDialog.Builder(ac)
.setView(view)
.setPositiveButton(android.R.string.ok) { _, _ ->
val fileName = editText.text.toString()
when (fileType) {
FileType.PNG -> saveAs(
addSuffixIfNotGiven(fileName, ".png"),
MIME_PNG
) {
barcode.bitmap().saveAsPng(it)
}
FileType.SVG -> saveAs(
addSuffixIfNotGiven(fileName, ".svg"),
MIME_SVG
) { outputStream ->
outputStream.write(barcode.svg().toByteArray())
}
FileType.TXT -> saveAs(
addSuffixIfNotGiven(fileName, ".txt"),
MIME_TXT
) { outputStream ->
outputStream.write(barcode.text().toByteArray())
}
}
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
}
.show()
}
private fun saveAs(
fileName: String,
mimeType: String,
write: (outputStream: OutputStream) -> Unit
) {
val ac = activity ?: return
scope.launch(Dispatchers.IO) {
val message = ac.writeExternalFile(fileName, mimeType, write).toSaveResult()
withContext(Dispatchers.Main) {
ac.toast(message)
}
}
}
private fun Context.shareAs(fileType: FileType) {
when (fileType) {
FileType.PNG -> share(barcode.bitmap())
FileType.SVG -> shareText(barcode.svg(), MIME_SVG)
FileType.TXT -> shareText(barcode.text())
}
}
private fun Context.share(bitmap: Bitmap) {
scope.launch(Dispatchers.IO) {
val file = File(
externalCacheDir,
"shared_barcode.png"
)
val success = try {
FileOutputStream(file).use {
bitmap.saveAsPng(it)
}
true
} catch (e: IOException) {
false
}
withContext(Dispatchers.Main) {
if (success) {
shareFile(file, "image/png")
} else {
toast(R.string.error_saving_file)
}
}
}
}
companion object {
private const val CONTENT_TEXT = "content_text"
private const val CONTENT_RAW = "content_raw"
private const val FORMAT = "format"
private const val SIZE = "size"
private const val MARGIN = "margin"
private const val EC_LEVEL = "ec_level"
private const val COLORS = "colors"
private const val MIME_PNG = "image/png"
private const val MIME_SVG = "image/svg+xmg"
private const val MIME_TXT = "text/plain"
fun <T> newInstance(
content: T,
format: BarcodeFormat,
size: Int,
margin: Int,
ecLevel: Int = -1,
colors: Int = 0
): Fragment {
val args = Bundle()
when (content) {
is String -> {
args.putString(CONTENT_TEXT, content)
}
is ByteArray -> {
args.putByteArray(CONTENT_RAW, content)
}
else -> {
throw IllegalArgumentException(
"content must be a String of a ByteArray"
)
}
}
args.putString(FORMAT, format.name)
args.putInt(SIZE, size)
args.putInt(MARGIN, margin)
args.putInt(EC_LEVEL, ecLevel)
args.putInt(COLORS, colors)
val fragment = BarcodeFragment()
fragment.arguments = args
return fragment
}
}
}
private data class Barcode<T>(
val content: T,
val format: BarcodeFormat,
val size: Int,
val margin: Int,
val ecLevel: Int,
val colors: Colors
) {
private var _bitmap: Bitmap? = null
fun bitmap(): Bitmap {
val b = _bitmap ?: ZxingCpp.encodeAsBitmap(
content, format, size, size, margin, ecLevel,
setColor = colors.foregroundColor(),
unsetColor = colors.backgroundColor()
)
_bitmap = b
return b
}
private var _svg: String? = null
fun svg(): String {
val s = _svg ?: ZxingCpp.encodeAsSvg(
content, format, margin, ecLevel
)
_svg = s
return s
}
private var _text: String? = null
fun text(): String {
val t = _text ?: ZxingCpp.encodeAsText(
content, format, margin, ecLevel,
inverted = colors == Colors.BLACK_ON_WHITE
)
_text = t
return t
}
}
private enum class Colors {
BLACK_ON_WHITE,
WHITE_ON_BLACK,
BLACK_ON_TRANSPARENT,
WHITE_ON_TRANSPARENT;
fun foregroundColor(): Int = when (this) {
BLACK_ON_WHITE,
BLACK_ON_TRANSPARENT -> COLOR_BLACK
WHITE_ON_BLACK,
WHITE_ON_TRANSPARENT -> COLOR_WHITE
}
fun backgroundColor(): Int = when (this) {
BLACK_ON_WHITE -> COLOR_WHITE
WHITE_ON_BLACK -> COLOR_BLACK
BLACK_ON_TRANSPARENT,
WHITE_ON_TRANSPARENT -> 0
}
}
private fun Bitmap.saveAsPng(outputStream: OutputStream, quality: Int = 90) =
compress(Bitmap.CompressFormat.PNG, quality, outputStream)
private val fileNameCharacters = "[^A-Za-z0-9]".toRegex()
private fun encodeFileName(name: String): String = fileNameCharacters
.replace(name, "_")
.take(16)
.trim('_')
.lowercase(Locale.getDefault())
private fun readAndAddToHistory(
bitmap: Bitmap,
format: BarcodeFormat
) {
ZxingCpp.readBitmap(
bitmap,
0, 0,
bitmap.width, bitmap.height,
0,
ZxingCpp.ReaderOptions(
tryHarder = true,
tryRotate = true,
tryInvert = true,
tryDownscale = true,
maxNumberOfSymbols = 1,
formats = setOf(format)
)
)?.first {
db.insertScan(it.toScan())
true
}
}
@@ -1,645 +0,0 @@
package de.markusfisch.android.binaryeye.fragment
import android.os.Build
import android.os.Bundle
import android.support.design.widget.FloatingActionButton
import android.support.v4.app.Fragment
import android.text.Editable
import android.text.Html
import android.text.TextWatcher
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
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.TableLayout
import android.widget.TableRow
import android.widget.TextView
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.actions.ActionRegistry
import de.markusfisch.android.binaryeye.actions.IAction
import de.markusfisch.android.binaryeye.actions.wifi.WifiAction
import de.markusfisch.android.binaryeye.actions.wifi.WifiConnector
import de.markusfisch.android.binaryeye.activity.MainActivity
import de.markusfisch.android.binaryeye.adapter.prettifyFormatName
import de.markusfisch.android.binaryeye.app.addFragment
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.copyToClipboard
import de.markusfisch.android.binaryeye.content.shareAsFile
import de.markusfisch.android.binaryeye.content.shareText
import de.markusfisch.android.binaryeye.content.toHexString
import de.markusfisch.android.binaryeye.content.wipeLastShareFile
import de.markusfisch.android.binaryeye.database.Recreation
import de.markusfisch.android.binaryeye.database.Scan
import de.markusfisch.android.binaryeye.database.toRecreation
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.view.setPaddingFromWindowInsets
import de.markusfisch.android.binaryeye.widget.toast
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.roundToInt
class DecodeFragment : Fragment() {
private lateinit var contentView: EditText
private lateinit var formatView: TextView
private lateinit var dataView: TableLayout
private lateinit var metaView: TableLayout
private lateinit var hexView: TextView
private lateinit var stampView: TextView
private lateinit var recreationView: ImageView
private lateinit var labelView: EditText
private lateinit var fab: FloatingActionButton
private lateinit var format: String
private val parentJob = Job()
private val scope: CoroutineScope = CoroutineScope(Dispatchers.Main + parentJob)
private val dp: Float
get() = resources.displayMetrics.density
private val content: String
get() = contentView.text.toString()
private var closeAutomatically = false
private var action = ActionRegistry.DEFAULT_ACTION
private var isBinary = false
private var originalBytes: ByteArray = ByteArray(0)
private var id = 0L
private var recreation: Recreation? = null
private var label: String? = null
override fun onCreate(state: Bundle?) {
super.onCreate(state)
setHasOptionsMenu(true)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
state: Bundle?
): View {
activity?.setTitle(R.string.content)
val view = inflater.inflate(
R.layout.fragment_decode,
container,
false
)
closeAutomatically = prefs.closeAutomatically &&
activity?.intent?.hasExtra(MainActivity.DECODED) == true
val scan = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
arguments?.getParcelable(SCAN, Scan::class.java)
} else {
@Suppress("DEPRECATION")
arguments?.getParcelable(SCAN) as Scan?
} ?: throw IllegalArgumentException("DecodeFragment needs a Scan")
val originalContent = scan.content
isBinary = scan.content.isEmpty()
originalBytes = scan.raw ?: originalContent.toByteArray()
format = scan.format.name
id = scan.id
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)
initContentAndFab(originalContent)
if (prefs.showMetaData) {
metaView.fillMetaView(scan)
} else {
metaView.visibility = View.GONE
}
if (prefs.showRecreation) {
recreation = scan.toRecreation(
(200f * dp).roundToInt()
)
}
if (id > 0) {
scan.label?.let {
labelView.setText(it)
label = it
}
} else {
labelView.visibility = View.GONE
}
updateViewsAndFab(originalContent, originalBytes)
(view.findViewById(R.id.inset_layout) as View).setPaddingFromWindowInsets()
(view.findViewById(R.id.scroll_view) as View).setPaddingFromWindowInsets()
return view
}
override fun onPause() {
super.onPause()
if (id > 0) {
val newLabel = labelView.text.toString().trim()
if (newLabel != (label ?: "")) {
db.renameScan(id, newLabel)
label = newLabel
}
}
}
override fun onDestroy() {
super.onDestroy()
parentJob.cancel()
wipeLastShareFile()
}
private fun initContentAndFab(originalContent: String) {
if (isBinary) {
contentView.setText(String(originalBytes).foldNonAlNum())
contentView.isEnabled = false
fab.setImageResource(R.drawable.ic_action_save)
fab.setOnClickListener {
askForFileNameAndSave(originalBytes)
}
} else {
contentView.setText(originalContent)
contentView.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) {
updateViewsAndFab(s?.toString() ?: "")
}
override fun beforeTextChanged(
s: CharSequence?,
start: Int,
count: Int,
after: Int
) = Unit
override fun onTextChanged(
s: CharSequence?,
start: Int,
before: Int,
count: Int
) = Unit
})
fab.setOnClickListener {
executeAction(content)
}
if (prefs.openImmediately) {
executeAction(content)
}
}
}
private fun updateViewsAndFab(text: String, bytes: ByteArray? = null) {
val b = bytes ?: text.toByteArray()
resolveActionAndUpdateFab(b)
updateViews(text, b)
}
private fun resolveActionAndUpdateFab(bytes: ByteArray) {
val prevAction = action
if (!prevAction.canExecuteOn(bytes)) {
action = ActionRegistry.getAction(bytes)
}
if (prevAction !== action) {
updateFab(action)
}
}
private fun updateFab(action: IAction) {
fab.setImageResource(action.iconResId)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
fab.setOnLongClickListener { v ->
v.context.toast(action.titleResId)
true
}
} else {
fab.tooltipText = getString(action.titleResId)
}
}
private fun updateViews(text: String, bytes: ByteArray) {
formatView.text = resources.getQuantityString(
R.plurals.barcode_info,
bytes.size,
prettifyFormatName(format),
bytes.size
)
hexView.showIf(prefs.showHexDump) { v ->
v.text = hexDump(bytes)
}
recreationView.showIf(prefs.showRecreation) { v ->
val r = recreation ?: return@showIf
val content = if (isBinary) bytes else text
v.setImageBitmap(r.encode(content))
v.setOnClickListener {
fragmentManager?.addFragment(
BarcodeFragment.newInstance(
content,
r.format,
r.size,
r.margin,
r.ecLevel
)
)
}
}
dataView.fillDataView(text)
stampView.setTrackingLink(bytes, format)
}
private fun TableLayout.fillDataView(text: String) {
val items = LinkedHashMap<Int, String?>()
if (action 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"]
)
)
}
}
fill(items)
}
private fun TableLayout.fillMetaView(scan: Scan) {
val items = linkedMapOf(
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,
)
if (!scan.version.isNullOrBlank()) {
val versionString = if (scan.format.name == "QR_CODE") {
getString(
R.string.qr_version_and_modules,
scan.version,
(scan.version.toIntOrNull() ?: 0) * 4 + 17
)
} else {
scan.version
}
items.putAll(
linkedMapOf(
R.string.barcode_version_number to versionString
)
)
}
fill(items)
}
private fun TableLayout.fill(
items: LinkedHashMap<Int, String?>
) {
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)
keyView.setText(item.key)
val valueView = TextView(ctx)
valueView.setPadding(spaceBetween, 0, 0, 0)
valueView.text = text
tr.addView(keyView)
tr.addView(valueView)
addView(tr)
}
}
View.VISIBLE
}
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.fragment_decode, menu)
if (id > 0L) {
menu.findItem(R.id.remove).isVisible = true
}
if (action is WifiAction) {
menu.findItem(R.id.copy_password).isVisible = true
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.copy_password -> {
copyPasswordToClipboard()
maybeBackOrFinish()
true
}
R.id.copy_to_clipboard -> {
copyToClipboard(textOrHex())
maybeBackOrFinish()
true
}
R.id.share -> {
context?.apply {
shareText(textOrHex())
maybeBackOrFinish()
}
true
}
R.id.share_file -> {
context?.apply {
val (data, name) = if (isBinary) {
Pair(originalBytes, "barcode_content.bin")
} else {
Pair(content, "barcode_content.txt")
}
shareAsFile(data, name)
maybeBackOrFinish()
}
true
}
R.id.create -> {
fragmentManager?.addFragment(
EncodeFragment.newInstance(
if (isBinary) originalBytes else content,
format
)
)
true
}
R.id.remove -> {
db.removeScan(id)
backOrFinish()
true
}
else -> super.onOptionsItemSelected(item)
}
}
private fun textOrHex() = if (isBinary) {
originalBytes.toHexString()
} else {
content
}
private fun copyPasswordToClipboard() {
val ac = action
if (ac is WifiAction) {
ac.password?.let { password ->
activity?.apply {
copyToClipboard(password, isSensitive = true)
toast(R.string.copied_password_to_clipboard)
}
}
}
}
private fun copyToClipboard(text: String, isSensitive: Boolean = false) {
activity?.apply {
copyToClipboard(text, isSensitive)
toast(R.string.copied_to_clipboard)
}
}
private fun maybeBackOrFinish() {
if (closeAutomatically) {
backOrFinish()
}
}
private fun backOrFinish() {
val fm = fragmentManager
if (fm != null && fm.backStackEntryCount > 0) {
fm.popBackStack()
} else {
activity?.finish()
}
}
private fun executeAction(content: String) {
val ac = activity ?: return
if (content.isNotEmpty()) {
if (action is WifiAction &&
Build.VERSION.SDK_INT < Build.VERSION_CODES.Q &&
!ac.hasLocationPermission { executeAction(content) }
) {
return
}
scope.launch {
action.execute(ac, content.toByteArray())
maybeBackOrFinish()
}
}
}
private fun askForFileNameAndSave(raw: ByteArray) {
val ac = activity ?: return
// Write permission is only required before Android Q.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q &&
!ac.hasWritePermission { askForFileNameAndSave(raw) }
) {
return
}
scope.launch(Dispatchers.Main) {
val name = ac.askForFileName() ?: return@launch
withContext(Dispatchers.IO) {
val message = ac.writeExternalFile(
name,
"application/octet-stream"
) {
it.write(raw)
}.toSaveResult()
withContext(Dispatchers.Main) {
ac.toast(message)
}
}
}
}
companion object {
private const val SCAN = "scan"
fun newInstance(scan: Scan): Fragment {
val args = Bundle()
args.putParcelable(SCAN, scan)
val fragment = DecodeFragment()
fragment.arguments = args
return fragment
}
}
}
private inline fun <T : View> T.showIf(
visible: Boolean,
block: (T) -> Unit
) {
visibility = if (visible) {
block.invoke(this)
View.VISIBLE
} else {
View.GONE
}
}
private val nonAlNum = "[^a-zA-Z0-9]".toRegex()
private val multipleDots = "[…]+".toRegex()
private fun String.foldNonAlNum() = replace(nonAlNum, "")
.replace(multipleDots, "")
private fun hexDump(bytes: ByteArray, charsPerLine: Int = 33): String {
if (charsPerLine < 4 || bytes.isEmpty()) {
return ""
}
val dump = StringBuilder()
val hex = StringBuilder()
val ascii = StringBuilder()
val itemsPerLine = (charsPerLine - 1) / 4
val len = bytes.size
var i = 0
while (true) {
val ord = bytes[i]
hex.append(String.format("%02X ", ord))
ascii.append(if (ord > 31) ord.toInt().toChar() else " ")
++i
val posInLine = i % itemsPerLine
val atEnd = i >= len
var atLineEnd = posInLine == 0
if (atEnd && !atLineEnd) {
for (j in posInLine until itemsPerLine) {
hex.append(" ")
}
atLineEnd = true
}
if (atLineEnd) {
dump.append(hex.toString())
dump.append(" ")
dump.append(ascii.toString())
dump.append("\n")
hex.setLength(0)
ascii.setLength(0)
}
if (atEnd) {
break
}
}
return dump.toString()
}
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 == "DATA_MATRIX" &&
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/verfolgen.html?piececode=$trackingNumber\">Deutsche Post: $trackingNumber</a>"
}
// 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
}
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)
}
@@ -1,464 +0,0 @@
package de.markusfisch.android.binaryeye.fragment
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.support.v4.app.Fragment
import android.view.LayoutInflater
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.SeekBar
import android.widget.Spinner
import android.widget.TextView
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.adapter.prettifyFormatName
import de.markusfisch.android.binaryeye.app.addFragment
import de.markusfisch.android.binaryeye.app.prefs
import de.markusfisch.android.binaryeye.text.unescape
import de.markusfisch.android.binaryeye.view.hideSoftKeyboard
import de.markusfisch.android.binaryeye.view.setPaddingFromWindowInsets
import de.markusfisch.android.binaryeye.widget.toast
import de.markusfisch.android.zxingcpp.ZxingCpp.BarcodeFormat
import java.io.InputStream
import kotlin.math.max
import kotlin.math.min
class EncodeFragment : Fragment() {
private lateinit var formatView: Spinner
private lateinit var ecLabel: TextView
private lateinit var ecSpinner: Spinner
private lateinit var colorsLabel: TextView
private lateinit var colorsSpinner: Spinner
private lateinit var sizeView: TextView
private lateinit var sizeBarView: SeekBar
private lateinit var marginLayout: View
private lateinit var marginView: TextView
private lateinit var marginBarView: SeekBar
private lateinit var contentView: EditText
private lateinit var unescapeCheckBox: CheckBox
private val formats = arrayListOf(
BarcodeFormat.AZTEC,
BarcodeFormat.CODABAR,
BarcodeFormat.CODE_39,
BarcodeFormat.CODE_128,
BarcodeFormat.DATA_MATRIX,
BarcodeFormat.EAN_8,
BarcodeFormat.EAN_13,
BarcodeFormat.ITF,
BarcodeFormat.PDF_417,
BarcodeFormat.QR_CODE,
BarcodeFormat.UPC_A
)
private var minMargin = 0
private var bytes: ByteArray? = null
override fun onActivityResult(
requestCode: Int,
resultCode: Int,
resultData: Intent?
) {
when (requestCode) {
PICK_FILE_RESULT_CODE -> {
if (resultCode == Activity.RESULT_OK &&
resultData != null &&
resultData.data != null
) {
val ac = activity ?: return
ac.hideSoftKeyboard(contentView)
val uri = resultData.data ?: return
bytes = ac.contentResolver?.openInputStream(uri)?.use {
it.readBytesMax(4096)
} ?: return
setEncodeByteArray()
}
}
}
}
override fun onCreate(state: Bundle?) {
super.onCreate(state)
setHasOptionsMenu(true)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
state: Bundle?
): View? {
val ac = activity ?: return null
ac.setTitle(R.string.compose_barcode)
val view = inflater.inflate(
R.layout.fragment_encode,
container,
false
)
formatView = view.findViewById(R.id.format)
val formatAdapter = ArrayAdapter(
ac,
android.R.layout.simple_spinner_item,
formats.map { prettifyFormatName(it.name) }
)
formatAdapter.setDropDownViewResource(
android.R.layout.simple_spinner_dropdown_item
)
formatView.adapter = formatAdapter
formatView.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(
parentView: AdapterView<*>?,
selectedItemView: View?,
position: Int,
id: Long
) {
val format = formats[position]
val arrayId = when (format) {
BarcodeFormat.AZTEC -> R.array.aztec_error_correction_levels
BarcodeFormat.QR_CODE -> R.array.qr_error_correction_levels
BarcodeFormat.PDF_417 -> R.array.pdf417_error_correction_levels
else -> 0
}
if (arrayId > 0) {
ecSpinner.setEntries(arrayId)
val idx = format.unpackEcLevel(
prefs.indexOfLastSelectedEcLevel
)
if (idx < ecSpinner.adapter.count) {
ecSpinner.setSelection(idx)
}
true
} else {
false
}.setVisibilityFor(ecLabel, ecSpinner)
format.canBeInverted().setVisibilityFor(
colorsLabel, colorsSpinner
)
when (format) {
BarcodeFormat.AZTEC -> setMarginBar(4, 4)
BarcodeFormat.DATA_MATRIX -> setMarginBar(1, 1)
BarcodeFormat.QR_CODE -> setMarginBar(4, 4)
BarcodeFormat.PDF_417 -> setMarginBar(0, 30)
else -> false
}.setVisibilityFor(marginLayout)
}
override fun onNothingSelected(parentView: AdapterView<*>?) {}
}
ecLabel = view.findViewById(R.id.error_correction_label)
ecSpinner = view.findViewById(R.id.error_correction_level)
ecSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(
parentView: AdapterView<*>?,
selectedItemView: View?,
position: Int,
id: Long
) {
val format = formats[formatView.selectedItemPosition]
prefs.indexOfLastSelectedEcLevel = format.packEcLevel(
prefs.indexOfLastSelectedEcLevel,
position
)
}
override fun onNothingSelected(parentView: AdapterView<*>?) {}
}
colorsLabel = view.findViewById(R.id.colors_label)
colorsSpinner = view.findViewById(R.id.colors)
sizeView = view.findViewById(R.id.size_display)
sizeBarView = view.findViewById(R.id.size_bar)
initSizeBar()
marginLayout = view.findViewById(R.id.margin)
marginView = view.findViewById(R.id.margin_display)
marginBarView = view.findViewById(R.id.margin_bar)
initMarginBar()
contentView = view.findViewById(R.id.content)
unescapeCheckBox = view.findViewById(R.id.unescape)
unescapeCheckBox.isChecked = prefs.expandEscapeSequences
val args = arguments
args?.getString(CONTENT_TEXT)?.let {
contentView.setText(it)
}
args?.getByteArray(CONTENT_RAW)?.let {
bytes = it
setEncodeByteArray()
}
val barcodeFormat = args?.getString(FORMAT)
if (barcodeFormat != null) {
formatView.setSelection(
formats.indexOf(barcodeFormat.toFormat())
)
} else if (state == null) {
formatView.post {
formatView.setSelection(prefs.indexOfLastSelectedFormat)
}
}
view.findViewById<View>(R.id.encode).setOnClickListener {
it.context.encode()
}
(view.findViewById(R.id.inset_layout) as View).setPaddingFromWindowInsets()
(view.findViewById(R.id.scroll_view) as View).setPaddingFromWindowInsets()
return view
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.fragment_encode, menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.pick_file -> {
startActivityForResult(
Intent.createChooser(
Intent(Intent.ACTION_GET_CONTENT).apply {
type = "*/*"
},
getString(R.string.pick_file)
),
PICK_FILE_RESULT_CODE
)
true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onPause() {
super.onPause()
prefs.indexOfLastSelectedFormat = formatView.selectedItemPosition
prefs.expandEscapeSequences = unescapeCheckBox.isChecked
prefs.lastMargin = marginBarView.progress
}
private fun Context.encode() {
hideSoftKeyboard(contentView)
val content = if (bytes != null) {
bytes
} else {
var text = contentView.text.toString()
if (unescapeCheckBox.isChecked) {
try {
text = text.unescape()
} catch (e: IllegalArgumentException) {
toast(e.message ?: "Invalid escape sequence")
return
}
}
if (text.isEmpty()) {
toast(R.string.error_no_content)
return
}
text
}
val format = formats[formatView.selectedItemPosition]
fragmentManager?.addFragment(
BarcodeFragment.newInstance(
content,
format,
getSize(sizeBarView.progress),
when (format) {
BarcodeFormat.AZTEC,
BarcodeFormat.DATA_MATRIX,
BarcodeFormat.QR_CODE,
BarcodeFormat.PDF_417 -> marginBarView.progress
else -> -1
},
format.getErrorCorrectionLevel(ecSpinner.selectedItemPosition),
if (format.canBeInverted()) {
colorsSpinner.selectedItemPosition
} else 0
)
)
}
private fun initSizeBar() {
updateSize(sizeBarView.progress)
sizeBarView.setOnSeekBarChangeListener(
object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(
seekBar: SeekBar,
progressValue: Int,
fromUser: Boolean
) {
updateSize(progressValue)
}
override fun onStartTrackingTouch(seekBar: SeekBar) {}
override fun onStopTrackingTouch(seekBar: SeekBar) {}
})
}
private fun updateSize(power: Int) {
val size = getSize(power)
sizeView.text = getString(R.string.size_width_by_height, size, size)
}
private fun initMarginBar() {
updateMargin(marginBarView.progress)
marginBarView.setOnSeekBarChangeListener(
object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(
seekBar: SeekBar,
progressValue: Int,
fromUser: Boolean
) {
if (progressValue >= minMargin) {
updateMargin(progressValue)
}
}
override fun onStartTrackingTouch(seekBar: SeekBar) {}
override fun onStopTrackingTouch(seekBar: SeekBar) {}
})
}
private fun updateMargin(margin: Int) {
marginView.text = getString(R.string.margin_size, margin)
}
private fun setMarginBar(minimum: Int, value: Int): Boolean {
minMargin = minimum
marginBarView.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
min = minimum
}
progress = max(
minimum, if (prefs.lastMargin > -1) {
prefs.lastMargin
} else {
value
}
)
}
return true
}
private fun setEncodeByteArray() {
contentView.text = null
contentView.hint = getString(R.string.binary_data)
contentView.isEnabled = false
unescapeCheckBox.isEnabled = false
}
companion object {
private const val CONTENT_TEXT = "content_text"
private const val CONTENT_RAW = "content_raw"
private const val FORMAT = "format"
private const val PICK_FILE_RESULT_CODE = 1
fun <T> newInstance(
content: T? = null,
format: String? = null
): Fragment {
val args = Bundle()
content?.let {
when (content) {
is String -> args.putString(CONTENT_TEXT, content)
is ByteArray -> args.putByteArray(CONTENT_RAW, content)
else -> throw IllegalArgumentException(
"content must be a String or a ByteArray"
)
}
}
format?.let { args.putString(FORMAT, it) }
val fragment = EncodeFragment()
fragment.arguments = args
return fragment
}
}
}
private fun Boolean.setVisibilityFor(vararg views: View) {
val visibility = if (this) View.VISIBLE else View.GONE
for (view in views) {
view.visibility = visibility
}
}
private fun BarcodeFormat.packEcLevel(packed: Int, level: Int): Int {
val s = ecLevelShift()
return (level shl s) or (packed and (15 shl s).inv())
}
private fun BarcodeFormat.unpackEcLevel(packed: Int) =
(packed shr ecLevelShift()) and 15
private fun BarcodeFormat.ecLevelShift() = when (this) {
BarcodeFormat.AZTEC -> 0
BarcodeFormat.QR_CODE -> 4
BarcodeFormat.PDF_417 -> 8
else -> throw IllegalArgumentException("$this does not have error levels")
}
private fun BarcodeFormat.canBeInverted() = when (this) {
BarcodeFormat.AZTEC,
BarcodeFormat.DATA_MATRIX,
BarcodeFormat.QR_CODE -> true
else -> false
}
private fun String.toFormat(default: BarcodeFormat = BarcodeFormat.QR_CODE): BarcodeFormat = try {
BarcodeFormat.valueOf(this)
} catch (_: IllegalArgumentException) {
default
}
private fun BarcodeFormat.getErrorCorrectionLevel(position: Int) = when (this) {
BarcodeFormat.AZTEC -> position
BarcodeFormat.QR_CODE -> (position + 1) * 2
BarcodeFormat.PDF_417 -> position
else -> 0
}.coerceIn(0, 8)
private fun Spinner.setEntries(resId: Int) = ArrayAdapter.createFromResource(
this.context,
resId,
android.R.layout.simple_spinner_item
).also { aa ->
aa.setDropDownViewResource(
android.R.layout.simple_spinner_dropdown_item
)
adapter = aa
}
private fun getSize(power: Int) = 128 * (power + 1)
private fun InputStream.readBytesMax(max: Int): ByteArray {
var offset = 0
var remaining = min(available(), max)
val result = ByteArray(remaining)
while (remaining > 0) {
val read = read(result, offset, remaining)
if (read < 0) {
break
}
remaining -= read
offset += read
}
return result
}
@@ -1,85 +1,206 @@
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.support.annotation.RequiresApi
import android.support.v14.preference.MultiSelectListPreference
import android.support.v7.preference.ListPreference
import android.support.v7.preference.Preference
import android.support.v7.preference.PreferenceFragmentCompat
import android.support.v7.preference.PreferenceGroup
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.app.addFragment
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.camera.availableDefaultCameras
import de.markusfisch.android.binaryeye.camera.defaultCameraEntries
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
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(
sharedPreferences: SharedPreferences?,
key: String?
) {
key ?: return
val preference = findPreference(key) ?: return
prefs.update()
when (preference.key) {
"custom_locale" -> activity?.restartApp()
"beep_tone_name" -> {
beepConfirm()
setSummary(preference)
}
"send_scan_bluetooth" -> {
if (prefs.sendScanBluetooth &&
activity?.hasBluetoothPermission() == false
) {
prefs.sendScanBluetooth = false
}
setSummary(preference)
}
else -> setSummary(preference)
val preference = findPreference<Preference>(key) ?: return
if (preference.key != PROFILE) {
prefs.update()
}
when (preference.key) {
CUSTOM_LOCALE,
DYNAMIC_COLORS -> {
activity?.restartApp()
return
}
BEEP_TONE_NAME,
BEEP_STREAM_NAME -> activity?.beepConfirm()
SEND_SCAN_BLUETOOTH -> if (
prefs.sendScanBluetooth &&
activity?.hasBluetoothPermission() == false
) {
prefs.sendScanBluetooth = false
}
}
setSummary(preference)
}
}
override fun onCreatePreferences(state: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.preferences)
loadPreferences(rootKey)
}
private fun loadPreferences(rootKey: String? = null) {
preferenceScreen?.sharedPreferences
?.unregisterOnSharedPreferenceChangeListener(changeListener)
preferenceManager.apply {
sharedPreferencesName = prefs.profile ?: "${context.packageName}_preferences"
sharedPreferencesMode = Context.MODE_PRIVATE
}
// Refresh the preferences.
preferenceScreen = null
setPreferencesFromResource(R.xml.preferences, rootKey)
preferenceScreen.sharedPreferences
?.registerOnSharedPreferenceChangeListener(changeListener)
setSummaries(preferenceScreen)
setIcons()
wireBeepTonePicker()
setDefaultCameras()
wireProfiles()
wireAutomatedActions()
wireIgnoreCodes()
setBluetoothResources()
wireClearNetworkPreferences()
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
findPreference<PreferenceGroup>("appearance_category")?.isVisible = false
}
}
private fun wireProfiles() {
findPreference<Preference>(PROFILE)?.apply {
updateProfileSummary(this)
onPreferenceClickListener = Preference.OnPreferenceClickListener {
startActivity(Intent(activity, ProfilesActivity::class.java))
true
}
}
}
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 setDefaultCameras() {
findPreference<ListPreference>(DEFAULT_CAMERA)?.apply {
val values = availableDefaultCameras(requireContext())
entries = defaultCameraEntries(requireContext(), values)
entryValues = values.toTypedArray()
if (!values.contains(value)) {
value = values.firstOrNull()
}
isVisible = values.size > 1
setSummary(this)
}
}
private fun wireAutomatedActions() {
findPreference<Preference>(AUTOMATED_ACTIONS)?.apply {
updateAutomatedActionsSummary(this)
onPreferenceClickListener = Preference.OnPreferenceClickListener {
startActivity(
Intent(activity, AutomatedActionsActivity::class.java)
)
true
}
}
}
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
) {
setBluetoothHosts(
findPreference("send_scan_bluetooth_host") as ListPreference
)
findPreference<ListPreference>(SEND_SCAN_BLUETOOTH_HOST)?.let {
setBluetoothHosts(it)
}
}
}
private fun wireClearNetworkPreferences() {
findPreference("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.
setOnPreferenceClickListener {
fragmentManager.addFragment(NetworkSuggestionsFragment())
startActivity(
Intent(activity, NetworkSuggestionsActivity::class.java)
)
true
}
} else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) {
@@ -100,7 +221,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()
@@ -124,29 +245,38 @@ class PreferencesFragment : PreferenceFragmentCompat() {
override fun onResume() {
super.onResume()
if (lastProfile != prefs.profile) {
loadPreferences()
}
activity?.setTitle(R.string.preferences)
findPreference<Preference>(AUTOMATED_ACTIONS)?.let {
updateAutomatedActionsSummary(it)
}
findPreference<Preference>(IGNORE_CODES)?.let {
updateIgnoreCodesSummary(it)
}
findPreference<Preference>(PROFILE)?.let {
updateProfileSummary(it)
}
listView.setPaddingFromWindowInsets()
listView.removeOnScrollListener(systemBarRecyclerViewScrollListener)
listView.addOnScrollListener(systemBarRecyclerViewScrollListener)
preferenceScreen.sharedPreferences
.registerOnSharedPreferenceChangeListener(changeListener)
setSummaries(preferenceScreen)
}
override fun onPause() {
super.onPause()
preferenceScreen.sharedPreferences
.unregisterOnSharedPreferenceChangeListener(changeListener)
?.unregisterOnSharedPreferenceChangeListener(changeListener)
}
override fun onDisplayPreferenceDialog(preference: Preference) {
if (preference is UrlPreference) {
val fm = fragmentManager
val fm = parentFragmentManager
UrlDialogFragment.newInstance(preference.key).apply {
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)
@@ -165,38 +295,225 @@ class PreferencesFragment : PreferenceFragmentCompat() {
}
private fun setSummary(preference: Preference) {
when (preference) {
is UrlPreference -> {
preference.setSummary(preference.getUrl())
}
is ListPreference -> {
preference.setSummary(preference.entry)
}
is MultiSelectListPreference -> {
preference.setSummary(
preference.values.joinToString(", ") {
it.replace(Regex("_"), " ")
}
)
}
is PreferenceGroup -> {
setSummaries(preference)
when (preference.key) {
AUTOMATED_ACTIONS -> return updateAutomatedActionsSummary(preference)
IGNORE_CODES -> return updateIgnoreCodesSummary(preference)
BEEP_TONE_NAME -> {
setBeepToneSummary(preference)
return
}
}
when (preference) {
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 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(DEFAULT_CAMERA, R.drawable.ic_action_switch_camera)
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(SEND_SCAN_KDE_CONNECT, R.drawable.ic_action_forward)
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) {
getString(R.string.automated_actions_none)
} else {
resources.getQuantityString(
R.plurals.automated_actions_count,
count,
count
)
}
}
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.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 DEFAULT_CAMERA = "default_camera"
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"
private const val SEND_SCAN_KDE_CONNECT = "send_scan_kde_connect"
}
}
private fun Activity.restartApp() {
val intent = Intent(this, SplashActivity::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
intent.addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TASK
)
}
intent.addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TASK
)
startActivity(intent)
finish()
// Restart to begin with an unmodified Locale to follow system settings.
@@ -1,9 +1,9 @@
package de.markusfisch.android.binaryeye.fragment
import android.os.Bundle
import android.support.v7.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
@@ -15,10 +15,10 @@ class UrlDialogFragment : PreferenceDialogFragmentCompat() {
private var urlView: TextView? = null
private var testButton: TextView? = null
override fun onBindDialogView(view: View?) {
override fun onBindDialogView(view: View) {
super.onBindDialogView(view)
urlView = view?.findViewById(R.id.url)
testButton = view?.findViewById(R.id.test_url)
urlView = view.findViewById(R.id.url)
testButton = view.findViewById(R.id.test_url)
testButton?.setOnClickListener {
testUrl(testButton)
}
@@ -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
@@ -80,10 +83,5 @@ private fun completeUrl(template: String): String {
if (!s.startsWith("http")) {
s = "http://${s}"
}
if (prefs.sendScanType == "0" &&
!s.matches(".*/[a-zA-Z._-]*\\?[a-zA-Z0-9&=_-]+=$".toRegex())
) {
s = "${s}?content="
}
return s
}
@@ -25,7 +25,7 @@ fun ContentResolver.loadImageUri(uri: Uri): Bitmap? = try {
openInputStream(uri)?.use {
BitmapFactory.decodeStream(it, null, options)
}
} catch (e: IOException) {
} catch (_: IOException) {
null
}
@@ -3,12 +3,12 @@ package de.markusfisch.android.binaryeye.graphics
import android.content.Context
import android.graphics.DashPathEffect
import android.graphics.Paint
import android.support.v4.content.ContextCompat
import androidx.core.content.ContextCompat
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,30 +1,32 @@
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
import android.graphics.drawable.Drawable
import android.os.Build
import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.createBitmap
fun Resources.getBitmapFromDrawable(
resId: Int
): Bitmap = getBitmapFromDrawable(getDrawableCompat(resId))
): Bitmap = getBitmapFromDrawable(resId, null)
fun Resources.getDrawableCompat(
resId: Int
): Drawable = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
getDrawable(resId, null)
} else {
@Suppress("DEPRECATION")
getDrawable(resId)
}
fun Resources.getBitmapFromDrawable(
resId: Int,
theme: Theme?
): Bitmap = getBitmapFromDrawable(
ResourcesCompat.getDrawable(this, resId, theme) ?: throw Resources.NotFoundException(
"Drawable resource ID #0x${resId.toString(16)}"
)
)
private fun getBitmapFromDrawable(drawable: Drawable): Bitmap {
if (drawable is BitmapDrawable) {
return drawable.bitmap
}
val bitmap = Bitmap.createBitmap(
val bitmap = createBitmap(
drawable.intrinsicWidth,
drawable.intrinsicHeight,
Bitmap.Config.ARGB_8888
@@ -10,7 +10,9 @@ data class FrameMetrics(
var width: Int = 0,
var height: Int = 0,
var orientation: Int = 0
)
) {
fun isValid() = width > 0 && height > 0
}
fun Rect.setFrameRoi(
frameMetrics: FrameMetrics,
@@ -38,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(
@@ -86,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
}
}
@@ -2,14 +2,16 @@ package de.markusfisch.android.binaryeye.graphics
import android.graphics.Bitmap
import android.graphics.Canvas
import androidx.core.graphics.createBitmap
fun Bitmap.fixTransparency(): Bitmap {
val result = analyzeTransparency()
return if (result.hasTransparentPixels) {
val copy = Bitmap.createBitmap(
val cfg = config
return if (cfg != null && result.hasTransparentPixels) {
val copy = createBitmap(
width,
height,
config
cfg
)
Canvas(copy).apply {
drawColor(result.backgroundColor)
@@ -6,8 +6,8 @@ import android.content.Context
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.support.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
@@ -53,7 +53,7 @@ fun Context.writeExternalFile(
): Boolean = try {
openExternalOutputStream(fileName, mimeType).use { write(it) }
true
} catch (e: IOException) {
} catch (_: IOException) {
false
}
@@ -0,0 +1,30 @@
package de.markusfisch.android.binaryeye.kdeconnect
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import de.markusfisch.android.binaryeye.content.startIntentOrToast
import de.markusfisch.android.binaryeye.database.Scan
private const val KDE_CONNECT_PACKAGE = "org.kde.kdeconnect_tp"
private const val KDE_CONNECT_KEYSTROKES_ACTIVITY =
"org.kde.kdeconnect.plugins.mousepad.SendKeystrokesToHostActivity"
private const val KEYSTROKES_MIME_TYPE = "text/x-keystrokes"
fun Context.sendToKdeConnect(scan: Scan): Boolean {
if (scan.text.isEmpty()) {
return false
}
return startIntentOrToast(createKdeConnectIntent(scan.text))
}
fun createKdeConnectIntent(text: String): Intent {
return Intent(Intent.ACTION_SEND).apply {
component = ComponentName(
KDE_CONNECT_PACKAGE,
KDE_CONNECT_KEYSTROKES_ACTIVITY
)
type = KEYSTROKES_MIME_TYPE
putExtra(Intent.EXTRA_TEXT, text)
}
}
@@ -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
}
@@ -0,0 +1,30 @@
package de.markusfisch.android.binaryeye.net
import android.net.Uri
private const val SCHEME = "binaryeye"
private const val HOSTNAME = "markusfisch.de"
private const val ENCODE_PATH = "encode"
fun isScanDeeplink(text: String) = listOf(
"$SCHEME://scan",
"http://$HOSTNAME/BinaryEye",
"https://$HOSTNAME/BinaryEye"
).any { text.startsWith(it) }
fun isEncodeDeeplink(text: String) = listOf(
"$SCHEME://$ENCODE_PATH",
"http://$HOSTNAME/$ENCODE_PATH",
"https://$HOSTNAME/$ENCODE_PATH"
).any { text.startsWith(it) }
fun createEncodeDeeplink(format: String, content: String): String {
return Uri.Builder()
.scheme("https")
.authority(HOSTNAME)
.appendPath(ENCODE_PATH)
.appendQueryParameter("format", format)
.appendQueryParameter("content", content)
.build()
.toString()
}
@@ -1,6 +1,8 @@
package de.markusfisch.android.binaryeye.net
import de.markusfisch.android.binaryeye.content.toHexString
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
@@ -15,59 +17,96 @@ 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(
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 + content.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(
"content" to content,
private fun Scan.getMap(extras: Map<String, String>): Map<String, String> = (mapOf(
"content" to text,
"raw" to raw?.toHexString(),
"format" to format.name,
"errorCorrectionLevel" to errorCorrectionLevel,
@@ -79,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 }
@@ -5,6 +5,6 @@ import java.net.URLEncoder
fun String.urlEncode(): String = try {
URLEncoder.encode(this, "UTF-8")
} catch (e: UnsupportedEncodingException) {
} catch (_: UnsupportedEncodingException) {
this
}
}
@@ -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,24 @@
package de.markusfisch.android.binaryeye.os
import android.app.Activity
import android.view.WindowManager
fun Activity.getScreenBrightness(): Float {
val layoutParams = window.attributes
return if (layoutParams.screenBrightness ==
WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
) {
android.provider.Settings.System.getInt(
contentResolver,
android.provider.Settings.System.SCREEN_BRIGHTNESS
) / 255.0f
} else {
layoutParams.screenBrightness
}
}
fun Activity.setScreenBrightness(brightness: Float) {
val layoutParams = window.attributes
layoutParams.screenBrightness = brightness
window.attributes = layoutParams
}
@@ -0,0 +1,49 @@
package de.markusfisch.android.binaryeye.preference
import android.content.SharedPreferences
import android.graphics.Point
import androidx.core.content.edit
import org.json.JSONObject
fun SharedPreferences.storeCropHandle(
name: String,
viewWidth: Int,
viewHeight: Int,
cropHandle: Point
) {
edit {
putString(
cropHandleKey(name, viewWidth, viewHeight),
JSONObject().apply {
put("x", cropHandle.x)
put("y", cropHandle.y)
}.toString()
)
}
}
fun SharedPreferences.restoreCropHandle(
name: String,
viewWidth: Int,
viewHeight: Int,
default: Point = Point(-2, -2) // -2 means set default roi.
): Point {
val s = getString(
cropHandleKey(name, viewWidth, viewHeight), null
) ?: return default
return try {
val json = JSONObject(s)
Point(
json.getInt("x"),
json.getInt("y")
)
} catch (_: Exception) {
return default
}
}
private fun cropHandleKey(
name: String,
viewWidth: Int,
viewHeight: Int
) = "${name}:${viewWidth}:${viewHeight}"
@@ -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()
}
}
}
@@ -3,60 +3,82 @@ package de.markusfisch.android.binaryeye.preference
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import android.graphics.Point
import android.media.ToneGenerator
import android.os.Build
import android.preference.PreferenceManager
import android.support.annotation.RequiresApi
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
lateinit var preferences: SharedPreferences
val profiles = ArrayList<String>()
var profile: String? = null
set(value) {
defaultPreferences.edit {
putString(PROFILE, value)
}
field = value
}
var barcodeFormats = setOf(
BarcodeFormat.AZTEC.name,
BarcodeFormat.CODABAR.name,
BarcodeFormat.CODE_39.name,
BarcodeFormat.CODE_93.name,
BarcodeFormat.CODE_128.name,
BarcodeFormat.DATA_BAR.name,
BarcodeFormat.DATA_BAR_EXPANDED.name,
BarcodeFormat.DATA_MATRIX.name,
BarcodeFormat.EAN_8.name,
BarcodeFormat.EAN_13.name,
BarcodeFormat.Aztec.name,
BarcodeFormat.Codabar.name,
BarcodeFormat.Code39Std.name,
BarcodeFormat.Code39Ext.name,
BarcodeFormat.Code32.name,
BarcodeFormat.PZN.name,
BarcodeFormat.Code93.name,
BarcodeFormat.Code128.name,
BarcodeFormat.DataBar.name,
BarcodeFormat.DataBarOmni.name,
BarcodeFormat.DataBarStk.name,
BarcodeFormat.DataBarLtd.name,
BarcodeFormat.DataBarExp.name,
BarcodeFormat.DataBarExpStk.name,
BarcodeFormat.DataMatrix.name,
BarcodeFormat.DXFilmEdge.name,
BarcodeFormat.EAN8.name,
BarcodeFormat.EAN13.name,
BarcodeFormat.ITF.name,
BarcodeFormat.MAXICODE.name,
BarcodeFormat.PDF_417.name,
BarcodeFormat.QR_CODE.name,
BarcodeFormat.MICRO_QR_CODE.name,
BarcodeFormat.RMQR_CODE.name,
BarcodeFormat.UPC_A.name,
BarcodeFormat.UPC_E.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,
BarcodeFormat.UPCA.name,
BarcodeFormat.UPCE.name,
)
@RequiresApi(Build.VERSION_CODES.HONEYCOMB)
set(value) {
apply(BARCODE_FORMATS, value)
field = value
}
var cropHandleX = -2 // -2 means set default roi.
set(value) {
apply(CROP_HANDLE_X, value)
field = value
}
var cropHandleY = -2
set(value) {
apply(CROP_HANDLE_Y, value)
field = value
}
var cropHandleOrientation = 0
set(value) {
apply(CROP_HANDLE_ORIENTATION, value)
field = value
}
var showCropHandle = true
set(value) {
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)
@@ -77,6 +99,11 @@ class Preferences {
apply(BULK_MODE, value)
field = value
}
var defaultCamera = "back"
set(value) {
apply(DEFAULT_CAMERA, value)
field = value
}
var bulkModeDelay = "500"
set(value) {
apply(BULK_MODE_DELAY, value)
@@ -102,16 +129,28 @@ 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)
field = value
}
var ignoreConsecutiveDuplicates = true
var ignoreDuplicatesName = "ignore_consecutive_duplicates"
set(value) {
apply(IGNORE_CONSECUTIVE_DUPLICATES, value)
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)
@@ -122,6 +161,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)
@@ -132,6 +176,11 @@ class Preferences {
apply(SHOW_HEX_DUMP, value)
field = value
}
var showChecksum = ""
set(value) {
apply(SHOW_CHECKSUM, value)
field = value
}
var showRecreation = true
set(value) {
apply(SHOW_RECREATION, value)
@@ -167,6 +216,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)
@@ -177,6 +231,11 @@ class Preferences {
apply(SEND_SCAN_BLUETOOTH_HOST, value)
field = value
}
var sendScanKdeConnect = false
set(value) {
apply(SEND_SCAN_KDE_CONNECT, value)
field = value
}
var customLocale: String = ""
set(value) {
// Make sure this setting is written immediately because
@@ -194,11 +253,6 @@ class Preferences {
apply(INDEX_OF_LAST_SELECTED_EC_LEVEL, value)
field = value
}
var lastMargin: Int = -1
set(value) {
apply(LAST_MARGIN, value)
field = value
}
var freeRotation = false
set(value) {
apply(FREE_ROTATION, value)
@@ -209,41 +263,313 @@ class Preferences {
apply(EXPAND_ESCAPE_SEQUENCES, value)
field = value
}
var addQuietZone = true
set(value) {
apply(ADD_QUIET_ZONE, value)
field = value
}
var brightenScreen = false
set(value) {
apply(BRIGHTEN_SCREEN, value)
field = value
}
var previewScale = 0f
set(value) {
apply(PREVIEW_SCALE, value)
field = value
}
var dynamicColors = true
set(value) {
apply(DYNAMIC_COLORS, value)
field = value
}
var automatedActions = mutableListOf<AutomatedAction>()
private set
fun init(context: Context) {
preferences = PreferenceManager.getDefaultSharedPreferences(context)
// I'm not including a support library just to get the
// default preferences. Dependencies are a burden.
@Suppress("DEPRECATION")
defaultPreferences = PreferenceManager.getDefaultSharedPreferences(context)
loadProfiles()
addPredefinedProfiles(context)
load(context, defaultPreferences.getString(PROFILE, null))
}
fun load(context: Context, profileName: String?) {
preferences = if (profileName.isNullOrEmpty()) {
profile = null
defaultPreferences
} else {
profile = profileName
context.getSharedPreferences(
profileName,
Context.MODE_PRIVATE
)
}
update()
}
fun update() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
preferences.getStringSet(BARCODE_FORMATS, barcodeFormats)?.let {
// Add rMQR Code for existing installations.
val addRMQRCode = "rmqr_added"
barcodeFormats = if (!preferences.getBoolean(addRMQRCode, false)) {
preferences.edit().putBoolean(addRMQRCode, true).apply()
it.toMutableSet().apply {
add(BarcodeFormat.RMQR_CODE.name)
}
} else {
it
}
}
fun addProfile(name: String): Boolean {
if (!name.matches("^[a-zA-Z0-9_-]+$".toRegex()) ||
profiles.contains(name)
) {
return false
}
cropHandleX = preferences.getInt(CROP_HANDLE_X, cropHandleX)
cropHandleY = preferences.getInt(CROP_HANDLE_Y, cropHandleY)
cropHandleOrientation = preferences.getInt(
CROP_HANDLE_ORIENTATION,
cropHandleOrientation
profiles.add(name)
saveProfiles()
return true
}
fun removeProfile(name: String): Boolean {
if (!profiles.remove(name)) {
return false
}
saveProfiles()
return true
}
fun saveProfiles() {
defaultPreferences.edit {
putString(PROFILES, org.json.JSONArray(profiles).toString())
}
}
fun loadProfiles() {
profiles.clear()
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(DEFAULT_CAMERA, p, defaults.defaultCamera)
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(SEND_SCAN_KDE_CONNECT, p, defaults.sendScanKdeConnect)
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(DEFAULT_CAMERA, 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)
putBoolean(SEND_SCAN_KDE_CONNECT, 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 {
barcodeFormats = addFormatsOnUpdate(
migrateCode39ToCode39Std(
migrateBarcodeFormatNames(it)
),
BarcodeFormat.Code39Std,
BarcodeFormat.Code39Ext,
BarcodeFormat.Code32,
BarcodeFormat.PZN,
BarcodeFormat.DataBarOmni,
BarcodeFormat.DataBarStk,
BarcodeFormat.DataBarLtd,
BarcodeFormat.DataBarExp,
BarcodeFormat.DataBarExpStk,
BarcodeFormat.RMQRCode,
BarcodeFormat.DXFilmEdge,
BarcodeFormat.Telepen,
BarcodeFormat.TelepenAlpha,
BarcodeFormat.TelepenNumeric
)
}
showCropHandle = preferences.getBoolean(
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)
bulkMode = preferences.getBoolean(BULK_MODE, bulkMode)
preferences.getString(DEFAULT_CAMERA, defaultCamera)?.also {
defaultCamera = it
}
bulkModeDelay = preferences.getString(
BULK_MODE_DELAY,
bulkModeDelay
@@ -257,10 +583,36 @@ 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)
ignoreConsecutiveDuplicates = preferences.getBoolean(
IGNORE_CONSECUTIVE_DUPLICATES,
ignoreConsecutiveDuplicates
// Map old setting to new one if it wasn't on default value.
val ignoreConsecutiveDuplicates = "ignore_consecutive_duplicates"
if (!preferences.getBoolean(ignoreConsecutiveDuplicates, true)) {
preferences.edit {
remove(ignoreConsecutiveDuplicates)
}
ignoreDuplicatesName = "accept_duplicates"
}
preferences.getString(
IGNORE_DUPLICATES_NAME,
ignoreDuplicatesName
)?.also {
ignoreDuplicatesName = it
}
ignoreCodes = ignoreCodesFromJsonArray(
preferences.getString(
IGNORE_CODES,
ignoreCodesToJsonArray(
listOf(IgnoreCode(DEFAULT_IGNORE_CODE_PATTERN))
)
) ?: "[]"
)
copyImmediately = preferences.getBoolean(
COPY_IMMEDIATELY,
@@ -270,8 +622,15 @@ 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 {
showChecksum = it
}
showRecreation = preferences.getBoolean(SHOW_RECREATION, showRecreation)
closeAutomatically = preferences.getBoolean(
CLOSE_AUTOMATICALLY,
@@ -293,6 +652,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
@@ -303,6 +665,10 @@ class Preferences {
)?.also {
sendScanBluetoothHost = it
}
sendScanKdeConnect = preferences.getBoolean(
SEND_SCAN_KDE_CONNECT,
sendScanKdeConnect
)
preferences.getString(CUSTOM_LOCALE, customLocale)?.also {
customLocale = it
}
@@ -314,17 +680,52 @@ class Preferences {
INDEX_OF_LAST_SELECTED_EC_LEVEL,
indexOfLastSelectedEcLevel
)
lastMargin = preferences.getInt(
LAST_MARGIN,
lastMargin
)
freeRotation = preferences.getBoolean(FREE_ROTATION, freeRotation)
expandEscapeSequences = preferences.getBoolean(
EXPAND_ESCAPE_SEQUENCES,
expandEscapeSequences
)
addQuietZone = preferences.getBoolean(ADD_QUIET_ZONE, addQuietZone)
brightenScreen = preferences.getBoolean(
BRIGHTEN_SCREEN,
brightenScreen
)
previewScale = preferences.getFloat(
PREVIEW_SCALE,
previewScale
)
dynamicColors = preferences.getBoolean(DYNAMIC_COLORS, dynamicColors)
automatedActions = fromJsonArray(
preferences.getString(AUTOMATED_ACTIONS, null) ?: "[]"
)
}
fun setAutomatedActions(actions: List<AutomatedAction>) {
automatedActions = actions.toMutableList()
apply(AUTOMATED_ACTIONS, toJsonArray(actions))
}
fun setIgnoreCodes(patterns: List<IgnoreCode>) {
ignoreCodes = patterns.toMutableList()
apply(IGNORE_CODES, ignoreCodesToJsonArray(patterns))
}
fun shouldIgnoreHistoryContent(content: String, format: String): Boolean =
ignoreCodes.any { it.matches(content, format) }
fun restoreCropHandle(
name: String,
viewWidth: Int,
viewHeight: Int
) = preferences.restoreCropHandle(name, viewWidth, viewHeight)
fun storeCropHandle(
name: String,
viewWidth: Int,
viewHeight: Int,
cropHandle: Point
) = preferences.storeCropHandle(name, viewWidth, viewHeight, cropHandle)
fun beepTone() = when (beepToneName) {
"tone_cdma_confirm" -> ToneGenerator.TONE_CDMA_CONFIRM
"tone_sup_radio_ack" -> ToneGenerator.TONE_SUP_RADIO_ACK
@@ -334,55 +735,214 @@ class Preferences {
else -> ToneGenerator.TONE_PROP_BEEP
}
private fun put(label: String, value: Boolean) =
preferences.edit().putBoolean(label, value)
fun beepStream() = when (beepStreamName) {
"stream_notification" -> android.media.AudioManager.STREAM_NOTIFICATION
else -> android.media.AudioManager.STREAM_MUSIC
}
private fun put(label: String, value: String) =
preferences.edit().putString(label, value)
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(DEFAULT_CAMERA, defaults.defaultCamera)
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)
putBoolean(SEND_SCAN_KDE_CONNECT, defaults.sendScanKdeConnect)
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) {
put(label, value).apply()
preferences.edit {
putBoolean(label, value)
}
}
private fun apply(label: String, value: String) {
preferences.edit().putString(label, value).apply()
preferences.edit {
putString(label, value)
}
}
private fun apply(label: String, value: Int) {
preferences.edit().putInt(label, value).apply()
preferences.edit {
putInt(label, value)
}
}
private fun apply(label: String, value: Float) {
preferences.edit {
putFloat(label, value)
}
}
@SuppressLint("ApplySharedPref")
private fun commit(label: String, value: String) {
preferences.edit().putString(label, value).commit()
preferences.edit(true) {
putString(label, value)
}
}
@RequiresApi(Build.VERSION_CODES.HONEYCOMB)
private fun apply(label: String, value: Set<String>) {
preferences.edit().putStringSet(label, value).apply()
preferences.edit {
putStringSet(label, value)
}
}
companion object {
enum class IgnoreDuplicates {
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 CROP_HANDLE_X = "crop_handle_x"
private const val CROP_HANDLE_Y = "crop_handle_y"
private const val CROP_HANDLE_ORIENTATION = "crop_handle_orientation"
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"
private const val BULK_MODE = "bulk_mode"
private const val DEFAULT_CAMERA = "default_camera"
private const val BULK_MODE_DELAY = "bulk_mode_delay"
private const val SHOW_TOAST_IN_BULK_MODE = "show_toast_in_bulk_mode"
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_CONSECUTIVE_DUPLICATES = "ignore_consecutive_duplicates"
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"
private const val SHOW_CHECKSUM = "show_checksum"
private const val SHOW_RECREATION = "show_recreation"
private const val CLOSE_AUTOMATICALLY = "close_automatically"
private const val DEFAULT_SEARCH_URL = "default_search_url"
@@ -390,13 +950,61 @@ 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 SEND_SCAN_KDE_CONNECT = "send_scan_kde_connect"
private const val CUSTOM_LOCALE = "custom_locale"
private const val INDEX_OF_LAST_SELECTED_FORMAT = "index_of_last_selected_format"
private const val INDEX_OF_LAST_SELECTED_EC_LEVEL = "index_of_last_selected_ec_level"
private const val LAST_MARGIN = "last_margin"
private const val FREE_ROTATION = "free_rotation"
private const val EXPAND_ESCAPE_SEQUENCES = "expand_escape_sequences"
private const val ADD_QUIET_ZONE = "add_quiet_zone"
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())
}
}
@@ -1,12 +1,12 @@
package de.markusfisch.android.binaryeye.preference
import android.content.Context
import android.support.v7.preference.DialogPreference
import android.util.AttributeSet
import androidx.preference.DialogPreference
import de.markusfisch.android.binaryeye.R
class UrlPreference(
context: Context?,
context: Context,
attrs: AttributeSet?
) : DialogPreference(context, attrs) {
private var url: String? = null
@@ -5,7 +5,7 @@ import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import android.service.quicksettings.TileService
import android.support.annotation.RequiresApi
import androidx.annotation.RequiresApi
import de.markusfisch.android.binaryeye.activity.CameraActivity
@RequiresApi(Build.VERSION_CODES.N)
@@ -70,7 +70,13 @@ fun String.unescape(): String {
val sb = StringBuilder()
while (i < len) {
val ch = get(i++)
sb.append(if (ch == '\\' && i < len) expand() else ch)
sb.append(
if (ch == '\\' && i < len) {
expand()
} else {
ch
}
)
}
return sb.toString()
}
@@ -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,15 +2,18 @@ package de.markusfisch.android.binaryeye.view
import android.content.Context
import android.content.ContextWrapper
import android.graphics.drawable.ColorDrawable
import android.graphics.Color
import android.os.Build
import android.support.v4.content.ContextCompat
import android.support.v7.app.AppCompatActivity
import android.support.v7.widget.LinearLayoutManager
import android.support.v7.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 {
@@ -25,7 +28,8 @@ val systemBarListViewScrollListener = object : AbsListView.OnScrollListener {
view.post {
val scrolled = firstVisibleItem > 0 ||
(totalItemCount > 0 && firstChildScrolled(view))
val scrollable = scrolled || totalItemCount > 0 && lastChildOutOfView(view)
val scrollable = scrolled ||
totalItemCount > 0 && lastChildOutOfView(view)
colorSystemAndToolBars(view.context, scrolled, scrollable)
}
}
@@ -37,9 +41,11 @@ val systemBarListViewScrollListener = object : AbsListView.OnScrollListener {
val systemBarRecyclerViewScrollListener = object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
val scrolled = layoutManager.findFirstCompletelyVisibleItemPosition() != 0
val scrollable = scrolled || layoutManager.findLastVisibleItemPosition() <
recyclerView.adapter.itemCount - 1
val scrolled =
layoutManager.findFirstCompletelyVisibleItemPosition() != 0
val scrollable = scrolled ||
layoutManager.findLastVisibleItemPosition() <
(recyclerView.adapter?.itemCount ?: 0) - 1
colorSystemAndToolBars(recyclerView.context, scrolled, scrollable)
}
@@ -57,19 +63,25 @@ private fun lastChildOutOfView(listView: AbsListView): Boolean {
return child != null && child.bottom >= listView.height
}
fun initSystemBars(activity: AppCompatActivity) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
// Keeps the soft keyboard from repositioning the layout.
val window = activity.window
window.setSoftInputMode(
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
)
window.decorView.systemUiVisibility =
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
}
colorSystemAndToolBars(activity)
fun AppCompatActivity.initBars() {
// Keeps the soft keyboard from repositioning the layout.
window.setSoftInputMode(
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
)
window.decorView.systemUiVisibility =
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
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(getTranslucentPrimaryColor(this@initBars))
}
)
}
private var statusBarColorLocked = false
@@ -81,42 +93,49 @@ fun unlockStatusBarColor() {
statusBarColorLocked = false
}
private var translucentPrimaryColor = 0
private val actionBarBackground = ColorDrawable()
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
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
// System bars no longer have a background from SDK35+.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) {
val window = activity.window
if (!statusBarColorLocked) {
@Suppress("DEPRECATION")
window.statusBarColor = topColor
}
@Suppress("DEPRECATION")
window.navigationBarColor = if (scrolled || scrollable) {
translucentPrimaryColor
} else {
0
}
}
activity.supportActionBar?.setBackgroundDrawable(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
// Avoid allocation on Honeycomb and better.
actionBarBackground.color = topColor
actionBarBackground
} else {
// ColorDrawable.setColor() doesn't exist pre Honeycomb.
ColorDrawable(topColor)
} else {
activity.findViewById<View>(R.id.navbar)?.apply {
visibility = if (scrolled || scrollable) {
View.VISIBLE
} else {
View.GONE
}
setBackgroundColor(translucentPrimaryColor)
}
)
}
activity.supportActionBar?.setBackgroundDrawable(topColor.toDrawable())
}
private fun getAppCompatActivity(context: Context): AppCompatActivity? {
@@ -2,39 +2,83 @@ package de.markusfisch.android.binaryeye.view
import android.graphics.Rect
import android.os.Build
import android.support.annotation.RequiresApi
import android.support.v4.view.ViewCompat
import android.support.v4.view.WindowInsetsCompat
import android.support.v7.widget.Toolbar
import android.view.View
import android.view.WindowInsets
import androidx.appcompat.widget.Toolbar
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
private var toolbarHeight = 0
fun recordToolbarHeight(toolbar: Toolbar) {
fun setPaddingFromWindowInsets(
mainLayout: View,
toolbar: Toolbar,
navbar: View?
) {
toolbarHeight = toolbar.layoutParams.height
// SDK 35+ is edge-to-edge by default, so the toolbar needs to extend
// below the status bar.
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
toolbar.setPadding(0, statusBarTop, 0, 0)
toolbar.layoutParams.height = baseToolbarHeight + statusBarTop
val navBarBottom = systemBarInsets.bottom
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) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
f(this, insetsWithToolbar())
} else {
ViewCompat.setOnApplyWindowInsetsListener(this) { v, insets ->
f(v, insetsWithToolbar(insets))
insets
}
// It's important to explicitly request the insets (again) in
// case the view was created in Fragment.onCreateView() because
// setOnApplyWindowInsetsListener() won't fire when the view
// isn't attached.
requestApplyInsetsWhenAttached()
fun View.doOnApplyWindowInsets(f: (View, Rect, WindowInsetsCompat) -> Unit) {
ViewCompat.setOnApplyWindowInsetsListener(this) { v, insets ->
f(v, insetsWithToolbar(insets), insets)
insets
}
// It's important to explicitly request the insets (again) in
// case the view was created in Fragment.onCreateView() because
// setOnApplyWindowInsetsListener() won't fire when the view
// isn't attached.
requestApplyInsetsWhenAttached()
}
private fun insetsWithToolbar(insets: WindowInsetsCompat? = null) = Rect(
@@ -44,7 +88,11 @@ private fun insetsWithToolbar(insets: WindowInsetsCompat? = null) = Rect(
insets?.systemWindowInsetBottom ?: 0
)
@RequiresApi(Build.VERSION_CODES.KITKAT)
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)
@@ -10,6 +10,8 @@ import de.markusfisch.android.scalingimageview.widget.ScalingImageView
open class ConfinedScalingImageView : ScalingImageView {
val insets = Rect()
var runAfterLayout: (() -> Unit)? = null
constructor(context: Context, attrs: AttributeSet, defStyle: Int) :
super(context, attrs, defStyle)
@@ -37,5 +39,8 @@ open class ConfinedScalingImageView : ScalingImageView {
)
}
centerRemap()
runAfterLayout?.invoke()
}
fun isInBounds(): Boolean = inBounds()
}
@@ -27,7 +27,8 @@ class ConfinedScrollView : ScrollView {
// not putting it on the queue makes it only work sometimes.
post {
getChildAt(0)?.also { child ->
scrollable = height < child.height + paddingTop + paddingBottom
scrollable = height <
child.height + paddingTop + paddingBottom
colorSystemAndToolBars(
context,
scrollY > 0,
@@ -2,18 +2,23 @@ package de.markusfisch.android.binaryeye.widget
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.*
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 android.os.Build
import android.os.Parcel
import android.os.Parcelable
import android.support.v4.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
import de.markusfisch.android.binaryeye.graphics.getDashedBorderPaint
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
@@ -23,26 +28,40 @@ class DetectorView : View {
val coordinates = FloatArray(32)
val roi = Rect()
var cropHandleName: String? = null
var onRoiChange: (() -> Unit)? = null
var onRoiChanged: (() -> Unit)? = null
private val currentOrientation = resources.configuration.orientation
private val invalidateRunnable: Runnable = Runnable {
coordinatesLast = 0
invalidate()
}
private val roiPaint = context.getDashedBorderPaint()
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
private val handlePos = Point(-1, -1)
private val inactiveHandlePos = Point(-1, -1)
private val handlePos = Point(inactiveHandlePos)
private val handleHome = Point()
private val center = Point()
private val touchDown = Point()
@@ -52,6 +71,8 @@ class DetectorView : View {
private val fabHeight: Int
private val padding: Int
private var viewWidth = 0
private var viewHeight = 0
private var coordinatesLast = 0
private var handleGrabbed = false
private var handleActive = false
@@ -78,37 +99,38 @@ class DetectorView : View {
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) :
super(context, attrs, defStyleAttr)
fun saveCropHandlePos() {
val pos = getCropHandlePos()
prefs.cropHandleX = pos.x
prefs.cropHandleY = pos.y
prefs.cropHandleOrientation = currentOrientation
}
private fun getCropHandlePos() = if (handleActive) {
handlePos
} else {
Point(-1, -1)
}
fun restoreCropHandlePos() {
setCropHandlePos(
prefs.cropHandleX,
prefs.cropHandleY,
prefs.cropHandleOrientation
fun storeCropHandlePos() {
val name = cropHandleName ?: return
val pos = if (handleActive) {
handlePos
} else {
inactiveHandlePos
}
prefs.storeCropHandle(
name,
viewWidth,
viewHeight,
pos
)
}
private fun setCropHandlePos(x: Int, y: Int, orientation: Int) {
// Always set handlePos even if it's invalid because
// handlePos.x may be -2 which is a signal to set the
// default ROI.
if (orientation == currentOrientation) {
handlePos.set(x, y)
} else {
handlePos.set(y, x)
fun updateCropHandlePos() {
val name = cropHandleName ?: return
if (viewWidth < 1 || viewHeight < 1) {
return
}
val cropHandle = prefs.restoreCropHandle(name, viewWidth, viewHeight)
if (cropHandle.x == -2) {
setHandleToDefaultRoi()
} else {
handlePos.set(cropHandle.x, cropHandle.y)
handleActive = handlePos.x > -1
}
if (handleActive) {
updateClipRect()
} else {
reset()
}
handleActive = handlePos.x > -1
}
fun update(numberOfCoordinates: Int) {
@@ -118,31 +140,6 @@ class DetectorView : View {
postDelayed(invalidateRunnable, 500)
}
override fun onSaveInstanceState(): Parcelable? {
if (!handleActive) {
return super.onSaveInstanceState()
}
return SavedState(super.onSaveInstanceState()).apply {
savedHandlePos.set(getCropHandlePos())
savedOrientation = currentOrientation
}
}
override fun onRestoreInstanceState(state: Parcelable) {
super.onRestoreInstanceState(
if (state is SavedState) {
setCropHandlePos(
state.savedHandlePos.x,
state.savedHandlePos.y,
state.savedOrientation
)
state.superState
} else {
state
}
)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent?): Boolean {
event ?: return super.onTouchEvent(event)
@@ -211,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
}
@@ -239,40 +236,48 @@ class DetectorView : View {
roi.set(0, 0, 0, 0)
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
override fun onLayout(
changed: Boolean,
left: Int,
top: Int,
right: Int,
bottom: Int
) {
super.onLayout(changed, left, top, right, bottom)
val width = right - left
val height = bottom - top
viewWidth = right - left
viewHeight = bottom - top
center.set(
left + (width / 2),
top + (height / 2)
viewWidth / 2,
viewHeight / 2
)
minY = padding * 2
maxY = height - minY
maxY = viewHeight - minY
handleHome.set(
width - handleXRadius - paddingRight - padding,
height - handleYRadius - paddingBottom - fabHeight
viewWidth - handleXRadius - paddingRight - padding,
viewHeight - handleYRadius - paddingBottom - fabHeight
)
if (handlePos.x == -2) {
setHandleToDefaultRoi()
}
if (handleActive) {
updateClipRect()
} else {
reset()
}
updateCropHandlePos()
}
override fun onDraw(canvas: Canvas) {
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) {
@@ -289,10 +294,8 @@ class DetectorView : View {
if (minDist < 1) {
return
}
// canvas.clipRect() doesn't work reliably below KITKAT.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
val radius = min(minDist / 2, cornerRadius).toFloat()
save()
val radius = min(minDist / 2, cornerRadius).toFloat()
withSave {
clipOutPathCompat(
calculateRoundedRectPath(
roi.left.toFloat(),
@@ -304,9 +307,6 @@ class DetectorView : View {
)
)
drawColor(shadeColor)
restore()
} else {
drawRect(roi, roiPaint)
}
}
@@ -319,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)
@@ -344,37 +366,6 @@ class DetectorView : View {
private fun clampX(x: Int) = min(center.x * 2, max(0, x))
private fun clampY(y: Int) = min(maxY, max(minY, y))
internal class SavedState : BaseSavedState {
val savedHandlePos = Point()
var savedOrientation = 0
constructor(superState: Parcelable?) : super(superState)
private constructor(parcel: Parcel) : super(parcel) {
savedHandlePos.set(
parcel.readInt(),
parcel.readInt()
)
savedOrientation = parcel.readInt()
}
override fun writeToParcel(out: Parcel, flags: Int) {
super.writeToParcel(out, flags)
out.writeInt(savedHandlePos.x)
out.writeInt(savedHandlePos.y)
out.writeInt(savedOrientation)
}
companion object {
@JvmField
val CREATOR = object : Parcelable.Creator<SavedState> {
override fun createFromParcel(source: Parcel) = SavedState(source)
override fun newArray(size: Int): Array<SavedState?> = arrayOfNulls(size)
}
}
}
}
private fun distSq(a: Point, b: Point): Int {
@@ -3,19 +3,29 @@ package de.markusfisch.android.binaryeye.widget
import android.content.Context
import android.widget.Toast
fun Context.toast(message: Int) = Toast.makeText(
applicationContext,
message,
Toast.LENGTH_LONG
).show()
private var lastToast: Toast? = null
fun Context.toast(message: String) = Toast.makeText(
applicationContext,
message.ellipsize(128),
Toast.LENGTH_LONG
).show()
fun <T> Context.toast(message: T) {
lastToast?.cancel()
lastToast = when (message) {
is String -> Toast.makeText(
applicationContext,
message.ellipsize(),
Toast.LENGTH_LONG
)
private fun String.ellipsize(max: Int) = if (length < max) {
is Int -> Toast.makeText(
applicationContext,
message,
Toast.LENGTH_LONG
)
else -> return
}
lastToast?.show()
}
private fun String.ellipsize(max: Int = 128) = if (length < max) {
this
} else {
"${take(max)}"
@@ -0,0 +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() = 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>

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