Compare commits

..
146 Commits
Author SHA1 Message Date
Markus Fisch e318385032 Advance version number to 1.30.0 2020-05-30 14:42:17 +02:00
Markus Fisch 9b1445efb5 Draw a dotted rectangle around ROI below Kitkat
Because Canvas.clipRect() doesn't work very well below
Kitkat.
2020-05-30 14:32:35 +02:00
Markus Fisch 9a74098f11 Commit tryHarder and autoRotate instead of apply
It's important those values are updated before returning to the
CameraActivity. `apply()` saves preferences asynchronously what
can be too late.
2020-05-30 13:46:52 +02:00
Markus Fisch e22d242248 Add a setting to show/hide crop handle
In case somebody doesn't like/want this feature.
2020-05-30 13:40:01 +02:00
Markus Fisch 77d419aa7b Refactor axis to center in DetectorView
And name all crop handle variables just handle.
2020-05-30 13:22:05 +02:00
Markus Fisch b2fbdceee4 Put crop handle to center bottom for portait
And to center right in landscape.
2020-05-30 12:40:23 +02:00
Markus Fisch 8a625dc5d0 Fix malformed XML of spanish translation 2020-05-30 12:40:11 +02:00
Markus Fisch 6d9c6aa53f Snap crop area for cancelled touches too
And make handlePos private because it's not used from the outside yet.
2020-05-30 12:37:29 +02:00
Markus Fisch 6b03935b9a Add a drop shadow to crop handle
To give it a similar look like the FAB.
2020-05-30 12:34:32 +02:00
Markus Fisch fcac0add8b Add a handle to define a region of interest
And scan only within that region.

Helps to scan only a certain barcode when there are a lot
of barcodes very close together.
2020-05-29 21:02:45 +02:00
Markus Fisch f63b78cc28 Remove insignificant inline argument
Because "inlining works best for functions with parameters
of functional types".
2020-05-29 09:03:50 +02:00
Diego SanguniettiandGitHub f34194c5b5 Add spanish translation 2020-05-29 08:57:24 +02:00
Markus Fisch f2a22d2312 Keep camera selection over orientation changes
And save the facing flag in the state bundle too.
2020-05-28 22:24:56 +02:00
Markus Fisch 746bcc0706 Simplify generating hex dump
To export binary data.
2020-05-27 22:29:27 +02:00
Markus Fisch 2525b693c7 Add support for VCALENDAR types
Treat VCALENDAR like VEVENT types as VCALENDAR is just a wrapper
around a VEVENT.

Also add a few simple tests for VCARD, VCALENDAR and VEVENT to
make sure everything works as expected.
2020-05-27 22:23:00 +02:00
Markus Fisch ba9970eb4d Vibrate only if activity isn't finishing
With Gesture Navigation, a back may also move the image a bit
what will cause the view to redraw and to vibrate even if the
activity is already finishing.
2020-05-24 15:37:44 +02:00
Markus Fisch 0495985518 Add copy to clipboard button to context menu
So it's easy to quickly copy something from the history.
2020-05-22 19:18:33 +02:00
Markus Fisch b662ba9307 Enable/disable menu items in history list
Depending on if there are scans visible or not.
2020-05-22 19:04:47 +02:00
Markus Fisch c4fe713b6d Check for null before using last list child 2020-05-22 18:55:13 +02:00
Markus Fisch 3609c377d2 Always remove current listing in history only
When the list is filtered, the remove all button should only
affect those records.
2020-05-22 18:53:11 +02:00
Markus Fisch 8043ab7eff Advance version number to 1.29.0 2020-05-22 13:39:41 +02:00
Markus Fisch 4f7b392611 Add a note about using the NDK and RenderScript
At the time of writing, it's important to _not_ have the NDK available
when compiling the custom rotation kernel. This will produce a broken
build for some ARMv7 devices running Android 6 (e.g. One Plus X,
Yotaphone 2, Moto E) while it works for newer Android versions.
2020-05-19 22:12:19 +02:00
Markus Fisch 27fb611f4e Add a note about scanning 2D barcodes with ZXing 2020-05-19 22:07:35 +02:00
Markus Fisch 3c693c7bd5 Rotate every other frame if auto rotate is set
Add a new setting that will automatically rotate every other frame
by 90 degrees. This makes it possible to read vertical 1D barcodes
too. ZXing can read 1D barcodes horizontally only.

The setting is unset by default because it makes recognition of 1D
barcodes a tiny bit slower since half of the frames are now useless
for ZXing (because the barcode is in the wrong orientation on them).

On a fast device, this is hardly noticeable. But on a low-end device
it makes a slight difference which is why the setting is unset by
default.
2020-05-19 20:21:27 +02:00
Markus Fisch b2cc1c3c34 Move posting result into its on function
Easier to read.
2020-05-15 13:46:08 +02:00
Markus Fisch e2effb613f Add a rule to dump meminfo of debug version 2020-05-15 13:38:51 +02:00
Markus Fisch ae422d9a02 Advance version number to 1.28.0 2020-05-15 10:14:50 +02:00
zmniandGitHub ace841814a Update Indonesian translation 2020-05-15 09:55:16 +02:00
Markus Fisch 21c3206464 Reset preprocessor to fit current orientation
Important for landscape to landscape orientation changes.
2020-05-14 20:28:05 +02:00
Markus Fisch f392baca8b Remove special RenderScript handling for LineageOS
Always running `RenderScript.forceCompat()` interferes with the
`forceCompat` flag that is set/unset if there is a RSRuntimeException.

Now that the app is restarting automatically for a RSRuntimeException
this special handling is no longer required anyway.
2020-05-14 14:08:28 +02:00
Markus Fisch 4cd0fb6418 Restart the app a fixed number of times only
To avoid being stuck in a restart loop if there is always a
RSRuntimeException not matter if forceCompat() is called or
not.
2020-05-14 14:05:06 +02:00
Markus Fisch 5b948a4c8f Add a commit() method to preferences too
Removes the doubled code for putting a Boolean into preferences.
2020-05-14 14:02:25 +02:00
Markus Fisch a75aff2e64 Add a note about RenderScript to the README
To save others from stumbling over the same problems.
2020-05-14 11:58:25 +02:00
Markus Fisch a49a49f1d2 Save preference onStop() instead of onPause()
There's no need to save preference in onPause() when there's
onStop().
2020-05-13 13:42:07 +02:00
Markus Fisch 6db46f1d0d Automatically restart the app after a RSRuntimeExc
And toggle the forceCompat flag instead of setting it to true
so it works the other way round too.

Of course, this makes much more sense if there was a switch to
manually enable/disable forceCompat so a user can play with it.

On the other hand, I don't want users to bother with this.
So it's probably best to try and fix this behind the curtain.
2020-05-13 13:35:46 +02:00
Markus Fisch 1c0062df8b Share history as CSV and JSON too 2020-05-12 12:17:45 +02:00
Markus Fisch d776688d26 Refactor string resource name
error_saving_binary_data should really be just error_saving_file.
2020-05-12 11:58:39 +02:00
Markus Fisch 9856aa9efa Move showing result toast to top most call
So toasts are not scattered all over export functions.
2020-05-12 11:53:58 +02:00
Markus Fisch 27ce6cb12b Export history in JSON format 2020-05-12 10:48:11 +02:00
Markus Fisch 9d3a2b8479 Simplify writing external files
And refactor getExternalOutputStream() to openExternalOutputStream().
2020-05-12 10:44:20 +02:00
Markus Fisch 74f0c050c0 Advance version number to 1.27.1 2020-05-11 20:15:32 +02:00
Markus Fisch af92d1db17 Refactor preference setting functions
It's much simpler that way and you know it's going to use
`apply()` and not `commit()`.
2020-05-11 11:40:40 +02:00
Markus Fisch 307401087e Run RenderScript.forceCompat() after RSRuntimeExc
Because there are more systems than just Lineage that need
`RenderScript.forceCompat()`.

To not hold back everyone else, the app now silently switches to
`forceCompat()` once there was a RSRuntimeException. Thats's very
crude because there may be other reasons for RSRuntimeException's
*and* the app will still crash even if `forceCompat()` is the fix
because `forceCompat()` must be called before every other
RenderScript function.

But that's probably the best I can do right now.
2020-05-11 11:31:16 +02:00
Ptilopsis LeucotisandGitHub a36fd668a9 Update Russion translation
And mark untranslatable string as `translatable="false"` for Android Studio
Translation Editor that doesn't recognize `donottranslate.xml`.
2020-05-07 14:22:00 +02:00
Markus Fisch 7e022249aa Advance version number to 1.27.0 2020-05-04 21:01:01 +02:00
Markus Fisch d464cc26b0 Remove Leak Canary from debug build
Because specifying a different minSdkVersion for the debug version
results in a higher minSdkVersion in the release build too. So

	buildTypes {
		debug {
			defaultConfig.minSdkVersion 14
			...
		}
		...
	}

will mean the release build will have minSdkVersion 14 too.
WTF, Gradle.

This reverts commit 45eb24036f.
2020-05-04 20:47:56 +02:00
Markus Fisch 45eb24036f Add Leak Canary to debug build
And increase minSdk for the debug build to 14 for Leak Canary.
2020-05-04 13:42:32 +02:00
Markus Fisch ea0212c58e Remove special treatment for macOS Catalina
Since 64bit Linux systems can build with build tools 29.0.3 too
and RenderScript.forceCompat() is only executed for Lineage OS
now.

Still, do NOT build release versions with macOS Catalina or the
build will break on Lineage OS!
2020-05-04 13:25:55 +02:00
Markus Fisch 170a9944ef Replace static inset callback for view callbacks
Using a common static callback function to set the paddings from
WindowInsets creates memory leaks because the function holds
references to the views and so to the Activity or Fragment.

Every view needs to have its own WindowInsetsListener that cannot
hold any references from its Activity or Fragment.

So I'm finally biting the bullet and do it like Chris Banes
described here:
https://medium.com/androiddevelopers/windowinsets-listeners-to-layouts-8f9ccc8fa4d1
2020-05-02 21:13:51 +02:00
unbranchedandGitHub ccfd356c67 Update Italian translation 2020-05-01 13:31:15 +02:00
Markus Fisch 91aa1d343e Put metadata into fastlane folder
Apparently the this data needs to be in a fastlane
folder so fastlane supply can work.
2020-04-29 22:02:26 +02:00
Markus Fisch b64b3c5c01 Add a note about the lacking MAXICODE support
And remove it from the metadata.

ZXing can read MAXICODEs only in PURE mode what means the
source image must be monochrome, unrotated and unskewed.
Of course, this isn't the case for a camera frame and this
is why Binary Eye cannot read MAXICODEs.
2020-04-29 10:01:36 +02:00
Markus Fisch dfb6c07930 Always check activity for null before use 2020-04-29 09:39:18 +02:00
Markus Fisch 6ca93f2502 Remove unused code 2020-04-29 09:37:22 +02:00
Markus Fisch e867651acd Add metadata for F-Droid and Google Play
And use these screenshots instead of the ones in gh-page in
the README.

It's probably better to put those screenshots in the master
branch anyway. This way, the images can always be displayed
even when offline.
2020-04-27 20:48:25 +02:00
Markus Fisch 607e1324ed Export SQLite database 2020-04-27 20:47:20 +02:00
Markus Fisch 256183b4aa Fix version headlines in CHANGELOG 2020-04-27 10:10:56 +02:00
Markus Fisch 733b6bb123 Improve order of preferences
Arrange preferences in the order a user would encounter them.
2020-04-27 09:31:33 +02:00
Markus Fisch 27a382f2cb Advance version number to 1.26.0 2020-04-26 20:32:44 +02:00
Markus Fisch b0eb601e5a Add a setting for swipe to zoom feature 2020-04-25 19:37:37 +02:00
Markus Fisch 3a3e743575 Stop calling focusTo() as soon as it returns false
To avoid throwing and catching future RuntimeExceptions.
2020-04-25 19:22:20 +02:00
Markus Fisch f08efb8238 Zoom in & out by swiping vertically on camera view
Alternative way to zoom in/out.
Probably a bit more convenient than using the slider.
2020-04-24 20:27:04 +02:00
Markus Fisch 723362dd67 Update build tools 2020-04-24 19:02:27 +02:00
zmniandGitHub dde039a299 Update Indonesian translation 2020-04-23 14:40:14 +02:00
Markus Fisch f2a056c1d0 Add avocado rule to Makefile
To make this a regular task.
2020-04-21 09:19:57 +02:00
Markus Fisch 0defe263ad Optimize vector drawables with avocado
See:
https://github.com/alexjlockwood/avocado
2020-04-20 21:01:25 +02:00
Markus Fisch 9d8d33de06 Update Kotlin version 2020-04-17 14:19:23 +02:00
Markus Fisch 738b4d0f7b Advance version number to 1.25.1 2020-04-15 12:22:05 +02:00
Markus Fisch 80378afa84 Explicitly keep support.v7.widget.SearchView
Somehow it's required to name this class explicitly so ProGuard
won't remove it.
2020-04-15 11:06:32 +02:00
Markus Fisch ec07e10208 Advance version number to 1.25.0 2020-04-14 21:45:16 +02:00
Markus Fisch 2a6cd169aa Remove unused otpauth_error resource string 2020-04-14 21:23:43 +02:00
Markus Fisch 051454988a Keep selection when item is scrolled out of view 2020-04-14 21:21:05 +02:00
Markus Fisch a388d7cbec Update Kotlin version 2020-04-14 21:19:32 +02:00
Markus Fisch fa45d9e088 Show a label next to renamed entries
So a user can easily tell what entries are labeled.
2020-04-14 17:54:50 +02:00
Markus Fisch 41e538fa97 Remove unused function to show soft keyboard 2020-04-14 12:31:09 +02:00
Markus Fisch 1753438288 Handle null in resultPoints array
Apparently ZXing returns an array that may contain null references
for some barcodes.
2020-04-14 12:20:34 +02:00
Markus Fisch 4135dfbd3f Add app shortcuts for launchers that support them
So a user can go directly to the preferences for example.
2020-04-14 12:06:30 +02:00
Markus Fisch 4de7d5d5d7 Remove context argument from update()
update() is tied to the fragment and can never be pure so
it's simpler to just get the context/activity inside the
function.
2020-04-14 09:32:13 +02:00
Markus Fisch c4856f97ef Use the same icon to remove all or just one scan 2020-04-13 17:04:29 +02:00
Markus Fisch aa885b8379 Add an action to search history 2020-04-13 17:02:13 +02:00
Markus Fisch eab9639242 Remove DatabaseRepository and export binary as hex
Well, it was nice seeing Flow in action but I think it's slowly
becoming more of a burden than a benefit.

The traditional Cursor approach sure isn't as nice, but it's much
more simple, does use less resources (because we never need to put
things into an object) and it's less code too.

Also, scans that contain binary data are now always exported as
a hex string.
2020-04-12 21:42:05 +02:00
Markus Fisch a199d82539 Color status bar in contextual mode to match CAB 2020-04-11 19:06:56 +02:00
Markus Fisch 469fc2ff5f Remove irrelevant one quantity for IN and CH
Apparently, in Chinese and Indonesian, only the other quantity
is ever used.
2020-04-11 14:00:25 +02:00
Markus Fisch b4261d8571 Give scans an individual name in history listing
So we can add some information about the scan.
2020-04-11 13:55:45 +02:00
Markus Fisch 725817151d Reuse ColorDrawable for ActionBar only for API 11+
ColorDrawable.setColor() doesn't exist pre Honeycomb.
2020-04-11 13:54:02 +02:00
Markus Fisch e434300eda Define nav bar color only if it's required 2020-04-10 19:40:28 +02:00
Markus Fisch 6b08c422cb Simplify loading translucent primary color
For the translucent system bars.
2020-04-10 19:14:57 +02:00
Markus Fisch 4bfa7b3582 Remove saving barcodes for intents from other apps
Since the app shouldn't save contents meant for other apps.
2020-04-08 18:43:52 +02:00
Markus Fisch d6f902ae02 Improve coloring system bars
Avoid constant (re)allocation of ColorDrawable for the action
bar background.

Also load the transparent primary color only once from Resources.

It's a tiny bit more efficient although nobody will be able to
actually feel a difference ;)
2020-04-07 23:19:30 +02:00
Markus Fisch 94023b0671 Separate Mapping into its own file
Makes it easier to find.
2020-04-07 23:12:28 +02:00
Markus Fisch 16a9428bc1 Separate handling window insets into its own file
Window insets have nothing to do with coloring system bars.
2020-04-07 23:11:40 +02:00
Markus Fisch 3db5891e0f Set padding on history instead of header/footer
Moves the scroll bar initially below the action bar where
it should be. Also, it's simpler this way.
2020-04-05 20:19:41 +02:00
Markus Fisch d03fd72b95 Use import/export icon for export history listing
It's less about saving and more about exporting something.
2020-04-05 14:44:36 +02:00
Markus Fisch 97640c2a1f Update tools version 2020-04-03 18:27:28 +02:00
Markus Fisch 3bba6cf10d Make sure to handle a null ResultPoint correctly
Never trust ZXing to deliver a non-null object.
Also make sure to not use an empty array of ResultPoint's.
2020-04-03 18:11:45 +02:00
Markus Fisch aca97afc78 Ignore consecutive duplicates by default
For new installations that is, of course.
2020-04-03 15:50:19 +02:00
Markus Fisch 3b46da5571 Add a message for copying the WiFi password 2020-04-03 15:46:34 +02:00
Markus Fisch df62881ee6 Refactor put_into_clipboard to copied_to_clipboard
To make it more clear that this is the message that is shown
*after* something has been copied into the clipboard.

Also update the german and english translations accordingly.
2020-04-03 15:38:33 +02:00
Markus Fisch 966eea4f3f Always put parsed WiFi password into clipboard
So if adding the network automatically fails, it's easier
for to manually connect to the WiFi.
2020-04-03 15:34:23 +02:00
Markus Fisch eb2ed05b6d Move clipboard handling into it's own file
So we can use from elsewhere too.
2020-04-03 15:33:44 +02:00
Markus Fisch f31c1eee0e Only request ACCESS_FINE_LOCATION below Android Q
Not required to suggest a network for Android Q.
2020-04-03 15:31:57 +02:00
Markus Fisch c61db34d27 Check return values when adding a WiFi
Only before Android Q.

Also refactor connect() to addNetwork() because on Android Q,
we can only suggest new networks but cannot directly connect
to them.
2020-04-03 15:29:20 +02:00
Markus Fisch 3d85cef8a0 Move vibration setting above history
use_history and ignore_consecutive_duplicates should be
directly next to each other.
2020-04-03 12:37:15 +02:00
Markus Fisch 276fcccc46 Request ACCESS_FINE_LOCATION for adding a WiFi
configuredNetworks requires ACCESS_FINE_LOCATION.
2020-04-03 12:34:50 +02:00
Markus Fisch caa075f422 Add a setting to control vibration on detection 2020-04-03 11:59:35 +02:00
Markus Fisch cc629314ca Update german translation for TRY_HARDER 2020-04-03 11:51:45 +02:00
Markus Fisch c9242c3475 Make test rule run only unit tests
And add an explicit "cat" rule to do test on a connected
Android device.
2020-04-03 11:49:28 +02:00
Markus Fisch 3a87416cdb Add missing annotations for experimental stuff 2020-04-03 11:43:03 +02:00
Markus Fisch 4c4f60bddc Add a setting to enable TRY_HARDER
And always enable it when scanning from an image.
2020-04-03 11:41:42 +02:00
Markus Fisch 557652b18e Remove test for hex strings in WiFi codes
Because SSID/password are no longer quoted in the HashMap.

Instead, both will be quoted when they're used in a WifiConfiguration
since Android Q's WifiNetworkSuggestion.Builder never needs those
values quoted.

Also, since it is fundamentally impossible to tell whether a string
is a hex representation or happens to just consist of characters
from 0 to f, the quotes are now only omitted for a password string
that is exactly 64 characters long and contains only 0-f (raw PSK).
2020-04-03 09:37:06 +02:00
Markus Fisch a1094626c4 Fix some style issues
Apply auto format.
2020-04-02 22:11:58 +02:00
Markus Fisch e45dd7ff18 Remove hex detection for SSID
Unfortunately, we can never know whether a string consisting only
of 0-f is a hex string. So we just make this one assumption that
a string of 0-f that is exactly 64 characters long, is _probably_
a hex string for raw PSK. Of course, this may fail for any string
that just happens to be 64 characters long and contains just 0-f.
2020-04-02 22:02:02 +02:00
Markus Fisch 1c1625b6fd Simplify toasting with an extension function
All these Toast.makeText().show() blocks mean too many
unnecessary repetitions.
2020-04-02 21:34:39 +02:00
Markus Fisch 9c4cb0daad Suggest a WiFi network on Android Q
Because WifiConfiguration and WifiManager.addNetwork() (and all
related functions) were deprecated in Android Q.

Because we target Android Q, WifiManager.addNetwork() will always
fail and return -1.

On Android Q, we can only suggest the system (and user) a WiFi
network but cannot add it permanently ourselves.
2020-04-02 21:28:55 +02:00
Markus Fisch 024dd379e8 Run RenderScript.forceCompat() for LineageOS only
Since forceCompat() disables the native implementation, what is
a drawback for all other devices whose native implementation is
fully functional.
2020-04-02 21:23:55 +02:00
Markus Fisch 16c4957c79 Simplify saving a generated barcode image 2020-04-01 22:38:09 +02:00
Markus Fisch 01741b83cd Fix vibrating for Android O and higher
The amplitude was too small.
2020-04-01 22:30:55 +02:00
Markus Fisch 4200b3274a Fix typo in german translation 2020-04-01 22:27:16 +02:00
Markus Fisch eabd14eb60 Use ContentResolver to save external files on Q+
To support Android Q and future Android versions.
2020-04-01 22:21:37 +02:00
Markus Fisch 778c087930 Clean up unnecessary/unused code
According to Android Studio's recommendations.
2020-04-01 15:16:09 +02:00
Markus Fisch cd646a2e90 Add annotations for deprecated code
Because this code is not going to change anytime soon.
So it's better to just annotate them to keep those warnings
out of the way.
2020-04-01 15:13:48 +02:00
Markus Fisch be6576ac74 Add annotations to mark experimental/preview stuff 2020-04-01 15:13:20 +02:00
Markus Fisch 6460500a4d Use new ClipboardManager for HoneyComb and better 2020-04-01 14:52:24 +02:00
Markus Fisch 02474dfad2 Move getting fragment for intent into own function
Improves readability and needs less code.
2020-04-01 13:57:52 +02:00
Markus Fisch d78fd109ff Use VibrationEffect in Android O or better
Instead of the deprecated `Vibrator.vibrate(int)`.
2020-04-01 13:54:23 +02:00
Markus Fisch 85e3f49270 Fix path to latest Inkscape on macOS 2020-04-01 10:23:52 +02:00
Markus Fisch 59d40b944b Show latest possible result point in camera view 2020-03-30 19:57:57 +02:00
Markus Fisch 1e82e8bd53 Use a sorted map to enlist metadata items
A HashMap is unsorted of course what means the order of the
metadata items was random.
2020-03-27 18:12:20 +01:00
Markus Fisch ab5e9b6e9b Advance version number to 1.24.0 2020-03-24 21:29:48 +01:00
Markus Fisch 7336d8d12b Try to resolve URL intent before starting it
Because, apparently, there are devices where this intent cannot
be resolved.

Also put the project URL into a resource so we can at least show
it in a Toast if there's no browser installed. Might as well put
it in the clipboard some day.
2020-03-24 21:09:42 +01:00
Markus Fisch 1c9dd73d9a Check resultPoints for null before using it
ZXing's Result.getResultPoints() may as well return null.
2020-03-24 20:55:07 +01:00
Markus Fisch 5892002a89 Use legacy storage permission model for Android Q
Just simply use `requestLegacyExternalStorage="true"` to opt out
of the new Scoped storage model for the time being.

As soon as we set Android 11 as target version, this will no longer
work!

Details on the issue:
https://developer.android.com/preview/privacy/storage
2020-03-24 20:34:06 +01:00
Markus Fisch 540290e97c Include meta data in CSV export 2020-03-24 20:26:00 +01:00
Markus Fisch f315916fb8 Add a dot before csv suffix
The suffix is not an extension but just a string that
gets appended to the file name.
2020-03-24 20:25:02 +01:00
Markus Fisch e75bb48360 Add a preference switch to show/hide metadata
If there's one for the hex dump, there should be a switch
for the metadata as well.
2020-03-24 18:28:36 +01:00
Markus Fisch 2b1d711168 Show ZXing metadata after decoding
Also save the metadata in the history so it's still available
for past scans.
2020-03-24 18:21:30 +01:00
unbranchedandGitHub 2af5a89430 Update italian translation 2020-03-21 14:32:13 +01:00
Markus Fisch b640fa1372 Reset flash flag when camera is closed
Closing the camera will also shut off the flash.

This fixes the bug that when you scan a barcode with the flash on and
immediately go back (to the previous instance of CameraActivity), the
flash flag will still be true when the flash is off.

Fix for https://github.com/markusfisch/BinaryEye/issues/101
2020-03-19 18:08:54 +01:00
Markus Fisch 8e24673d08 Advance version number to 1.23.0 2020-03-14 20:14:12 +01:00
Markus Fisch ae54e62ec3 Update comments about building on macOS Catalina 2020-03-14 20:05:14 +01:00
Markus Fisch 420a4cbad3 Limit history items to one line
And ellipsize content if longer.
2020-03-14 19:47:45 +01:00
Markus Fisch 05ec77da27 Add a setting to show/hide hex dump 2020-03-14 19:39:17 +01:00
Markus Fisch c03abf1148 Add a setting to not save consecutive double scans 2020-03-14 16:11:35 +01:00
Markus Fisch b929df4848 Always set window inset listener
In case there is a configuration (orientation) change later on.

And always return previously initialized window insets what is
required when Fragments change and the listener won't fire (again).

Also use an explicit variable to know when the window insets rect
has been initialized. `Rect.isEmpty()` doesn't cut the mustard.
2020-03-14 15:56:53 +01:00
Markus Fisch ec729b6624 Move infos about supported barcodes down
Probably the least interesting part.
2020-03-14 14:31:52 +01:00
Markus Fisch 67471ee141 Advance version number to 1.22.3 2020-03-12 10:07:30 +01:00
207 changed files with 4018 additions and 1583 deletions
+88 -19
View File
@@ -1,27 +1,96 @@
# Change Log
# 1.22.2
## 1.30.0
* Add a handle to define a region of interest
* Add a setting to show/hide cropping limiter
* Add support for VCALENDAR types
* Add spanish translation
* Add copy to clipboard button to context menu in history listing
* Make history actions work on current listing only
* Keep camera selection over orientation changes
## 1.29.0
* Add a setting to enable reading of vertical 1D barcodes
## 1.28.0
* Share history as CSV and JSON too
* Export history in JSON format
* Update Indonesian translation
* Fix initializing of RenderScript automatically
* Fix highlighting after landscape to landscape rotations
## 1.27.1
* Update Russian translation
* Force compat mode if native RenderScript crashed
## 1.27.0
* Export SQLite database
* Improve order of preferences
* Update Italian translation
## 1.26.0
* Zoom in & out by swiping vertically in camera view
* Add a setting for swipe to zoom feature
* Optimize vector drawables
* Update Indonesian translation
## 1.25.1
* Fix Proguard configuration
## 1.25.0
* Add giving scans individual names in history listing
* Add searching the history
* Add a setting to search harder for barcodes
* Add a setting to control vibration on detection
* Add app shortcuts for launchers that support them
* Remove saving barcodes for intents from other apps
* Show latest possible result point in camera view
* Suggest a WiFi network on Android Q+
* Always put parsed WiFi password into clipboard
* Use native image processing where available
* Ignore consecutive duplicates by default
* Fix saving external files on Android Q+
* Fix order of metadata items
## 1.24.0
* Show barcode metadata
* Add a setting to show/hide metadata
* Include metadata in CSV export
* Update italian translation
* Fix resetting flash button
* Fix CSV export for Android Q
## 1.23.0
* Add a setting to not save consecutive double scans
* Add a setting to show/hide hex dump
* Limit history items to just one line
* Fix window insets for consecutive screens
## 1.22.3
* Fix broken RenderScript built
## 1.22.2
* Fix getting window insets for Android Lollipop and above
# 1.22.1
## 1.22.1
* Ensure non-printable content is saved as raw data
# 1.22.0
## 1.22.0
* Highlight detected barcodes in camera view
* Add bing and yandex search engines
* Update Chinese Translation
* Update Italian translation
* Update Indonesian translation
# 1.21.0
## 1.21.0
* Save and restore history list state
* Improve scanning from large images
* Fix opening image files by "Open with"
# 1.20.1
## 1.20.1
* Fix window insets below Android Lollipop
# 1.20.0
## 1.20.0
* Scroll content below tool and system bars
* Preset file name to save with format and content
* Add encode menu item to make encoding easier when keyboard is visible
@@ -32,28 +101,28 @@
* Fix returning result for ZXing's SCAN intent again
* Fix transfering barcode format from history
# 1.19.0
## 1.19.0
* Allow scaling a new barcode to half its size
* Remember last selected barcode format
# 1.18.0
## 1.18.0
* Make open URL default action after decoding a barcode
* Add a setting to open contents without inspection
* Highlight codes for picking in shared/loaded images
* Improve visability of crop border
* Fix returning result for ZXing's SCAN intent
# 1.17.1
## 1.17.1
* Fix navigating back without choosing an image
# 1.17.0
## 1.17.0
* Pinch/Zoom shared/loaded images to scan just a section
* Add a load file menu item to load an image file
* Add a save file menu item to the barcode view
* Use discrete style for size seek bar
* Fix bar transparency when zooming/pinching
# 1.16.0
## 1.16.0
* Improve displaying scanned barcodes
* Show a message if a barcode cannot be shared
* Fix setting system bar transparency when double taping a barcode
@@ -62,36 +131,36 @@
* Update Indonesian translation
* Update Italian translation
# 1.15.0
## 1.15.0
* Additionally export history as CSV file
* Color tool and system bars when a view is scrolled
* Add an intent filter for opening images
* Add Russian translation
# 1.14.0
## 1.14.0
* Add OTP support
* Update Indonesian translation
# 1.13.1
## 1.13.1
* Fix crash caused by missing support for Regex on Android < 6
# 1.13.0
## 1.13.0
* Add missing extras for ZXing result intents
# 1.12.0
## 1.12.0
* Add preferences dialog and the option to use a custom URL with read contents
* Add Brazilian Portuguese translation
* Add Chinese translation
* Encode text using UTF-8 encoding when creating a barcode
# 1.11.2
## 1.11.2
* Downgrade ZXing version
* Improve getting system UI metrics
# 1.11.1
## 1.11.1
* Fix tap to focus on some devices
# 1.11.0
## 1.11.0
* Add support for VCard and VEvent barcodes contents
* Generated barcodes are now zoomable
* Fix creating PDF417 barcodes
+9
View File
@@ -18,6 +18,9 @@ infer: clean
infer -- ./gradlew assembleDebug
test:
./gradlew test
cat:
./gradlew test cAT
install:
@@ -30,8 +33,14 @@ start:
uninstall:
adb $(TARGET) uninstall $(PACKAGE).debug
meminfo:
adb shell dumpsys meminfo $(PACKAGE).debug
images:
svg/update.sh
avocado:
avocado $(shell fgrep -rl '<vector' app/src/main/res)
clean:
./gradlew clean
+64 -15
View File
@@ -10,11 +10,21 @@ comes in Material Design and can also generate barcodes.
Binary Eye uses the [ZXing][zxing] ("Zebra Crossing") barcode scanning
library.
## Screenshots
![Screenshot Scanning](fastlane/metadata/android/en-US/images/phoneScreenshots/screencap-scanning.png)
![Screenshot Result](fastlane/metadata/android/en-US/images/phoneScreenshots/screencap-decoded.png)
![Screenshot Compose Barcode](fastlane/metadata/android/en-US/images/phoneScreenshots/screencap-compose-barcode.png)
## Download
<a href="https://f-droid.org/en/packages/de.markusfisch.android.binaryeye/"><img alt="Get it on F-Droid" src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" height="80"/></a> <a href="https://play.google.com/store/apps/details?id=de.markusfisch.android.binaryeye"><img alt="Get it on Google Play" src="https://play.google.com/intl/en_us/badges/images/generic/en_badge_web_generic.png" height="80"/></a>
## Supported Barcode Formats
### Read
ZXing can _read_ the following barcode formats:
ZXing can read the following barcode formats:
* [AZTEC][aztec]
* [CODABAR][codabar]
* [CODE 39][code_39]
@@ -24,7 +34,8 @@ ZXing can _read_ the following barcode formats:
* [EAN 8][ean_8]
* [EAN 13][ean_13]
* [ITF][itf]
* [MAXICODE][maxicode]
* [MAXICODE][maxicode] (only when unrotated and unskewed, see [77][77],
because of which Binary Eye *cannot* read this barcode)
* [PDF417][pdf417]
* [QR CODE][qr_code]
* [RSS 14][rss]
@@ -35,7 +46,7 @@ ZXing can _read_ the following barcode formats:
### Generate
ZXing can _generate_ the following barcode formats:
ZXing can generate the following barcode formats:
* [AZTEC][aztec]
* [CODABAR][codabar]
* [CODE 39][code_39]
@@ -48,21 +59,57 @@ ZXing can _generate_ the following barcode formats:
* [QR CODE][qr_code]
* [UPC A][upc_a]
## Screenshots
## RenderScript
<img
src="https://raw.githubusercontent.com/markusfisch/BinaryEye/gh-pages/screencap-scanning.png"
alt="Screenshot Scanning" width="160"/>
<img
src="https://raw.githubusercontent.com/markusfisch/BinaryEye/gh-pages/screencap-decoded.png"
alt="Screenshot Result" width="160"/>
<img
src="https://raw.githubusercontent.com/markusfisch/BinaryEye/gh-pages/screencap-compose-barcode.png"
alt="Screenshot Compose Barcode" width="160"/>
This app uses [RenderScript][rs] to resize and rotate the camera image.
Unfortunately, RenderScript has some nasty gotchas.
## Download
### RenderScript.forceCompat()
<a href="https://f-droid.org/en/packages/de.markusfisch.android.binaryeye/"><img alt="Get it on F-Droid" src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" height="80"/></a> <a href="https://play.google.com/store/apps/details?id=de.markusfisch.android.binaryeye"><img alt="Get it on Google Play" src="https://play.google.com/intl/en_us/badges/images/generic/en_badge_web_generic.png" height="80"/></a>
It's necessary to call `RenderScript.forceCompat()` on some devices/roms.
`RenderScript.forceCompat()` needs to be run before any other RenderScript
function and unfortunately there is no way to know if invoking `forceCompat()`
is necessary or not.
If `RenderScript.forceCompat()` is necessary, a `RSRuntimeException` will
be thrown and the only option is to restart the app, this time with calling
`forceCompat()` first.
Calling `RenderScript.forceCompat()` means the processing is done in
software so you probably don't want to enable it by default.
### macOS Catalina
At the time of writing, calling `RenderScript.forceCompat()` in a build made
on macOS Catalina with build tools 29.0.3 will crash the app. This is a bug
in the build tools for Catalina.
So *do not* build release builts on macOS Catalina with build tools 29.0.3.
Building on Linux with 29.0.3 works fine.
### Side by Side NDK
At the time of writing, it's important to _not_ have the NDK available when
compiling the custom rotation kernel. This will produce a broken build for
some ARMv7 devices running Android 6 (e.g. One Plus X, Yotaphone 2, Moto E)
while it works for newer Android versions.
### App Bundle
At the time of writing, `./gradlew bundleRelease` will produce a broken
App Bundle without the necessary RenderScript libraries. This is another
bug in the build tools.
### 2D barcodes
If you want to fork this and are only interested in reading 2D barcodes
(like QR or Aztec), you may want to remove the custom rotation kernel
altogether as ZXing can read 2D barcodes in any orientation.
This will make your app a bit simpler and saves you from compiling a
custom RenderScript kernel for each architecture you want to support.
[play]: https://play.google.com/store/search?q=barcode%20scanner&c=apps
[zxing]: https://github.com/zxing/zxing
@@ -83,3 +130,5 @@ ZXing can _generate_ the following barcode formats:
[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
[77]: https://github.com/markusfisch/BinaryEye/issues/77
[rs]: https://developer.android.com/guide/topics/renderscript/compute
+3 -5
View File
@@ -9,8 +9,8 @@ android {
minSdkVersion 9
targetSdkVersion sdk_version
versionCode 54
versionName '1.22.2'
versionCode 66
versionName '1.30.0'
// it's recommended to set this value to the lowest API level
// able to provide all the functionality
@@ -18,8 +18,6 @@ android {
renderscriptSupportModeEnabled true
testInstrumentationRunner 'android.support.test.runner.AndroidJUnitRunner'
buildConfigField 'boolean', 'IS_CATALINA', is_catalina.toString()
}
signingConfigs {
@@ -75,6 +73,6 @@ dependencies {
// Do NOT upgrade ZXing because newer versions won't work on API < 24
// https://github.com/zxing/zxing/issues/1170
implementation 'com.google.zxing:core:3.3.3'
implementation 'com.github.markusfisch:CameraView:1.8.4'
implementation 'com.github.markusfisch:CameraView:1.9.1'
implementation 'com.github.markusfisch:ScalingImageView:1.2.0'
}
+2
View File
@@ -1,4 +1,6 @@
-dontwarn com.google.**
-dontwarn android.support.v7.**
-dontwarn android.support.v8.**
-keep class android.support.v7.widget.SearchView { *; }
-keep class android.support.v8.renderscript.** { *; }
@@ -34,15 +34,21 @@ class PreprocessorTest {
val preprocessor = Preprocessor(
InstrumentationRegistry.getTargetContext(),
frameWidth.toInt(),
frameHeight.toInt(),
frameOrientation.toInt()
)
preprocessor.process(frameData)
val result = zxing.decode(
frameData,
preprocessor.outWidth,
preprocessor.outHeight
frameHeight.toInt()
)
val outWidth: Int
val outHeight: Int
val orientation = frameOrientation.toInt()
if (orientation == 90 || orientation == 270) {
preprocessor.resizeAndRotate(frameData)
outWidth = preprocessor.outHeight
outHeight = preprocessor.outWidth
} else {
preprocessor.resizeOnly(frameData)
outWidth = preprocessor.outWidth
outHeight = preprocessor.outHeight
}
val result = zxing.decode(frameData, outWidth, outHeight)
preprocessor.destroy()
checkNotNull(result) { "no barcode found in $file" }
+9 -1
View File
@@ -5,6 +5,7 @@
<supports-screens
android:largeScreens="true"
android:xlargeScreens="true"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
@@ -30,6 +31,9 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts"/>
</activity>
<activity
android:name=".activity.CameraActivity"
@@ -70,7 +74,8 @@
</activity>
<activity
android:name=".activity.MainActivity"
android:label="@string/app_name"/>
android:label="@string/app_name"
android:exported="true"/>
<provider
android:name="android.support.v4.content.FileProvider"
android:authorities="${applicationId}.provider"
@@ -80,5 +85,8 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"/>
</provider>
<meta-data
android:name="android.app.searchable"
android:resource="@xml/searchable"/>
</application>
</manifest>
@@ -13,7 +13,7 @@ import de.markusfisch.android.binaryeye.actions.wifi.WifiAction
object ActionRegistry {
val DEFAULT_ACTION: IAction = OpenOrSearchAction
val REGISTRY: Set<IAction> = setOf(
private val REGISTRY: Set<IAction> = setOf(
MailAction,
OtpauthAction,
SmsAction,
@@ -2,9 +2,9 @@ package de.markusfisch.android.binaryeye.actions
import android.content.Context
import android.content.Intent
import android.widget.Toast
import de.markusfisch.android.binaryeye.app.execShareIntent
import de.markusfisch.android.binaryeye.app.parseAndNormalizeUri
import de.markusfisch.android.binaryeye.widget.toast
interface IAction {
val iconResId: Int
@@ -18,12 +18,9 @@ abstract class IntentAction : IAction {
abstract val errorMsg: Int
final override suspend fun execute(context: Context, data: ByteArray) {
val intent =
createIntent(context, data) ?: return Toast.makeText(
context,
errorMsg,
Toast.LENGTH_LONG
).show()
val intent = createIntent(context, data) ?: return context.toast(
errorMsg
)
execShareIntent(context, intent)
}
@@ -2,13 +2,13 @@ package de.markusfisch.android.binaryeye.actions.search
import android.content.Context
import android.content.Intent
import android.widget.Toast
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.actions.IAction
import de.markusfisch.android.binaryeye.app.alertDialog
import de.markusfisch.android.binaryeye.app.execShareIntent
import de.markusfisch.android.binaryeye.app.parseAndNormalizeUri
import de.markusfisch.android.binaryeye.app.prefs
import de.markusfisch.android.binaryeye.widget.toast
import java.net.URLEncoder
object OpenOrSearchAction : IAction {
@@ -28,11 +28,7 @@ object OpenOrSearchAction : IAction {
when {
intent.resolveActivity(context.packageManager) != null -> return intent
search -> return getSearchIntent(context, data)
else -> Toast.makeText(
context,
R.string.cannot_resolve_action,
Toast.LENGTH_SHORT
).show()
else -> context.toast(R.string.cannot_resolve_action)
}
return null
}
@@ -6,17 +6,25 @@ object VTypeParser {
private const val BACKSLASH_R_LEGACY =
"(?:\u000D\u000A|[\u000A\u000B\u000C\u000D\u0085\u2028\u2029])"
private val vTypeRegex =
"""^BEGIN:(VCARD|VEVENT)(?:$BACKSLASH_R_LEGACY.+?:[\s\S]+?)+?${BACKSLASH_R_LEGACY}END:\1$BACKSLASH_R_LEGACY?$""".toRegex(
"""^BEGIN:(V.+)(?:$BACKSLASH_R_LEGACY.+?:[\s\S]+?)+?${BACKSLASH_R_LEGACY}END:\1$BACKSLASH_R_LEGACY?$""".toRegex(
RegexOption.IGNORE_CASE
)
private val propertyRegex = """^(.+?):([\s\S]*?)$""".toRegex(RegexOption.MULTILINE)
private val propertyRegex = """^(.+?):([\s\S]*?)$""".toRegex(
RegexOption.MULTILINE
)
fun parseVType(data: String): String? =
vTypeRegex.matchEntire(data)?.groupValues?.get(1)?.toUpperCase(Locale.US)
fun parseVType(data: String): String? = vTypeRegex.matchEntire(
data
)?.groupValues?.get(1)?.toUpperCase(Locale.US)
fun parseMap(data: String): Map<String, List<VTypeProperty>> = propertyRegex.findAll(data).map {
fun parseMap(
data: String
): Map<String, List<VTypeProperty>> = propertyRegex.findAll(data).map {
it.groupValues[1].split(';').let { typeAndInfo ->
typeAndInfo[0] to VTypeProperty(typeAndInfo.drop(1), it.groupValues[2])
typeAndInfo[0] to VTypeProperty(
typeAndInfo.drop(1),
it.groupValues[2]
)
}
}.groupBy({ it.first.toUpperCase(Locale.US) }) { it.second }
}
@@ -120,24 +120,26 @@ object VCardAction : IntentAction() {
}
private val String.locationFormat: String
get() = """^([^;]*?);([^;]*?);([^;]*?);([^;]*?);([^;]*?);([^;]*?);([^;]*?)$""".toRegex().matchEntire(
this
)?.groupValues?.let {
listOf(
it[1],
it[2],
it[3],
"${it[4]} ${it[6]}",
"${it[5]} ${it[7]}"
).filter { line -> line.isNotBlank() }
.joinToString("\n").takeIf { location -> location.isNotBlank() }
} ?: this
get() = """^([^;]*?);([^;]*?);([^;]*?);([^;]*?);([^;]*?);([^;]*?);([^;]*?)$""".toRegex()
.matchEntire(
this
)?.groupValues?.let {
listOf(
it[1],
it[2],
it[3],
"${it[4]} ${it[6]}",
"${it[5]} ${it[7]}"
).filter { line -> line.isNotBlank() }
.joinToString("\n").takeIf { location -> location.isNotBlank() }
} ?: this
private val String.nameFormat: String
get() = """^([^;]*?);([^;]*?);([^;]*?);([^;]*?);([^;]*?)$""".toRegex().matchEntire(this)?.groupValues?.let {
listOf(it[4], it[2], it[3], it[1], it[5]).joinToString(" ")
.takeIf { name -> name.isNotBlank() }
} ?: this
get() = """^([^;]*?);([^;]*?);([^;]*?);([^;]*?);([^;]*?)$""".toRegex()
.matchEntire(this)?.groupValues?.let {
listOf(it[4], it[2], it[3], it[1], it[5]).joinToString(" ")
.takeIf { name -> name.isNotBlank() }
} ?: this
private val String.mailType: Int?
get() = when (this.toUpperCase(Locale.US)) {
@@ -21,8 +21,11 @@ object VEventAction : IntentAction() {
get() = R.string.vevent_failed
override fun canExecuteOn(data: ByteArray): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH
&& VTypeParser.parseVType(String(data)) == "VEVENT"
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)
@@ -1,90 +1,33 @@
package de.markusfisch.android.binaryeye.actions.wifi
import android.content.Context
import android.content.Context.WIFI_SERVICE
import android.net.wifi.WifiConfiguration
import android.net.wifi.WifiManager
import android.widget.Toast
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.actions.IAction
import de.markusfisch.android.binaryeye.widget.toast
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
object WifiAction : IAction {
override val iconResId = R.drawable.ic_action_wifi
override val titleResId = R.string.connect_to_wifi
override fun canExecuteOn(data: ByteArray): Boolean =
WifiConfigurationFactory.parse(String(data)) != null
var password: String? = null
override suspend fun execute(context: Context, data: ByteArray) = withContext(Dispatchers.IO) {
val wifiConfig = WifiConfigurationFactory.parse(
override fun canExecuteOn(data: ByteArray): Boolean =
WifiConnector.parse(String(data)) {
password = it
} != null
override suspend fun execute(
context: Context,
data: ByteArray
) = withContext(Dispatchers.IO) {
val wifiConfig = WifiConnector.parse(
String(data)
) ?: return@withContext
val wifiManager = context.applicationContext.getSystemService(
WIFI_SERVICE
) as WifiManager
wifiManager.enableWifi(context)
wifiManager.mayRemoveOldNetwork(wifiConfig)
wifiManager.enableNewNetwork(wifiConfig)
val message = WifiConnector.addNetwork(context, wifiConfig)
withContext(Dispatchers.Main) {
Toast.makeText(
context,
R.string.wifi_added,
Toast.LENGTH_LONG
).show()
context.toast(message)
}
}
private suspend fun WifiManager.enableWifi(context: Context): Boolean {
if (!isWifiEnabled) {
if (!setWifiEnabled(true)) {
withContext(Dispatchers.Main) {
Toast.makeText(
context,
R.string.wifi_config_failed,
Toast.LENGTH_LONG
).show()
}
return false
}
// wait for wifi to really be enabled, takes some time
withTimeoutOrNull(10000) {
while (!isWifiEnabled) delay(500)
} ?: if (!isWifiEnabled) {
withContext(Dispatchers.Main) {
Toast.makeText(
context,
R.string.wifi_config_failed,
Toast.LENGTH_LONG
).show()
}
return false
}
}
return true
}
private suspend fun WifiManager.mayRemoveOldNetwork(
wifiConfig: WifiConfiguration
) {
configuredNetworks?.firstOrNull {
it.SSID == wifiConfig.SSID &&
it.allowedKeyManagement == wifiConfig.allowedKeyManagement
}?.networkId?.also {
removeNetwork(it)
}
}
private suspend fun WifiManager.enableNewNetwork(
wifiConfig: WifiConfiguration
) {
val id = addNetwork(wifiConfig)
disconnect()
enableNetwork(id, true)
reconnect()
}
}
@@ -1,8 +1,13 @@
package de.markusfisch.android.binaryeye.actions.wifi
import android.content.Context
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.*
/**
@@ -29,11 +34,53 @@ import java.util.*
*
* The fields can appear in any order. Only "S:" is required.
*/
object WifiConfigurationFactory {
fun parse(input: String): WifiConfiguration? {
object WifiConnector {
fun parse(
input: String,
passwordBlock: ((password: String?) -> Unit)? = null
): Any? {
val inputMap = parseMap(input) ?: return null
val parsedData = SimpleDataAccessor.of(inputMap) ?: return null
return WifiConfiguration().apply(parsedData)
passwordBlock?.apply {
invoke(parsedData.password)
}
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
// WifiConfiguration is deprecated in Android Q
@Suppress("DEPRECATION")
WifiConfiguration().apply(parsedData)
} else {
WifiNetworkSuggestion.Builder().apply(parsedData)
}
}
fun addNetwork(context: Context, config: Any): Int {
val wifiManager = context.applicationContext.getSystemService(
Context.WIFI_SERVICE
) as WifiManager
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
// WifiConfiguration is deprecated in Android Q
@Suppress("DEPRECATION")
val wifiConfig = config as WifiConfiguration
if (wifiManager.enableWifi() &&
wifiManager.removeOldNetwork(wifiConfig) &&
wifiManager.enableNewNetwork(wifiConfig)
) {
R.string.wifi_added
} else {
R.string.wifi_config_failed
}
} else {
val suggestion = (config as WifiNetworkSuggestion.Builder).build()
val suggestions = listOf(suggestion)
// remove previous conflicting network suggestion
wifiManager.removeNetworkSuggestions(suggestions)
val result = wifiManager.addNetworkSuggestions(suggestions)
if (result == WifiManager.STATUS_NETWORK_SUGGESTIONS_SUCCESS) {
R.string.wifi_added
} else {
R.string.wifi_config_failed
}
}
}
internal fun parseMap(string: String): Map<String, String>? {
@@ -41,9 +88,8 @@ object WifiConfigurationFactory {
// generators don't add it
val wifiRegex = """^WIFI:((?:.+?:(?:[^\\;]|\\.)*;)+);?$""".toRegex()
// should be: ^(.+):((?:[^\\;,":]|\\.)*);$ but allows unescaped , "
// and : because many qr creator doesn't escape
// and : because many QR Code creators don't escape properly
val pairRegex = """(.+?):((?:[^\\;]|\\.)*);""".toRegex()
return wifiRegex.matchEntire(
string
)?.groupValues?.get(1)?.let { pairs ->
@@ -56,26 +102,19 @@ object WifiConfigurationFactory {
internal class SimpleDataAccessor private constructor(
private val inputMap: Map<String, String>
) {
private val hexRegex = """^[0-9a-f]+$""".toRegex(
RegexOption.IGNORE_CASE
)
// keep possibility of wrongly not escaped \ by explicitly searching
// for special chars
private val escapedRegex = """\\([\\;,":])""".toRegex()
// this should be private but because of testing it isn't possible
internal val ssid: String
get() = inputMap.getValue("S").quotedUnlessHex.unescaped
get() = inputMap.getValue("S").unescape
internal val securityType: String
get() = inputMap["T"]?.unescaped ?: ""
get() = inputMap["T"]?.unescape ?: ""
internal val password: String?
get() = inputMap["P"]?.quotedUnlessHex?.unescaped
get() = inputMap["P"]?.unescape
internal val hidden: Boolean
get() = inputMap["H"]?.unescaped == "true"
get() = inputMap["H"]?.unescape == "true"
internal val anonymousIdentity: String
get() = inputMap["AI"]?.unescaped ?: ""
get() = inputMap["AI"]?.unescape ?: ""
internal val identity: String
get() = inputMap["I"]?.unescaped ?: ""
get() = inputMap["I"]?.unescape ?: ""
internal val eapMethod: Int?
get() = if (inputMap["E"].isNullOrEmpty()) {
requireSdk(Build.VERSION_CODES.JELLY_BEAN_MR2) {
@@ -110,16 +149,6 @@ object WifiConfigurationFactory {
else -> null
}
private val String.unescaped: String
get() = this.replace(escapedRegex) { escaped ->
escaped.groupValues[1]
}
private val String.quotedUnlessHex: String
get() = if (matches(hexRegex) || (startsWith("\"") &&
endsWith("\""))
) this else "\"$this\""
internal companion object {
internal fun of(inputMap: Map<String, String>): SimpleDataAccessor? {
return SimpleDataAccessor(inputMap).takeUnless {
@@ -129,9 +158,68 @@ object WifiConfigurationFactory {
}
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun WifiNetworkSuggestion.Builder.apply(
data: SimpleDataAccessor
): WifiNetworkSuggestion.Builder? {
fun WifiNetworkSuggestion.Builder.applyCommon(
data: SimpleDataAccessor
): WifiNetworkSuggestion.Builder? {
setSsid(data.ssid)
return this
}
fun WifiNetworkSuggestion.Builder.applySecurity(
data: SimpleDataAccessor
): WifiNetworkSuggestion.Builder? {
when (data.securityType) {
"WPA", "WPA2" -> {
data.password?.let {
setWpa2Passphrase(it)
}
}
"WPA2-EAP" -> {
data.password?.let {
setWpa2Passphrase(it)
}
setWpa2EnterpriseConfig(WifiEnterpriseConfig().apply {
identity = data.identity
anonymousIdentity = data.anonymousIdentity
password = data.password
data.eapMethod?.let { eapMethod = it }
data.phase2Method?.let { phase2Method = it }
})
}
"WPA3" -> {
data.password?.let {
setWpa3Passphrase(it)
}
}
"WPA3-EAP" -> {
data.password?.let { setWpa3Passphrase(it) }
setWpa3EnterpriseConfig(WifiEnterpriseConfig().apply {
identity = data.identity
anonymousIdentity = data.anonymousIdentity
password = data.password
data.eapMethod?.let { eapMethod = it }
data.phase2Method?.let { phase2Method = it }
})
}
}
return this
}
return this.applyCommon(data)?.applySecurity(data)
}
// WifiConfiguration is deprecated in Android Q
@Suppress("DEPRECATION")
private fun WifiConfiguration.apply(
data: SimpleDataAccessor
): WifiConfiguration? {
val ssidWithQuotes = data.ssid.quote
val passwordWithQuotes = data.password?.quoteUnlessHex
fun WifiConfiguration.applyCommon(
data: SimpleDataAccessor
): WifiConfiguration? {
@@ -141,7 +229,7 @@ object WifiConfigurationFactory {
allowedPairwiseCiphers.clear()
allowedProtocols.clear()
SSID = data.ssid
SSID = ssidWithQuotes
hiddenSSID = data.hidden
return this
}
@@ -152,7 +240,7 @@ object WifiConfigurationFactory {
when (data.securityType) {
"", "nopass" -> allowedKeyManagement.set(WifiConfiguration.KeyMgmt.NONE)
"WEP" -> @Suppress("DEPRECATION") /* WEP as insecure */ {
data.password?.also {
passwordWithQuotes?.let {
wepKeys[0] = it
} ?: return null
wepTxKeyIndex = 0
@@ -164,7 +252,7 @@ object WifiConfigurationFactory {
allowedGroupCiphers.set(WifiConfiguration.GroupCipher.WEP104)
}
"WPA" -> {
data.password?.also {
passwordWithQuotes?.let {
preSharedKey = it
} ?: return null
allowedAuthAlgorithms.set(WifiConfiguration.AuthAlgorithm.OPEN)
@@ -180,7 +268,7 @@ object WifiConfigurationFactory {
allowedGroupCiphers.set(WifiConfiguration.GroupCipher.CCMP)
}
"WPA2-EAP" -> requireSdk(Build.VERSION_CODES.JELLY_BEAN_MR2) {
data.password?.also {
passwordWithQuotes?.let {
preSharedKey = it
} ?: return null
allowedAuthAlgorithms.set(WifiConfiguration.AuthAlgorithm.OPEN)
@@ -203,6 +291,7 @@ object WifiConfigurationFactory {
}
return this
}
return this.applyCommon(data)?.applySecurity(data)
}
@@ -210,3 +299,72 @@ object WifiConfigurationFactory {
return if (Build.VERSION.SDK_INT >= version) return block() else null
}
}
// keep possibility of wrongly unescaped \ by explicitly searching
// for special chars
private val escapedRegex = """\\([\\;,":])""".toRegex()
private val String.unescape: String
get() = this.replace(escapedRegex) { escaped ->
escaped.groupValues[1]
}
private val String.quote: String
get() = if (startsWith("\"") && endsWith("\"")) this else "\"$this\""
private val String.quoteUnlessHex: String
get() = if (isHex()) this else this.quote
// Unfortunately, we can never know whether a string consisting only
// of 0-f is a hex string. So we just make this one assumption that
// a string of 0-f that is exactly 64 characters long, is _probably_
// a hex string for raw PSK. Of course, this may fail for any string
// that just happens to be 64 characters long and contains just 0-f.
private val hexRegex = """^[0-9a-f]+$""".toRegex(
RegexOption.IGNORE_CASE
)
private fun String.isHex() = length == 64 && matches(hexRegex)
private fun WifiManager.enableWifi(): Boolean {
// setWifiEnabled() will always return false for Android Q
// because Q doesn't allow apps to enable/disable Wi-Fi anymore
@Suppress("DEPRECATION")
return isWifiEnabled || setWifiEnabled(true)
}
// WifiConfiguration is deprecated in Android Q
@Suppress("DEPRECATION")
private fun WifiManager.removeOldNetwork(
wifiConfig: WifiConfiguration
): Boolean {
try {
configuredNetworks?.firstOrNull {
it.SSID == wifiConfig.SSID &&
it.allowedKeyManagement == wifiConfig.allowedKeyManagement
}?.networkId?.also {
removeNetwork(it)
}
} catch (e: SecurityException) {
// the user didn't allow ACCESS_FINE_LOCATION which is
// required to access configuredNetworks and that's fine
}
return true
}
// WifiConfiguration is deprecated in Android Q
@Suppress("DEPRECATION")
private fun WifiManager.enableNewNetwork(
wifiConfig: WifiConfiguration
): Boolean {
val id = addNetwork(wifiConfig)
if (id == -1) {
return false
}
disconnect()
return if (enableNetwork(id, true)) {
reconnect()
true
} else {
false
}
}
@@ -4,35 +4,50 @@ import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Rect
import android.graphics.RectF
import android.hardware.Camera
import android.net.Uri
import android.os.Bundle
import android.os.Vibrator
import android.support.v7.app.AppCompatActivity
import android.support.v7.widget.Toolbar
import android.support.v8.renderscript.RSRuntimeException
import android.view.Menu
import android.view.MenuItem
import android.view.MotionEvent
import android.view.View
import android.widget.SeekBar
import android.widget.Toast
import com.google.zxing.Result
import com.google.zxing.ResultMetadataType
import com.google.zxing.ResultPointCallback
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.app.db
import de.markusfisch.android.binaryeye.app.hasCameraPermission
import de.markusfisch.android.binaryeye.app.initSystemBars
import de.markusfisch.android.binaryeye.app.prefs
import de.markusfisch.android.binaryeye.app.*
import de.markusfisch.android.binaryeye.data.Scan
import de.markusfisch.android.binaryeye.graphics.Mapping
import de.markusfisch.android.binaryeye.graphics.frameToView
import de.markusfisch.android.binaryeye.graphics.isPortrait
import de.markusfisch.android.binaryeye.rs.Preprocessor
import de.markusfisch.android.binaryeye.widget.DetectorView
import de.markusfisch.android.binaryeye.widget.toast
import de.markusfisch.android.binaryeye.zxing.Zxing
import de.markusfisch.android.cameraview.widget.CameraView
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
class CameraActivity : AppCompatActivity() {
private val zxing = Zxing()
private val zxing = Zxing(ResultPointCallback { point ->
point?.let {
getMapping()?.map(it)?.let {
detectorView.post {
detectorView.mark(listOf(it))
}
}
}
})
private lateinit var vibrator: Vibrator
private lateinit var cameraView: CameraView
@@ -41,6 +56,10 @@ class CameraActivity : AppCompatActivity() {
private lateinit var flashFab: View
private var preprocessor: Preprocessor? = null
private var nativeMapping: Mapping? = null
private var rotatedMapping: Mapping? = null
private var recreatePreprocessor = false
private var rotate = false
private var invert = false
private var flash = false
private var returnResult = false
@@ -56,11 +75,7 @@ class CameraActivity : AppCompatActivity() {
REQUEST_CAMERA -> if (grantResults.isNotEmpty() &&
grantResults[0] != PackageManager.PERMISSION_GRANTED
) {
Toast.makeText(
this,
R.string.no_camera_no_fun,
Toast.LENGTH_SHORT
).show()
toast(R.string.no_camera_no_fun)
finish()
}
}
@@ -101,6 +116,7 @@ class CameraActivity : AppCompatActivity() {
initCameraView()
initZoomBar()
restoreZoom()
detectorView.updateRoi = { recreatePreprocessor = true }
if (intent?.action == Intent.ACTION_SEND &&
intent.type == "text/plain"
@@ -111,7 +127,7 @@ class CameraActivity : AppCompatActivity() {
override fun onDestroy() {
super.onDestroy()
preprocessor?.destroy()
resetPreProcessor()
fallbackBuffer = null
saveZoom()
}
@@ -119,15 +135,22 @@ class CameraActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
System.gc()
zxing.updateHints(prefs.tryHarder)
returnResult = "com.google.zxing.client.android.SCAN" == intent.action
if (hasCameraPermission(this, REQUEST_CAMERA)) {
openCamera()
}
}
private fun resetPreProcessor() {
preprocessor?.destroy()
preprocessor = null
}
private fun openCamera() {
cameraView.openAsync(
CameraView.findCameraId(
@Suppress("DEPRECATION")
if (frontFacing) {
Camera.CameraInfo.CAMERA_FACING_FRONT
} else {
@@ -144,17 +167,21 @@ class CameraActivity : AppCompatActivity() {
private fun closeCamera() {
cameraView.close()
// closing the camera will also shut off the flash
flash = false
}
override fun onRestoreInstanceState(savedState: Bundle) {
super.onRestoreInstanceState(savedState)
zoomBar.max = savedState.getInt(ZOOM_MAX)
zoomBar.progress = savedState.getInt(ZOOM_LEVEL)
frontFacing = savedState.getBoolean(FRONT_FACING)
}
override fun onSaveInstanceState(outState: Bundle) {
outState.putInt(ZOOM_MAX, zoomBar.max)
outState.putInt(ZOOM_LEVEL, zoomBar.progress)
outState.putBoolean(FRONT_FACING, frontFacing)
super.onSaveInstanceState(outState)
}
@@ -208,13 +235,15 @@ class CameraActivity : AppCompatActivity() {
}
private fun openReadme() {
startActivity(
Intent(
Intent.ACTION_VIEW, Uri.parse(
"https://github.com/markusfisch/BinaryEye/blob/master/README.md"
)
)
val intent = Intent(
Intent.ACTION_VIEW,
Uri.parse(getString(R.string.project_url))
)
if (intent.resolveActivity(packageManager) != null) {
startActivity(intent)
} else {
toast(R.string.project_url)
}
}
private fun handleSendText(intent: Intent) {
@@ -227,7 +256,50 @@ class CameraActivity : AppCompatActivity() {
private fun initCameraView() {
cameraView.setUseOrientationListener(true)
cameraView.setTapToFocus()
@Suppress("ClickableViewAccessibility")
cameraView.setOnTouchListener(object : View.OnTouchListener {
var focus = true
var offset = -1f
var progress = 0
override fun onTouch(v: View?, event: MotionEvent?): Boolean {
event ?: return false
val pos = event.y
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
offset = pos
progress = zoomBar.progress
return true
}
MotionEvent.ACTION_MOVE -> {
if (prefs.zoomBySwiping) {
v ?: return false
val dist = offset - pos
val maxValue = zoomBar.max
val change = maxValue / v.height.toFloat() * 2f * dist
zoomBar.progress = min(
maxValue,
max(progress + change.roundToInt(), 0)
)
return true
}
}
MotionEvent.ACTION_UP -> {
// stop calling focusTo() as soon as it returns false
// to avoid throwing and catching future exceptions
if (focus) {
focus = cameraView.focusTo(v, event.x, event.y)
if (focus) {
v?.performClick()
return true
}
}
}
}
return false
}
})
@Suppress("DEPRECATION")
cameraView.setOnCameraListener(object : CameraView.OnCameraListener {
override fun onConfigureParameters(
parameters: Camera.Parameters
@@ -257,49 +329,28 @@ class CameraActivity : AppCompatActivity() {
}
override fun onCameraError() {
Toast.makeText(
this@CameraActivity,
R.string.camera_error,
Toast.LENGTH_SHORT
).show()
this@CameraActivity.toast(R.string.camera_error)
finish()
}
override fun onCameraReady(camera: Camera) {
// reset preprocessor to make sure it always fits the current
// frame orientation; important for landscape to landscape
// orientation changes
resetPreProcessor()
val frameWidth = cameraView.frameWidth
val frameHeight = cameraView.frameHeight
val frameOrientation = cameraView.frameOrientation
var mapping: Mapping? = null
var decoding = true
camera.setPreviewCallback { frameData, _ ->
if (decoding) {
val result = decodeFrame(
decodeFrame(
frameData,
frameWidth,
frameHeight,
frameOrientation
)
result?.let {
preprocessor?.let {
mapping = mapping ?: frameToView(
it.outWidth,
it.outHeight,
cameraView.previewRect
)
}
cameraView.post {
mapping?.let {
detectorView.mark(
it.map(result.resultPoints)
)
}
vibrator.vibrate(100)
showResult(
this@CameraActivity,
result,
returnResult
)
}
)?.let { result ->
postResult(result)
decoding = false
}
}
@@ -332,6 +383,7 @@ class CameraActivity : AppCompatActivity() {
})
}
@Suppress("DEPRECATION")
private fun setZoom(zoom: Int) {
val camera: Camera? = cameraView.camera
camera?.let {
@@ -368,6 +420,7 @@ class CameraActivity : AppCompatActivity() {
}
}
@Suppress("DEPRECATION")
private fun toggleTorchMode() {
val camera = cameraView.camera
val parameters = camera?.parameters
@@ -393,28 +446,156 @@ class CameraActivity : AppCompatActivity() {
frameOrientation: Int
): Result? {
frameData ?: return null
invert = invert xor true
val pp = preprocessor ?: Preprocessor(
this,
rotate = if (prefs.autoRotate) {
rotate xor true
} else {
invert = invert xor true
isPortrait(frameOrientation)
}
return try {
if (recreatePreprocessor) {
resetPreProcessor()
recreatePreprocessor = false
}
val pp = preprocessor ?: createPreprocessorAndMapping(
frameWidth,
frameHeight,
frameOrientation
)
val w: Int
val h: Int
if (rotate) {
pp.resizeAndRotate(frameData)
w = pp.outHeight
h = pp.outWidth
} else {
invert = invert xor true
pp.resizeOnly(frameData)
w = pp.outWidth
h = pp.outHeight
}
preprocessor = pp
zxing.decode(frameData, w, h, invert)
} catch (e: RSRuntimeException) {
prefs.forceCompat = prefs.forceCompat xor true
// now the only option is to restart the app because
// RenderScript.forceCompat() needs to be called before
// RenderScript is initialized
restartApp(this)
null
}
}
private fun createPreprocessorAndMapping(
frameWidth: Int,
frameHeight: Int,
frameOrientation: Int
): Preprocessor {
val frameRoi = calculateFrameRect(
frameWidth,
frameHeight,
frameOrientation
)
pp.process(frameData)
preprocessor = pp
return zxing.decode(
frameData,
val pp = Preprocessor(
this,
frameWidth,
frameHeight,
frameRoi
)
val viewRect = if (frameRoi != null) {
detectorView.roi
} else {
cameraView.previewRect
}
nativeMapping = frameToView(
pp.outWidth,
pp.outHeight,
invert
frameOrientation,
viewRect
)
rotatedMapping = frameToView(
pp.outHeight,
pp.outWidth,
(frameOrientation - 90 + 360) % 360,
viewRect
)
return pp
}
private fun calculateFrameRect(
frameWidth: Int,
frameHeight: Int,
frameOrientation: Int
): Rect? {
val viewRoi = detectorView.roi
return if (
viewRoi.width() == 0 ||
viewRoi.height() == 0
) {
null
} else {
// map ROI in detectorView to cameraView because cameraView may
// be larger than the detectorView
val previewRect = cameraView.previewRect
val previewRoi = Rect(
previewRect.left + viewRoi.left,
previewRect.top + viewRoi.top,
previewRect.left + viewRoi.right,
previewRect.top + viewRoi.bottom
)
val previewRectWidth = previewRect.width().toFloat()
val previewRectHeight = previewRect.height().toFloat()
val normalizedRoi = RectF(
previewRoi.left.toFloat() / previewRectWidth,
previewRoi.top.toFloat() / previewRectHeight,
previewRoi.right.toFloat() / previewRectWidth,
previewRoi.bottom.toFloat() / previewRectHeight
)
// since the ROI is always centered and symmetric, we don't
// need to distinguish between 0 and 180 or 90 and 270 degree
if (isPortrait(frameOrientation)) {
Rect(
(normalizedRoi.top * frameWidth.toFloat()).roundToInt(),
(normalizedRoi.left * frameHeight.toFloat()).roundToInt(),
(normalizedRoi.bottom * frameWidth.toFloat()).roundToInt(),
(normalizedRoi.right * frameHeight.toFloat()).roundToInt()
)
} else {
Rect(
(normalizedRoi.left * frameWidth.toFloat()).roundToInt(),
(normalizedRoi.top * frameHeight.toFloat()).roundToInt(),
(normalizedRoi.right * frameWidth.toFloat()).roundToInt(),
(normalizedRoi.bottom * frameHeight.toFloat()).roundToInt()
)
}
}
}
private fun postResult(result: Result) {
// get mapping for the current rotate value
val mapping = getMapping()
cameraView.post {
val rp = result.resultPoints
if (mapping != null && rp != null && rp.isNotEmpty()) {
detectorView.mark(mapping.map(rp))
}
vibrator.vibrate()
showResult(
this@CameraActivity,
result,
returnResult
)
}
}
private fun getMapping() = if (rotate) rotatedMapping else nativeMapping
companion object {
private const val REQUEST_CAMERA = 1
private const val PICK_FILE_RESULT_CODE = 1
private const val ZOOM_MAX = "zoom_max"
private const val ZOOM_LEVEL = "zoom_level"
private const val FRONT_FACING = "front_facing"
}
}
@@ -423,45 +604,22 @@ fun showResult(
result: Result,
isResult: Boolean = false
) {
val rawBytes = getRawBytes(result)
if (prefs.useHistory) {
GlobalScope.launch {
db.insertScan(
System.currentTimeMillis(),
result.text,
rawBytes,
result.barcodeFormat.toString()
)
}
}
val scan = Scan(result)
if (isResult) {
activity.setResult(Activity.RESULT_OK, getReturnIntent(result))
activity.finish()
} else {
if (prefs.useHistory) {
GlobalScope.launch {
db.insertScan(scan)
}
}
activity.startActivity(
MainActivity.getDecodeIntent(
activity,
result.text,
result.barcodeFormat,
rawBytes
)
MainActivity.getDecodeIntent(activity, scan)
)
}
}
fun getRawBytes(result: Result): ByteArray? {
val metadata = result.resultMetadata ?: return null
val segments = metadata[ResultMetadataType.BYTE_SEGMENTS] ?: return null
var bytes = ByteArray(0)
@Suppress("UNCHECKED_CAST")
for (seg in segments as Iterable<ByteArray>) {
bytes += seg
}
// byte segments can never be shorter than the text.
// Zxing cuts off content prefixes like "WIFI:"
return if (bytes.size >= result.text.length) bytes else null
}
fun getReturnIntent(result: Result): Intent {
val intent = Intent()
intent.putExtra("SCAN_RESULT", result.text)
@@ -472,34 +630,29 @@ fun getReturnIntent(result: Result): Intent {
if (result.rawBytes?.isNotEmpty() == true) {
intent.putExtra("SCAN_RESULT_BYTES", result.rawBytes)
}
val metadata = result.resultMetadata
metadata?.let {
val orientation = metadata[ResultMetadataType.UPC_EAN_EXTENSION]
orientation?.let {
result.resultMetadata?.let { metadata ->
metadata[ResultMetadataType.UPC_EAN_EXTENSION]?.let {
intent.putExtra(
"SCAN_RESULT_ORIENTATION",
orientation.toString()
it.toString()
)
}
val ecLevel = metadata[ResultMetadataType.ERROR_CORRECTION_LEVEL]
ecLevel?.let {
metadata[ResultMetadataType.ERROR_CORRECTION_LEVEL]?.let {
intent.putExtra(
"SCAN_RESULT_ERROR_CORRECTION_LEVEL",
ecLevel.toString()
it.toString()
)
}
val upcEanExtension = metadata[ResultMetadataType.UPC_EAN_EXTENSION]
upcEanExtension?.let {
metadata[ResultMetadataType.UPC_EAN_EXTENSION]?.let {
intent.putExtra(
"SCAN_RESULT_UPC_EAN_EXTENSION",
upcEanExtension.toString()
it.toString()
)
}
val segments = metadata[ResultMetadataType.BYTE_SEGMENTS]
segments?.let {
metadata[ResultMetadataType.BYTE_SEGMENTS]?.let {
var i = 0
@Suppress("UNCHECKED_CAST")
for (seg in segments as Iterable<ByteArray>) {
for (seg in it as Iterable<ByteArray>) {
intent.putExtra("SCAN_RESULT_BYTE_SEGMENTS_$i", seg)
++i
}
@@ -4,18 +4,19 @@ import android.content.Context
import android.content.Intent
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 com.google.zxing.BarcodeFormat
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.app.colorSystemAndToolBars
import de.markusfisch.android.binaryeye.app.initSystemBars
import de.markusfisch.android.binaryeye.app.setFragment
import de.markusfisch.android.binaryeye.app.setupInsets
import de.markusfisch.android.binaryeye.data.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.recordToolbarHeight
class MainActivity : AppCompatActivity() {
override fun onSupportNavigateUp(): Boolean {
@@ -34,7 +35,7 @@ class MainActivity : AppCompatActivity() {
initSystemBars(this)
val toolbar = findViewById(R.id.toolbar) as Toolbar
setupInsets(findViewById(android.R.id.content), toolbar)
recordToolbarHeight(toolbar)
setSupportActionBar(toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
@@ -43,27 +44,7 @@ class MainActivity : AppCompatActivity() {
}
if (state == null) {
setFragment(
supportFragmentManager, when {
intent?.hasExtra(PREFERENCES) == true ->
PreferencesFragment()
intent?.hasExtra(HISTORY) == true ->
HistoryFragment()
intent?.hasExtra(ENCODE) == true ->
EncodeFragment.newInstance(
intent.getStringExtra(ENCODE) ?: ""
)
intent?.hasExtra(DECODED_TEXT) == true ->
DecodeFragment.newInstance(
intent.getStringExtra(DECODED_TEXT) ?: "",
intent.getSerializableExtra(
DECODED_FORMAT
) as BarcodeFormat,
intent.getByteArrayExtra(DECODED_RAW)
)
else -> DecodeFragment()
}
)
setFragment(supportFragmentManager, getFragmentForIntent(intent))
}
}
@@ -71,9 +52,22 @@ class MainActivity : AppCompatActivity() {
private const val PREFERENCES = "preferences"
private const val HISTORY = "history"
private const val ENCODE = "encode"
private const val DECODED_TEXT = "decoded_text"
private const val DECODED_FORMAT = "decoded_format"
private const val DECODED_RAW = "decoded_raw"
private 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) -> DecodeFragment.newInstance(
intent.getParcelableExtra(DECODED)!!
)
else -> PreferencesFragment()
}
}
fun getPreferencesIntent(context: Context): Intent {
val intent = Intent(context, MainActivity::class.java)
@@ -108,18 +102,9 @@ class MainActivity : AppCompatActivity() {
return intent
}
fun getDecodeIntent(
context: Context,
text: String,
format: BarcodeFormat,
raw: ByteArray? = null
): Intent {
fun getDecodeIntent(context: Context, scan: Scan): Intent {
val intent = Intent(context, MainActivity::class.java)
intent.putExtra(DECODED_TEXT, text)
intent.putExtra(DECODED_FORMAT, format)
if (raw != null) {
intent.putExtra(DECODED_RAW, raw)
}
intent.putExtra(DECODED, scan)
return intent
}
}
@@ -3,7 +3,6 @@ package de.markusfisch.android.binaryeye.activity
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Point
import android.graphics.Rect
import android.net.Uri
import android.os.Bundle
@@ -13,17 +12,18 @@ import android.support.v7.app.AppCompatActivity
import android.support.v7.widget.Toolbar
import android.view.Menu
import android.view.MenuItem
import android.widget.Toast
import com.google.zxing.Result
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.app.colorSystemAndToolBars
import de.markusfisch.android.binaryeye.app.initSystemBars
import de.markusfisch.android.binaryeye.app.setWindowInsetListener
import de.markusfisch.android.binaryeye.app.setupInsets
import de.markusfisch.android.binaryeye.app.vibrate
import de.markusfisch.android.binaryeye.graphics.crop
import de.markusfisch.android.binaryeye.graphics.loadImageUri
import de.markusfisch.android.binaryeye.graphics.mapResult
import de.markusfisch.android.binaryeye.view.doOnApplyWindowInsets
import de.markusfisch.android.binaryeye.view.recordToolbarHeight
import de.markusfisch.android.binaryeye.widget.CropImageView
import de.markusfisch.android.binaryeye.widget.toast
import de.markusfisch.android.binaryeye.zxing.Zxing
class PickActivity : AppCompatActivity() {
@@ -36,11 +36,12 @@ class PickActivity : AppCompatActivity() {
super.onCreate(state)
setContentView(R.layout.activity_pick)
zxing.updateHints(true)
vibrator = getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
initSystemBars(this)
val toolbar = findViewById(R.id.toolbar) as Toolbar
setupInsets(findViewById(android.R.id.content), toolbar)
recordToolbarHeight(toolbar)
setSupportActionBar(toolbar)
supportFragmentManager.addOnBackStackChangedListener {
@@ -62,46 +63,39 @@ class PickActivity : AppCompatActivity() {
}
if (bitmap == null) {
Toast.makeText(
applicationContext,
R.string.error_no_content,
Toast.LENGTH_SHORT
).show()
applicationContext.toast(R.string.error_no_content)
finish()
return
}
val scannedRect = Rect()
val scanWithinBounds: () -> Result? = {
var result: Result? = null
crop(
bitmap,
cropImageView.normalizedRectInBounds,
cropImageView.imageRotation
)?.also {
scannedRect.set(0, 0, it.width, it.height)
result = zxing.decodePositiveNegative(it)
}
result
fun scanWithinBounds() = crop(
bitmap,
cropImageView.normalizedRectInBounds,
cropImageView.imageRotation
)?.let {
scannedRect.set(0, 0, it.width, it.height)
zxing.decodePositiveNegative(it)
}
cropImageView = findViewById(R.id.image) as CropImageView
cropImageView.setImageBitmap(bitmap)
cropImageView.onScan = {
var points: List<Point>? = null
scanWithinBounds()?.let {
points = mapResult(
if (!isFinishing) {
vibrator.vibrate()
}
mapResult(
scannedRect.width(),
scannedRect.height(),
0,
cropImageView.getBoundsRect(),
it
)
vibrator.vibrate(100)
}
points
}
setWindowInsetListener { insets ->
cropImageView.windowInsets.set(insets)
cropImageView.doOnApplyWindowInsets { v, insets ->
(v as CropImageView).windowInsets.set(insets)
}
findViewById(R.id.scan).setOnClickListener {
@@ -146,10 +140,6 @@ class PickActivity : AppCompatActivity() {
finish()
return
}
Toast.makeText(
applicationContext,
R.string.no_barcode_found,
Toast.LENGTH_SHORT
).show()
applicationContext.toast(R.string.no_barcode_found)
}
}
@@ -3,6 +3,7 @@ package de.markusfisch.android.binaryeye.activity
import android.content.Intent
import android.os.Bundle
import android.support.v7.app.AppCompatActivity
import de.markusfisch.android.binaryeye.app.setRestartCount
class SplashActivity : AppCompatActivity() {
override fun onCreate(state: Bundle?) {
@@ -12,6 +13,7 @@ class SplashActivity : AppCompatActivity() {
// because that would happen after the app is fully
// initialized what is too late
setRestartCount(intent)
startActivity(Intent(applicationContext, CameraActivity::class.java))
finish()
}
@@ -12,15 +12,41 @@ import de.markusfisch.android.binaryeye.data.Database
class ScansAdapter(context: Context, cursor: Cursor) :
CursorAdapter(context, cursor, false) {
var selectedScanId = 0L
var selectedScanPosition = -1
private val idIndex = cursor.getColumnIndex(Database.SCANS_ID)
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 formatIndex = cursor.getColumnIndex(Database.SCANS_FORMAT)
fun select(id: Long, position: Int) {
selectedScanId = id
selectedScanPosition = position
}
fun clearSelection() {
selectedScanId = 0L
selectedScanPosition = -1
}
fun getSelectedContent() = if (selectedScanPosition < 0) {
null
} else {
val cursor = getItem(selectedScanPosition)
if (cursor is Cursor) {
cursor.getString(contentIndex)
} else {
null
}
}
override fun newView(
context: Context,
cursor: Cursor,
parent: ViewGroup
) = LayoutInflater.from(parent.context).inflate(
): View? = LayoutInflater.from(parent.context).inflate(
R.layout.row_scan, parent, false
)
@@ -30,14 +56,27 @@ class ScansAdapter(context: Context, cursor: Cursor) :
cursor: Cursor
) {
val holder = getViewHolder(view)
val content = cursor.getString(contentIndex)
holder.timeView.text = cursor.getString(timeIndex)
holder.contentView.text = if (content.isEmpty()) {
context.getString(R.string.binary_data)
} else {
content
val name = cursor.getString(nameIndex)
val content = cursor.getString(contentIndex)
var icon = 0
holder.contentView.text = when {
name?.isNotEmpty() == true -> {
icon = R.drawable.ic_label
name
}
content?.isEmpty() == true -> context.getString(R.string.binary_data)
else -> content
}
holder.contentView.setCompoundDrawablesWithIntrinsicBounds(
icon, 0, 0, 0
)
holder.formatView.text = cursor.getString(formatIndex)
// view.isSelected needs to be put on the queue to work
val selected = cursor.getLong(idIndex) == selectedScanId
view.post {
view.isSelected = selected
}
}
private fun getViewHolder(view: View): ViewHolder {
@@ -0,0 +1,23 @@
package de.markusfisch.android.binaryeye.app
import android.content.Context
import android.support.v7.app.AlertDialog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
suspend inline fun <T : Any> alertDialog(
context: Context,
crossinline build: AlertDialog.Builder.(resume: (T?) -> Unit) -> Unit
): T? = withContext(Dispatchers.Main) {
suspendCoroutine<T?> { continuation ->
AlertDialog.Builder(context).apply {
setOnCancelListener {
continuation.resume(null)
}
build(continuation::resume)
show()
}
}
}
@@ -2,32 +2,21 @@ package de.markusfisch.android.binaryeye.app
import android.app.Application
import android.support.v8.renderscript.RenderScript
import de.markusfisch.android.binaryeye.BuildConfig
import de.markusfisch.android.binaryeye.data.Database
import de.markusfisch.android.binaryeye.preference.Preferences
import de.markusfisch.android.binaryeye.repository.DatabaseRepository
val db = DatabaseRepository()
val db = Database()
val prefs = Preferences()
class BinaryEyeApp : Application() {
override fun onCreate() {
super.onCreate()
prefs.init(this)
// Since RenderScript can no longer be compiled on macOS Catalina
// because Catalina cannot run 32bit binaries and Google failed
// to provide a 64bit compiler in time. Now, as if that wasn't
// bad enough, `RenderScript.forceCompat()` makes any build compiled
// with the new build tools that provide a 64bit compiler crash
// so we need to exclude this if we're building on Catalina.
// Maybe `RenderScript.forceCompat()` isn't required anymore for
// those new builds but I don't have a device to test this.
if (!BuildConfig.IS_CATALINA) {
// required to make RenderScript work for Lineage 16.0
// possibly because of a system/device bug
if (prefs.forceCompat) {
RenderScript.forceCompat()
}
db.open(this)
prefs.init(this)
}
}
@@ -0,0 +1,17 @@
package de.markusfisch.android.binaryeye.app
import android.content.ClipData
import android.content.Context
import android.os.Build
fun Context.copyToClipboard(text: String) {
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("simple text", text)
)
}
}
@@ -1,16 +1,9 @@
package de.markusfisch.android.binaryeye.app
import android.annotation.SuppressLint
import android.content.Context
import android.support.v4.app.Fragment
import android.support.v4.app.FragmentManager
import android.support.v7.app.AlertDialog
import android.view.View
import de.markusfisch.android.binaryeye.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
fun setFragment(fm: FragmentManager?, fragment: Fragment) {
fm?.let { getTransaction(fm, fragment).commit() }
@@ -25,38 +18,3 @@ private fun getTransaction(
fm: FragmentManager,
fragment: Fragment
) = fm.beginTransaction().replace(R.id.content_frame, fragment)
suspend inline fun View.useVisibility(
whileExecuting: Int = View.VISIBLE,
otherwise: Int = View.GONE,
crossinline block: suspend () -> Unit
) {
if (visibility == whileExecuting) {
return
}
withContext(Dispatchers.Main) {
visibility = whileExecuting
}
try {
block()
} finally {
withContext(Dispatchers.Main) {
visibility = otherwise
}
}
}
suspend inline fun <T : Any> alertDialog(
context: Context,
crossinline build: AlertDialog.Builder.(resume: (T?) -> Unit) -> Unit
): T? = withContext(Dispatchers.Main) {
suspendCoroutine<T?> { continuation ->
AlertDialog.Builder(context).apply {
setOnCancelListener {
continuation.resume(null)
}
build(continuation::resume)
show()
}
}
}
@@ -10,3 +10,5 @@ fun hasNonPrintableCharacters(s: String) = nonPrintable.containsMatchIn(s)
fun parseAndNormalizeUri(input: String): Uri = Uri.parse(input).let {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) it.normalizeScheme() else it
}
fun ByteArray.toHexString() = joinToString("") { "%02X".format(it) }
@@ -1,15 +1,18 @@
package de.markusfisch.android.binaryeye.app
import android.app.Activity
import android.content.ContentValues
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 de.markusfisch.android.binaryeye.R
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.OutputStream
fun addSuffixIfNotGiven(name: String, suffix: String): String {
val trimmed = name.trim()
@@ -36,22 +39,48 @@ suspend fun Activity.askForFileName(suffix: String = ""): String? {
}
}
suspend fun Flow<ByteArray>.writeToFile(name: String): Int {
return try {
val file = File(
Environment.getExternalStoragePublicDirectory(
Environment.DIRECTORY_DOWNLOADS
),
name
)
if (file.exists()) {
return R.string.error_file_exists
}
collect { bytes ->
file.appendBytes(bytes)
}
R.string.saved_in_downloads
} catch (e: IOException) {
R.string.error_saving_binary_data
}
fun Boolean.toSaveResult() = if (this) {
R.string.saved_in_downloads
} else {
R.string.error_saving_file
}
fun writeExternalFile(
context: Context,
fileName: String,
mimeType: String,
write: (outputStream: OutputStream) -> Unit
): Boolean = try {
openExternalOutputStream(context, fileName, mimeType).use { write(it) }
true
} catch (e: IOException) {
false
}
private fun openExternalOutputStream(
context: Context,
fileName: String,
mimeType: String
): OutputStream = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
@Suppress("DEPRECATION")
val file = File(
Environment.getExternalStoragePublicDirectory(
Environment.DIRECTORY_DOWNLOADS
),
fileName
)
if (file.exists()) {
throw IOException()
}
FileOutputStream(file)
} else {
val resolver = context.contentResolver
val uri = resolver.insert(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, fileName)
put(MediaStore.Downloads.MIME_TYPE, mimeType)
}
) ?: throw IOException()
resolver.openOutputStream(uri) ?: throw IOException()
}
@@ -1,23 +0,0 @@
package de.markusfisch.android.binaryeye.app
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.scanReduce
fun Flow<ByteArray>.addDelimiter(delimiter: String) = addDelimiter(
delimiter.toByteArray(),
ByteArray::plus
)
inline fun <T, R> Flow<T>.addDelimiter(
delimiter: R,
crossinline append: R.(T) -> T
): Flow<T> = scanReduce { _, content ->
delimiter.append(content)
}
infix fun <T> T.asFlowWith(other: Flow<T>) = flow<T> {
emit(this@asFlowWith)
emitAll(other)
}
@@ -0,0 +1,13 @@
package de.markusfisch.android.binaryeye.app
import android.content.Context
import android.view.View
import android.view.inputmethod.InputMethodManager
fun Context.hideSoftKeyboard(view: View) {
(getSystemService(
Context.INPUT_METHOD_SERVICE
) as InputMethodManager).hideSoftInputFromWindow(
view.windowToken, 0
)
}
@@ -6,11 +6,11 @@ import android.content.pm.PackageManager
import android.support.v4.app.ActivityCompat
import android.support.v4.content.ContextCompat
fun hasCameraPermission(activity: Activity, requestCode: Int): Boolean {
fun hasCameraPermission(activity: Activity, requestCode: Int = 1): Boolean {
return hasPermission(activity, Manifest.permission.CAMERA, requestCode)
}
fun hasWritePermission(activity: Activity, requestCode: Int = 1): Boolean {
fun hasWritePermission(activity: Activity, requestCode: Int = 2): Boolean {
return hasPermission(
activity,
Manifest.permission.WRITE_EXTERNAL_STORAGE,
@@ -18,6 +18,14 @@ fun hasWritePermission(activity: Activity, requestCode: Int = 1): Boolean {
)
}
fun hasLocationPermission(activity: Activity, requestCode: Int = 3): Boolean {
return hasPermission(
activity,
Manifest.permission.ACCESS_FINE_LOCATION,
requestCode
)
}
fun hasPermission(
activity: Activity,
permission: String,
@@ -0,0 +1,26 @@
package de.markusfisch.android.binaryeye.app
import android.app.Activity
import android.content.Intent
import de.markusfisch.android.binaryeye.activity.SplashActivity
private const val RESTART_COUNT = "restart_count"
private var restartCount = 0
fun setRestartCount(intent: Intent?) {
intent?.let {
restartCount = intent.getIntExtra(RESTART_COUNT, restartCount)
}
}
fun restartApp(activity: Activity? = null) {
if (activity != null && restartCount < 3) {
val intent = Intent(activity, SplashActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.putExtra(RESTART_COUNT, ++restartCount)
activity.startActivity(intent)
activity.finish()
}
Runtime.getRuntime().exit(0)
}
@@ -5,9 +5,9 @@ import android.content.Intent
import android.net.Uri
import android.os.Build
import android.support.v4.content.FileProvider
import android.widget.Toast
import de.markusfisch.android.binaryeye.BuildConfig
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.widget.toast
import java.io.File
fun shareText(context: Context, text: String, type: String = "text/plain") {
@@ -29,19 +29,17 @@ fun execShareIntent(context: Context, intent: Intent) {
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
} else {
Toast.makeText(
context,
R.string.cannot_resolve_action,
Toast.LENGTH_SHORT
).show()
context.toast(R.string.cannot_resolve_action)
}
}
fun shareFile(context: Context, file: File, type: String) {
shareUri(context, getUriForFile(context, file), type)
getUriForFile(context, file)?.let {
shareUri(context, it, type)
}
}
fun getUriForFile(context: Context, file: File): Uri {
fun getUriForFile(context: Context, file: File): Uri? {
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
Uri.fromFile(file)
} else {
@@ -2,13 +2,10 @@ package de.markusfisch.android.binaryeye.app
import android.content.Context
import android.content.ContextWrapper
import android.graphics.Rect
import android.graphics.drawable.ColorDrawable
import android.os.Build
import android.support.v4.content.ContextCompat
import android.support.v4.view.ViewCompat
import android.support.v7.app.AppCompatActivity
import android.support.v7.widget.Toolbar
import android.view.View
import android.widget.AbsListView
import de.markusfisch.android.binaryeye.R
@@ -20,13 +17,16 @@ val systemBarScrollListener = object : AbsListView.OnScrollListener {
visibleItemCount: Int,
totalItemCount: Int
) {
// give Android some time to settle down before running this;
// give Android some time to settle down before running this,
// not putting it on the queue makes it only work sometimes
view.post {
val scrolled = firstVisibleItem > 0 ||
(totalItemCount > 0 && view.getChildAt(0).top < 0)
val scrollable = if (scrolled) true else totalItemCount > 0 &&
view.getChildAt(view.lastVisiblePosition).bottom >= view.height
(totalItemCount > 0 && firstChildScrolled(view))
val scrollable = if (scrolled) {
true
} else {
totalItemCount > 0 && lastChildOutOfView(view)
}
colorSystemAndToolBars(view.context, scrolled, scrollable)
}
}
@@ -38,6 +38,16 @@ val systemBarScrollListener = object : AbsListView.OnScrollListener {
}
}
private fun firstChildScrolled(listView: AbsListView): Boolean {
val child = listView.getChildAt(0)
return child != null && child.top < listView.paddingTop
}
private fun lastChildOutOfView(listView: AbsListView): Boolean {
val child = listView.getChildAt(listView.lastVisiblePosition)
return child != null && child.bottom >= listView.height
}
fun initSystemBars(activity: AppCompatActivity) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
activity.window.decorView.systemUiVisibility =
@@ -48,28 +58,53 @@ fun initSystemBars(activity: AppCompatActivity) {
colorSystemAndToolBars(activity)
}
private var statusBarColorLocked = false
fun lockStatusBarColor() {
statusBarColorLocked = true
}
fun unlockStatusBarColor() {
statusBarColorLocked = false
}
private var translucentPrimaryColor = 0
private val actionBarBackground = ColorDrawable()
fun colorSystemAndToolBars(
context: Context,
scrolled: Boolean = false,
scrollable: Boolean = false
) {
val translucentColor = getTranslucentPrimaryColor(context)
val topColor = if (scrolled) translucentColor else 0
val bottomColor = if (scrolled || scrollable) translucentColor else 0
if (translucentPrimaryColor == 0) {
translucentPrimaryColor = ContextCompat.getColor(
context,
R.color.primary_translucent
)
}
val topColor = if (scrolled) translucentPrimaryColor else 0
val activity = getAppCompatActivity(context) ?: return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
val window = activity.window
window.statusBarColor = topColor
window.navigationBarColor = bottomColor
if (!statusBarColorLocked) {
window.statusBarColor = topColor
}
window.navigationBarColor = if (scrolled || scrollable) {
translucentPrimaryColor
} else {
0
}
}
activity.supportActionBar?.setBackgroundDrawable(ColorDrawable(topColor))
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)
}
)
}
private fun getTranslucentPrimaryColor(context: Context) = ContextCompat.getColor(
context,
R.color.primary
) and 0xffffff or 0xd8000000.toInt()
private fun getAppCompatActivity(context: Context): AppCompatActivity? {
var ctx = context
while (ctx is ContextWrapper) {
@@ -80,39 +115,3 @@ private fun getAppCompatActivity(context: Context): AppCompatActivity? {
}
return null
}
private var windowInsetsListener: ((insets: Rect) -> Unit)? = null
// invoke this from Fragment.onCreateView() *or* Activity.onCreate()
fun setWindowInsetListener(listener: (insets: Rect) -> Unit) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
listener(windowInsets)
} else {
windowInsetsListener = listener
}
}
private val windowInsets = Rect()
// invoke this from Activity.onCreate() to setup listening for insets
fun setupInsets(view: View, toolbar: Toolbar) {
val toolBarHeight = toolbar.layoutParams.height
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
// save insets so setWindowInsetListener() can return them
// when it's called from Fragment.onCreateView() later on
windowInsets.set(0, toolBarHeight, 0, 0)
windowInsetsListener?.also { it(windowInsets) }
return
}
ViewCompat.setOnApplyWindowInsetsListener(view) { _, insets ->
// runs *after* layout and thus *after* Fragment.onCreateView()
if (insets.hasSystemWindowInsets()) {
windowInsets.set(
insets.systemWindowInsetLeft,
insets.systemWindowInsetTop + toolBarHeight,
insets.systemWindowInsetRight,
insets.systemWindowInsetBottom
)
windowInsetsListener?.also { it(windowInsets) }
}
insets
}
}
@@ -0,0 +1,24 @@
package de.markusfisch.android.binaryeye.app
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
private const val MILLISECONDS = 100L
fun Vibrator.vibrate() {
if (!prefs.vibrate) {
return
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
@Suppress("DEPRECATION")
this.vibrate(MILLISECONDS)
} else {
this.vibrate(
VibrationEffect.createOneShot(
MILLISECONDS,
VibrationEffect.DEFAULT_AMPLITUDE
)
)
}
}
@@ -0,0 +1,92 @@
package de.markusfisch.android.binaryeye.data
import android.content.Context
import android.database.Cursor
import de.markusfisch.android.binaryeye.app.toHexString
import de.markusfisch.android.binaryeye.app.writeExternalFile
import java.io.ByteArrayOutputStream
import java.io.OutputStream
fun exportCsv(
context: Context,
name: String,
cursor: Cursor,
delimiter: String
) = writeExternalFile(context, name, "text/csv") { outputStream ->
exportCsv(outputStream, cursor, delimiter)
}
fun exportCsv(cursor: Cursor, delimiter: String): String? {
val outputStream = ByteArrayOutputStream()
exportCsv(outputStream, cursor, delimiter)
return outputStream.toString()
}
private fun exportCsv(
outputStream: OutputStream,
cursor: Cursor,
delimiter: String
) {
if (!cursor.moveToFirst()) {
return
}
val columns = arrayOf(
Database.SCANS_DATETIME,
Database.SCANS_FORMAT,
Database.SCANS_CONTENT,
Database.SCANS_ERROR_CORRECTION_LEVEL,
Database.SCANS_ISSUE_NUMBER,
Database.SCANS_ORIENTATION,
Database.SCANS_OTHER_META_DATA,
Database.SCANS_PDF417_EXTRA_METADATA,
Database.SCANS_POSSIBLE_COUNTRY,
Database.SCANS_SUGGESTED_PRICE,
Database.SCANS_UPC_EAN_EXTENSION
)
val indices = columns.map {
cursor.getColumnIndex(it)
}
val contentIndex = cursor.getColumnIndex(Database.SCANS_CONTENT)
val rawIndex = cursor.getColumnIndex(Database.SCANS_RAW)
outputStream.write(
columns.joinToString(
delimiter,
postfix = "\n"
).toByteArray()
)
do {
var deviation: Pair<Int, String>? = null
if (cursor.getString(contentIndex)?.isEmpty() == true) {
deviation = Pair(
contentIndex,
cursor.getBlob(rawIndex).toHexString()
)
}
outputStream.write(
cursor.toCsvRecord(indices, delimiter, deviation)
)
} while (cursor.moveToNext())
}
private fun Cursor.toCsvRecord(
indices: List<Int>,
delimiter: String,
deviation: Pair<Int, String>?
): ByteArray {
val sb = StringBuilder()
indices.forEach {
val value = if (deviation?.first == it) {
deviation.second
} else {
this.getString(it)
}
sb.append(value?.quoteAndEscape() ?: "")
sb.append(delimiter)
}
sb.append("\n")
return sb.toString().toByteArray()
}
private fun String.quoteAndEscape() = "\"${this
.replace("\n", " ")
.replace("\"", "\"\"")}\""
@@ -5,8 +5,8 @@ import android.content.Context
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import android.text.format.DateFormat
import de.markusfisch.android.binaryeye.app.hasNonPrintableCharacters
import de.markusfisch.android.binaryeye.app.prefs
class Database {
private lateinit var db: SQLiteDatabase
@@ -15,70 +15,181 @@ class Database {
db = OpenHelper(context).writableDatabase
}
fun getScans(): Cursor? = db.rawQuery(
fun getScans(query: String? = null): Cursor? = db.rawQuery(
"""SELECT
$SCANS_ID,
$SCANS_DATETIME,
$SCANS_NAME,
$SCANS_CONTENT,
$SCANS_FORMAT
FROM $SCANS
${getWhereClause(query)}
ORDER BY $SCANS_DATETIME DESC
""", null
""", getWhereArguments(query)
)
fun getScan(id: Long): Cursor? = db.rawQuery(
fun getScansDetailed(query: String? = null): Cursor? = db.rawQuery(
"""SELECT
$SCANS_ID,
$SCANS_DATETIME,
$SCANS_NAME,
$SCANS_CONTENT,
$SCANS_RAW,
$SCANS_FORMAT
$SCANS_FORMAT,
$SCANS_ERROR_CORRECTION_LEVEL,
$SCANS_ISSUE_NUMBER,
$SCANS_ORIENTATION,
$SCANS_OTHER_META_DATA,
$SCANS_PDF417_EXTRA_METADATA,
$SCANS_POSSIBLE_COUNTRY,
$SCANS_SUGGESTED_PRICE,
$SCANS_UPC_EAN_EXTENSION
FROM $SCANS
${getWhereClause(query)}
ORDER BY $SCANS_DATETIME DESC
""", getWhereArguments(query)
)
private fun getWhereClause(
query: String?,
prefix: String = "WHERE"
) = if (query?.isNotEmpty() == true) {
"""$prefix $SCANS_CONTENT LIKE ?
OR $SCANS_NAME LIKE ?"""
} else {
""
}
private fun getWhereArguments(
query: String?
) = if (query?.isNotEmpty() == true) {
val instr = "%$query%"
arrayOf(instr, instr)
} else {
null
}
fun getScan(id: Long): Scan? = db.rawQuery(
"""SELECT
$SCANS_ID,
$SCANS_DATETIME,
$SCANS_NAME,
$SCANS_CONTENT,
$SCANS_RAW,
$SCANS_FORMAT,
$SCANS_ERROR_CORRECTION_LEVEL,
$SCANS_ISSUE_NUMBER,
$SCANS_ORIENTATION,
$SCANS_OTHER_META_DATA,
$SCANS_PDF417_EXTRA_METADATA,
$SCANS_POSSIBLE_COUNTRY,
$SCANS_SUGGESTED_PRICE,
$SCANS_UPC_EAN_EXTENSION
FROM $SCANS
WHERE $SCANS_ID = ?
""", arrayOf("$id")
)
)?.use {
if (it.moveToFirst()) {
Scan(
it.getString(SCANS_CONTENT),
it.getBlob(SCANS_RAW),
it.getString(SCANS_FORMAT),
it.getString(SCANS_ERROR_CORRECTION_LEVEL),
it.getString(SCANS_ISSUE_NUMBER),
it.getString(SCANS_ORIENTATION),
it.getString(SCANS_OTHER_META_DATA),
it.getString(SCANS_PDF417_EXTRA_METADATA),
it.getString(SCANS_POSSIBLE_COUNTRY),
it.getString(SCANS_SUGGESTED_PRICE),
it.getString(SCANS_UPC_EAN_EXTENSION),
it.getString(SCANS_DATETIME),
it.getLong(SCANS_ID)
)
} else {
null
}
}
fun hasBinaryData(): Cursor? = db.rawQuery(
"""SELECT
1
FROM $SCANS
WHERE $SCANS_RAW IS NOT NULL
LIMIT 1
""", null
)
fun insertScan(scan: Scan): Long {
val cv = ContentValues()
cv.put(SCANS_DATETIME, scan.timestamp)
val isRaw = hasNonPrintableCharacters(scan.content)
if (isRaw) {
cv.put(SCANS_CONTENT, "")
cv.put(SCANS_RAW, scan.raw ?: scan.content.toByteArray())
} else {
cv.put(SCANS_CONTENT, scan.content)
}
cv.put(SCANS_FORMAT, scan.format)
scan.errorCorrectionLevel?.let {
cv.put(SCANS_ERROR_CORRECTION_LEVEL, it)
}
scan.issueNumber?.let { cv.put(SCANS_ISSUE_NUMBER, it) }
scan.orientation?.let { cv.put(SCANS_ORIENTATION, it) }
scan.otherMetaData?.let { cv.put(SCANS_OTHER_META_DATA, it) }
scan.pdf417ExtraMetaData?.let { cv.put(SCANS_PDF417_EXTRA_METADATA, it) }
scan.possibleCountry?.let { cv.put(SCANS_POSSIBLE_COUNTRY, it) }
scan.suggestedPrice?.let { cv.put(SCANS_SUGGESTED_PRICE, it) }
scan.upcEanExtension?.let { cv.put(SCANS_UPC_EAN_EXTENSION, it) }
if (prefs.ignoreConsecutiveDuplicates) {
val id = getIdOfLastScan(
cv.get(SCANS_CONTENT) as String,
if (isRaw) cv.get(SCANS_RAW) as ByteArray else null,
scan.format
)
if (id > 0L) {
return id
}
}
return db.insert(SCANS, null, cv)
}
fun insertScan(
timestamp: Long,
private fun getIdOfLastScan(
content: String,
raw: ByteArray?,
format: String
): Long {
val cv = ContentValues()
cv.put(
SCANS_DATETIME, DateFormat.format(
"yyyy-MM-dd HH:mm:ss",
timestamp
).toString()
)
if (hasNonPrintableCharacters(content)) {
cv.put(SCANS_CONTENT, "")
cv.put(SCANS_RAW, if (raw != null) raw else content.toByteArray())
} else {
cv.put(SCANS_CONTENT, content)
}
cv.put(SCANS_FORMAT, format)
return db.insert(SCANS, null, cv)
return db.rawQuery(
"""SELECT
$SCANS_ID,
$SCANS_CONTENT,
$SCANS_RAW,
$SCANS_FORMAT
FROM $SCANS
ORDER BY $SCANS_ID DESC
LIMIT 1
""", null
)?.use {
if (it.count > 0 &&
it.moveToFirst() &&
it.getString(it.getColumnIndex(SCANS_CONTENT)) == content &&
(raw == null || it.getBlob(it.getColumnIndex(SCANS_RAW))
?.contentEquals(raw) == true) &&
it.getString(it.getColumnIndex(SCANS_FORMAT)) == format
) {
it.getLong(it.getColumnIndex(SCANS_ID))
} else {
0L
}
} ?: 0L
}
fun removeScan(id: Long) {
db.delete(SCANS, "$SCANS_ID = ?", arrayOf("$id"))
}
fun removeScans() {
db.delete(SCANS, null, null)
fun removeScans(query: String? = null) {
db.delete(SCANS, getWhereClause(query, ""), getWhereArguments(query))
}
fun renameScan(id: Long, name: String) {
val cv = ContentValues()
cv.put(SCANS_NAME, name)
db.update(SCANS, cv, "$SCANS_ID = ?", arrayOf("$id"))
}
private class OpenHelper(context: Context) :
SQLiteOpenHelper(context, "history.db", null, 2) {
SQLiteOpenHelper(context, FILE_NAME, null, 4) {
override fun onCreate(db: SQLiteDatabase) {
createScans(db)
}
@@ -91,16 +202,32 @@ class Database {
if (oldVersion < 2) {
addRawColumn(db)
}
if (oldVersion < 3) {
addMetaDataColumns(db)
}
if (oldVersion < 4) {
addNameColumn(db)
}
}
}
companion object {
const val FILE_NAME = "history.db"
const val SCANS = "scans"
const val SCANS_ID = "_id"
const val SCANS_DATETIME = "_datetime"
const val SCANS_NAME = "name"
const val SCANS_CONTENT = "content"
const val SCANS_RAW = "raw"
const val SCANS_FORMAT = "format"
const val SCANS_ERROR_CORRECTION_LEVEL = "error_correction_level"
const val SCANS_ISSUE_NUMBER = "issue_number"
const val SCANS_ORIENTATION = "orientation"
const val SCANS_OTHER_META_DATA = "other_meta_data"
const val SCANS_PDF417_EXTRA_METADATA = "pdf417_extra_metadata"
const val SCANS_POSSIBLE_COUNTRY = "possible_country"
const val SCANS_SUGGESTED_PRICE = "suggested_price"
const val SCANS_UPC_EAN_EXTENSION = "upc_ean_extension"
private fun createScans(db: SQLiteDatabase) {
db.execSQL("DROP TABLE IF EXISTS $SCANS")
@@ -108,9 +235,18 @@ class Database {
"""CREATE TABLE $SCANS (
$SCANS_ID INTEGER PRIMARY KEY AUTOINCREMENT,
$SCANS_DATETIME DATETIME NOT NULL,
$SCANS_NAME TEXT,
$SCANS_CONTENT TEXT NOT NULL,
$SCANS_RAW BLOB,
$SCANS_FORMAT TEXT NOT NULL
$SCANS_FORMAT TEXT NOT NULL,
$SCANS_ERROR_CORRECTION_LEVEL TEXT,
$SCANS_ISSUE_NUMBER INT,
$SCANS_ORIENTATION INT,
$SCANS_OTHER_META_DATA TEXT,
$SCANS_PDF417_EXTRA_METADATA TEXT,
$SCANS_POSSIBLE_COUNTRY TEXT,
$SCANS_SUGGESTED_PRICE TEXT,
$SCANS_UPC_EAN_EXTENSION TEXT
)"""
)
}
@@ -118,5 +254,32 @@ class Database {
private fun addRawColumn(db: SQLiteDatabase) {
db.execSQL("ALTER TABLE $SCANS ADD COLUMN $SCANS_RAW BLOB")
}
private fun addMetaDataColumns(db: SQLiteDatabase) {
db.execSQL("ALTER TABLE $SCANS ADD COLUMN $SCANS_ERROR_CORRECTION_LEVEL TEXT")
db.execSQL("ALTER TABLE $SCANS ADD COLUMN $SCANS_ISSUE_NUMBER INT")
db.execSQL("ALTER TABLE $SCANS ADD COLUMN $SCANS_ORIENTATION INT")
db.execSQL("ALTER TABLE $SCANS ADD COLUMN $SCANS_OTHER_META_DATA TEXT")
db.execSQL("ALTER TABLE $SCANS ADD COLUMN $SCANS_PDF417_EXTRA_METADATA TEXT")
db.execSQL("ALTER TABLE $SCANS ADD COLUMN $SCANS_POSSIBLE_COUNTRY TEXT")
db.execSQL("ALTER TABLE $SCANS ADD COLUMN $SCANS_SUGGESTED_PRICE TEXT")
db.execSQL("ALTER TABLE $SCANS ADD COLUMN $SCANS_UPC_EAN_EXTENSION TEXT")
}
private fun addNameColumn(db: SQLiteDatabase) {
db.execSQL("ALTER TABLE $SCANS ADD COLUMN $SCANS_NAME TEXT")
}
}
}
private fun Cursor.getString(name: String) = this.getString(
this.getColumnIndex(name)
)
private fun Cursor.getBlob(name: String) = this.getBlob(
this.getColumnIndex(name)
)
private fun Cursor.getLong(name: String) = this.getLong(
this.getColumnIndex(name)
)
@@ -0,0 +1,28 @@
package de.markusfisch.android.binaryeye.data
import android.app.Activity
import android.os.Environment
import de.markusfisch.android.binaryeye.app.hasWritePermission
import de.markusfisch.android.binaryeye.app.writeExternalFile
import java.io.File
import java.io.FileInputStream
fun exportDatabase(activity: Activity, fileName: String): Boolean {
if (!hasWritePermission(activity)) {
return false
}
val dbFile = File(
Environment.getDataDirectory(),
"//data//${activity.packageName}//databases//${Database.FILE_NAME}"
)
if (!dbFile.exists()) {
return false
}
return writeExternalFile(
activity,
fileName,
"application/vnd.sqlite3"
) {
FileInputStream(dbFile).copyTo(it)
}
}
@@ -0,0 +1,75 @@
package de.markusfisch.android.binaryeye.data
import android.content.Context
import android.database.Cursor
import de.markusfisch.android.binaryeye.app.toHexString
import de.markusfisch.android.binaryeye.app.writeExternalFile
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
fun exportJson(
context: Context,
name: String,
cursor: Cursor
) = writeExternalFile(context, name, "application/json") { outputStream ->
exportJson(cursor)?.let {
outputStream.write(it.toByteArray())
}
}
fun exportJson(cursor: Cursor): String? {
if (!cursor.moveToFirst()) {
return null
}
val columns = arrayOf(
Database.SCANS_DATETIME,
Database.SCANS_FORMAT,
Database.SCANS_CONTENT,
Database.SCANS_ERROR_CORRECTION_LEVEL,
Database.SCANS_ISSUE_NUMBER,
Database.SCANS_ORIENTATION,
Database.SCANS_OTHER_META_DATA,
Database.SCANS_PDF417_EXTRA_METADATA,
Database.SCANS_POSSIBLE_COUNTRY,
Database.SCANS_SUGGESTED_PRICE,
Database.SCANS_UPC_EAN_EXTENSION
)
val indices = columns.map {
Pair(cursor.getColumnIndex(it), it)
}
val contentIndex = cursor.getColumnIndex(Database.SCANS_CONTENT)
val rawIndex = cursor.getColumnIndex(Database.SCANS_RAW)
val root = JSONArray()
do {
var deviation: Pair<Int, String>? = null
if (cursor.getString(contentIndex)?.isEmpty() == true) {
deviation = Pair(
contentIndex,
cursor.getBlob(rawIndex).toHexString()
)
}
root.put(cursor.toJsonObject(indices, deviation))
} while (cursor.moveToNext())
return root.toString()
}
private fun Cursor.toJsonObject(
indices: List<Pair<Int, String>>,
deviation: Pair<Int, String>?
): JSONObject {
val obj = JSONObject()
return try {
indices.forEach {
val value = if (deviation?.first == it.first) {
deviation.second
} else {
this.getString(it.first)
}
obj.put(it.second, value ?: "")
}
obj
} catch (e: JSONException) {
obj
}
}
@@ -0,0 +1,169 @@
package de.markusfisch.android.binaryeye.data
import android.os.Parcel
import android.os.Parcelable
import android.text.format.DateFormat
import com.google.zxing.Result
import com.google.zxing.ResultMetadataType
data class Scan(
val content: String,
val raw: ByteArray?,
val format: String,
val errorCorrectionLevel: String?,
val issueNumber: String?,
val orientation: String?,
val otherMetaData: String?,
val pdf417ExtraMetaData: String?,
val possibleCountry: String?,
val suggestedPrice: String?,
val upcEanExtension: String?,
val timestamp: String = getDateTime(),
val id: Long = 0L
) : Parcelable {
constructor(result: Result) : this(
result.text,
getRawBytes(result),
result.barcodeFormat.toString(),
result.getMetaString(ResultMetadataType.ERROR_CORRECTION_LEVEL),
result.getMetaString(ResultMetadataType.ISSUE_NUMBER),
result.getMetaString(ResultMetadataType.ORIENTATION),
result.getMetaString(ResultMetadataType.OTHER),
result.getMetaString(ResultMetadataType.PDF417_EXTRA_METADATA),
result.getMetaString(ResultMetadataType.POSSIBLE_COUNTRY),
result.getMetaString(ResultMetadataType.SUGGESTED_PRICE),
result.getMetaString(ResultMetadataType.UPC_EAN_EXTENSION)
)
// Needs to be overwritten manually, as ByteArray is an array and
// this isn't handled well by Kotlin
override fun equals(other: Any?): Boolean {
if (this === other) {
return true
}
if (javaClass != other?.javaClass) {
return false
}
other as Scan
return id == other.id &&
timestamp == other.timestamp &&
content == other.content &&
((raw == null && other.raw == null) ||
(raw != null && other.raw != null && raw.contentEquals(other.raw))) &&
format == other.format &&
errorCorrectionLevel == other.errorCorrectionLevel &&
issueNumber == other.issueNumber &&
orientation == other.orientation &&
otherMetaData == other.otherMetaData &&
pdf417ExtraMetaData == other.pdf417ExtraMetaData &&
possibleCountry == other.possibleCountry &&
suggestedPrice == other.suggestedPrice &&
upcEanExtension == other.upcEanExtension
}
// Needs to be overwritten manually, as ByteArray is an array and
// this isn't handled well by Kotlin
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + timestamp.hashCode()
result = 31 * result + content.hashCode()
result = 31 * result + (raw?.contentHashCode() ?: 0)
result = 31 * result + format.hashCode()
result = 31 * result + (errorCorrectionLevel?.hashCode() ?: 0)
result = 31 * result + (issueNumber?.hashCode() ?: 0)
result = 31 * result + (orientation?.hashCode() ?: 0)
result = 31 * result + (otherMetaData?.hashCode() ?: 0)
result = 31 * result + (pdf417ExtraMetaData?.hashCode() ?: 0)
result = 31 * result + (possibleCountry?.hashCode() ?: 0)
result = 31 * result + (suggestedPrice?.hashCode() ?: 0)
result = 31 * result + (upcEanExtension?.hashCode() ?: 0)
return result
}
private constructor(parcel: Parcel) : this(
content = parcel.readString() ?: "",
raw = readByteArray(parcel),
format = parcel.readString() ?: "",
errorCorrectionLevel = parcel.readString(),
issueNumber = parcel.readString(),
orientation = parcel.readString(),
otherMetaData = parcel.readString(),
pdf417ExtraMetaData = parcel.readString(),
possibleCountry = parcel.readString(),
suggestedPrice = parcel.readString(),
upcEanExtension = parcel.readString(),
timestamp = parcel.readString() ?: "",
id = parcel.readLong()
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeString(content)
writeByteArray(parcel, raw)
parcel.writeString(format)
parcel.writeString(errorCorrectionLevel)
parcel.writeString(issueNumber)
parcel.writeString(orientation)
parcel.writeString(otherMetaData)
parcel.writeString(pdf417ExtraMetaData)
parcel.writeString(possibleCountry)
parcel.writeString(suggestedPrice)
parcel.writeString(upcEanExtension)
parcel.writeString(timestamp)
parcel.writeLong(id)
}
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)
}
}
}
private fun getDateTime(time: Long = System.currentTimeMillis()) = DateFormat.format(
"yyyy-MM-dd HH:mm:ss",
time
).toString()
private fun getRawBytes(result: Result): ByteArray? {
val metadata = result.resultMetadata ?: return null
val segments = metadata[ResultMetadataType.BYTE_SEGMENTS] ?: return null
var bytes = ByteArray(0)
@Suppress("UNCHECKED_CAST")
for (seg in segments as Iterable<ByteArray>) {
bytes += seg
}
// byte segments can never be shorter than the text.
// Zxing cuts off content prefixes like "WIFI:"
return if (bytes.size >= result.text.length) bytes else null
}
private fun Result.getMetaString(
key: ResultMetadataType
): String? = this.resultMetadata?.let {
it[key]?.toString()
}
private fun writeByteArray(parcel: Parcel, array: ByteArray?) {
val size = array?.size ?: 0
parcel.writeInt(size)
if (size > 0) {
parcel.writeByteArray(array)
}
}
private fun readByteArray(parcel: Parcel): ByteArray? {
val size = parcel.readInt()
return if (size > 0) {
val array = ByteArray(size)
parcel.readByteArray(array)
array
} else {
null
}
}
@@ -1,80 +0,0 @@
package de.markusfisch.android.binaryeye.data.csv
import de.markusfisch.android.binaryeye.app.addDelimiter
import de.markusfisch.android.binaryeye.app.asFlowWith
import kotlinx.coroutines.flow.*
fun <T> csvBuilder(config: CSVBuilder<T>.() -> Unit): CSVBuilder<T> {
return CSVBuilder<T>().apply(config)
}
class CSVBuilder<T> {
private val columns = mutableListOf<ColumnBuilder>()
fun column(config: ColumnBuilder.() -> Unit): ColumnBuilder {
return ColumnBuilder().apply(config).also {
columns.add(it)
}
}
inner class ColumnBuilder {
var isBinary = false
var name: String? = null
internal var gettingBy: ((T) -> ByteArray)? = null
fun gettingBy(getter: (T) -> ByteArray) {
gettingBy = getter
}
fun gettingByString(getter: (T) -> String): Unit = gettingBy {
getter(it).toByteArray()
}
}
fun buildWith(inputs: Flow<T>, delimiter: String): Flow<ByteArray> = flow {
val columnsFlow = columns.asFlow()
val header = columnsFlow.buildHeader(delimiter)
val contentLines = inputs.buildContent(columnsFlow, delimiter)
emitAll(header)
emit("\n".toByteArray())
emitAll(contentLines)
}
private fun Flow<ColumnBuilder>.buildHeader(delimiter: String) = map {
it.name?.escaped(delimiter)?.toByteArray()
?: throw IllegalStateException("You need to provide a name for csv column")
}.addDelimiter(delimiter)
private fun Flow<T>.buildContent(
columnsFlow: Flow<ColumnBuilder>,
delimiter: String
): Flow<ByteArray> = map { input ->
input.getLine(columnsFlow, delimiter)
}.addDelimiter("\n".toByteArray()) { other: Flow<ByteArray> ->
// this: ByteArray
this asFlowWith other
}.flattenConcat()
private fun T.getLine(columnsFlow: Flow<ColumnBuilder>, delimiter: String): Flow<ByteArray> =
columnsFlow.map { column ->
column.gettingBy?.run {
var result = invoke(this@getLine)
if (!column.isBinary) {
result = String(result).escaped(delimiter).toByteArray()
}
return@run result
} ?: throw IllegalStateException("You need to provide a getter for the value")
}.addDelimiter(delimiter)
private fun Any.escaped(delimiter: String): String {
val any = this.toString()
return when {
any.run { contains('"') || contains('\n') || contains(delimiter) } ->
"\"${any.replace("\"", "\"\"").replace(delimiter, "\"$delimiter")}\""
else -> any
}
}
}
@@ -4,19 +4,16 @@ import android.annotation.SuppressLint
import android.app.AlertDialog
import android.graphics.Bitmap
import android.os.Bundle
import android.os.Environment
import android.support.v4.app.Fragment
import android.view.*
import android.widget.EditText
import android.widget.Toast
import com.google.zxing.BarcodeFormat
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.app.addSuffixIfNotGiven
import de.markusfisch.android.binaryeye.app.hasWritePermission
import de.markusfisch.android.binaryeye.app.setWindowInsetListener
import de.markusfisch.android.binaryeye.app.shareFile
import de.markusfisch.android.binaryeye.view.setPadding
import de.markusfisch.android.binaryeye.app.*
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.binaryeye.zxing.Zxing
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.GlobalScope
@@ -24,6 +21,7 @@ import kotlinx.coroutines.launch
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.OutputStream
import java.util.*
class BarcodeFragment : Fragment() {
@@ -41,7 +39,8 @@ class BarcodeFragment : Fragment() {
container: ViewGroup?,
state: Bundle?
): View? {
activity?.setTitle(R.string.view_barcode)
val ac = activity ?: return null
ac.setTitle(R.string.view_barcode)
val view = inflater.inflate(
R.layout.fragment_barcode,
@@ -60,7 +59,9 @@ class BarcodeFragment : Fragment() {
if (message == null || message.isEmpty()) {
message = getString(R.string.error_encoding_barcode)
}
Toast.makeText(activity, message, Toast.LENGTH_SHORT).show()
message?.let {
ac.toast(message)
}
fragmentManager.popBackStack()
return null
}
@@ -83,9 +84,9 @@ class BarcodeFragment : Fragment() {
}
}
setWindowInsetListener { insets ->
(view.findViewById(R.id.inset_layout) as View).setPadding(insets)
imageView.insets.set(insets)
(view.findViewById(R.id.inset_layout) as View).setPaddingFromWindowInsets()
imageView.doOnApplyWindowInsets { v, insets ->
(v as ConfinedScalingImageView).insets.set(insets)
}
return view
@@ -131,31 +132,17 @@ class BarcodeFragment : Fragment() {
.show()
}
private fun saveAsFile(bitmap: Bitmap, path: String) {
private fun saveAsFile(bitmap: Bitmap, fileName: String) {
val ac = activity ?: return
if (!hasWritePermission(ac)) {
return
}
GlobalScope.launch {
val success = saveBitmap(
bitmap,
File(
Environment.getExternalStoragePublicDirectory(
Environment.DIRECTORY_DOWNLOADS
),
path
)
)
val message = writeExternalFile(ac, fileName, "image/png") {
bitmap.saveAsPng(it)
}.toSaveResult()
GlobalScope.launch(Main) {
Toast.makeText(
ac,
if (success) {
R.string.saved_in_downloads
} else {
R.string.error_saving_binary_data
},
Toast.LENGTH_LONG
).show()
ac.toast(message)
}
}
}
@@ -166,19 +153,19 @@ class BarcodeFragment : Fragment() {
context.externalCacheDir,
"shared_barcode.png"
)
val success = saveBitmap(bitmap, file)
val success = try {
FileOutputStream(file).use {
bitmap.saveAsPng(it)
}
true
} catch (e: IOException) {
false
}
GlobalScope.launch(Main) {
if (success) {
shareFile(context, file, "image/png")
} else {
val ac = activity
ac?.let {
Toast.makeText(
ac,
R.string.error_saving_binary_data,
Toast.LENGTH_LONG
).show()
}
activity?.toast(R.string.error_saving_file)
}
}
}
@@ -205,15 +192,8 @@ class BarcodeFragment : Fragment() {
}
}
private fun saveBitmap(bitmap: Bitmap, file: File): Boolean {
return try {
val fos = FileOutputStream(file)
bitmap.compress(Bitmap.CompressFormat.PNG, 90, fos)
fos.close()
true
} catch (e: IOException) {
false
}
private fun Bitmap.saveAsPng(outputStream: OutputStream, quality: Int = 90) {
this.compress(Bitmap.CompressFormat.PNG, quality, outputStream)
}
private val fileNameCharacters = "[^A-Za-z0-9]".toRegex()
@@ -1,33 +1,34 @@
package de.markusfisch.android.binaryeye.fragment
import android.content.Context
import android.os.Build
import android.os.Bundle
import android.support.design.widget.FloatingActionButton
import android.support.v4.app.Fragment
import android.text.ClipboardManager
import android.text.Editable
import android.text.TextWatcher
import android.view.*
import android.widget.EditText
import android.widget.TableLayout
import android.widget.TableRow
import android.widget.TextView
import android.widget.Toast
import com.google.zxing.BarcodeFormat
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.app.*
import de.markusfisch.android.binaryeye.view.setPadding
import de.markusfisch.android.binaryeye.data.Scan
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.flow.flowOf
import kotlinx.coroutines.launch
class DecodeFragment : Fragment() {
private lateinit var contentView: EditText
private lateinit var formatView: TextView
private lateinit var metaView: TableLayout
private lateinit var hexView: TextView
private lateinit var format: BarcodeFormat
private lateinit var format: String
private lateinit var fab: FloatingActionButton
private var action = ActionRegistry.DEFAULT_ACTION
@@ -56,10 +57,15 @@ class DecodeFragment : Fragment() {
false
)
val inputContent = arguments?.getString(CONTENT) ?: ""
isBinary = hasNonPrintableCharacters(inputContent) or inputContent.isEmpty()
val raw = arguments?.getByteArray(RAW) ?: inputContent.toByteArray()
format = arguments?.getSerializable(FORMAT) as BarcodeFormat? ?: BarcodeFormat.QR_CODE
val scan = arguments?.getParcelable(SCAN) as Scan?
?: throw IllegalArgumentException("DecodeFragment needs a Scan")
val inputContent = scan.content
isBinary = hasNonPrintableCharacters(
inputContent
) or inputContent.isEmpty()
val raw = scan.raw ?: inputContent.toByteArray()
format = scan.format
contentView = view.findViewById(R.id.content)
fab = view.findViewById(R.id.open)
@@ -103,15 +109,27 @@ class DecodeFragment : Fragment() {
}
formatView = view.findViewById(R.id.format)
metaView = view.findViewById(R.id.meta)
hexView = view.findViewById(R.id.hex)
updateViewsAndAction(raw)
setWindowInsetListener { insets ->
(view.findViewById(R.id.inset_layout) as View).setPadding(insets)
(view.findViewById(R.id.scroll_view) as View).setPadding(insets)
if (action is WifiAction) {
val password = (action as WifiAction).password
if (password != null) {
activity?.apply {
copyToClipboard(password)
toast(R.string.copied_password_to_clipboard)
}
}
}
if (prefs.showMetaData) {
fillMetaView(metaView, scan)
}
(view.findViewById(R.id.inset_layout) as View).setPaddingFromWindowInsets()
(view.findViewById(R.id.scroll_view) as View).setPaddingFromWindowInsets()
return view
}
@@ -123,19 +141,15 @@ class DecodeFragment : Fragment() {
formatView.text = resources.getQuantityString(
R.plurals.barcode_info,
bytes.size,
format.toString(),
format,
bytes.size
)
hexView.text = hexDump(bytes, 33)
hexView.text = if (prefs.showHexDump) hexDump(bytes) else ""
if (prevAction !== action) {
fab.setImageResource(action.iconResId)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
fab.setOnLongClickListener { v ->
Toast.makeText(
v.context,
action.titleResId,
Toast.LENGTH_SHORT
).show()
v.context.toast(action.titleResId)
true
}
} else {
@@ -144,6 +158,38 @@ class DecodeFragment : Fragment() {
}
}
private fun fillMetaView(tableLayout: TableLayout, scan: Scan) {
val ctx = tableLayout.context
val spaceBetween = (ctx.resources.displayMetrics.density * 16f).toInt()
var hasMeta = false
sortedMapOf(
R.string.error_correction_level to scan.errorCorrectionLevel,
R.string.issue_number to scan.issueNumber,
R.string.orientation to scan.orientation,
R.string.other_meta_data to scan.otherMetaData,
R.string.pdf417_extra_metadata to scan.pdf417ExtraMetaData,
R.string.possible_country to scan.possibleCountry,
R.string.suggested_price to scan.suggestedPrice,
R.string.upc_ean_extension to scan.upcEanExtension
).forEach { item ->
item.value?.let {
val tr = TableRow(ctx)
val keyView = TextView(ctx)
keyView.setText(item.key)
val valueView = TextView(ctx)
valueView.setPadding(spaceBetween, 0, 0, 0)
valueView.text = it
tr.addView(keyView)
tr.addView(valueView)
tableLayout.addView(tr)
hasMeta = true
}
}
if (hasMeta) {
tableLayout.setPadding(0, 0, 0, spaceBetween)
}
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.fragment_decode, menu)
if (isBinary) {
@@ -174,36 +220,42 @@ class DecodeFragment : Fragment() {
}
private fun copyToClipboard(text: String) {
activity ?: return
val cm = activity.getSystemService(
Context.CLIPBOARD_SERVICE
) as ClipboardManager
cm.text = text
Toast.makeText(
activity,
R.string.put_into_clipboard,
Toast.LENGTH_SHORT
).show()
activity?.apply {
copyToClipboard(text)
toast(R.string.copied_to_clipboard)
}
}
private fun executeAction(content: ByteArray) {
if (activity != null && content.isNotEmpty()) scope.launch {
action.execute(activity, content)
val ac = activity ?: return
if (content.isNotEmpty()) {
if (action is WifiAction &&
Build.VERSION.SDK_INT < Build.VERSION_CODES.Q &&
!hasLocationPermission(ac)
) {
return
}
scope.launch {
action.execute(ac, content)
}
}
}
private fun askForFileNameAndSave(raw: ByteArray) {
val ac = activity ?: return
if (!hasWritePermission(ac)) return
if (!hasWritePermission(ac)) {
return
}
scope.launch(Dispatchers.Main) {
val name = ac.askForFileName() ?: return@launch
val message = flowOf(raw).writeToFile(name)
Toast.makeText(
val message = writeExternalFile(
ac,
message,
Toast.LENGTH_SHORT
).show()
name,
"application/octet-stream"
) {
it.write(raw)
}.toSaveResult()
ac.toast(message)
}
}
@@ -213,21 +265,11 @@ class DecodeFragment : Fragment() {
}
companion object {
private const val CONTENT = "content"
private const val FORMAT = "format"
private const val RAW = "raw"
private const val SCAN = "scan"
fun newInstance(
content: String,
format: BarcodeFormat,
raw: ByteArray? = null
): Fragment {
fun newInstance(scan: Scan): Fragment {
val args = Bundle()
args.putString(CONTENT, content)
args.putSerializable(FORMAT, format)
if (raw != null) {
args.putByteArray(RAW, raw)
}
args.putParcelable(SCAN, scan)
val fragment = DecodeFragment()
fragment.arguments = args
return fragment
@@ -235,7 +277,7 @@ class DecodeFragment : Fragment() {
}
}
private fun hexDump(bytes: ByteArray, charsPerLine: Int): String {
fun hexDump(bytes: ByteArray, charsPerLine: Int = 33): String {
if (charsPerLine < 4 || bytes.isEmpty()) {
return ""
}
@@ -249,9 +291,7 @@ private fun hexDump(bytes: ByteArray, charsPerLine: Int): String {
val ord = bytes[i]
hex.append(String.format("%02X ", ord))
ascii.append(if (ord > 31) ord.toChar() else " ")
++i
val posInLine = i % itemsPerLine
val atEnd = i >= len
var atLineEnd = posInLine == 0
@@ -1,17 +1,15 @@
package de.markusfisch.android.binaryeye.fragment
import android.content.Context
import android.os.Bundle
import android.support.v4.app.Fragment
import android.view.*
import android.view.inputmethod.InputMethodManager
import android.widget.*
import com.google.zxing.BarcodeFormat
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.app.addFragment
import de.markusfisch.android.binaryeye.app.hideSoftKeyboard
import de.markusfisch.android.binaryeye.app.prefs
import de.markusfisch.android.binaryeye.app.setWindowInsetListener
import de.markusfisch.android.binaryeye.view.setPadding
import de.markusfisch.android.binaryeye.view.setPaddingFromWindowInsets
class EncodeFragment : Fragment() {
private lateinit var formatView: Spinner
@@ -53,7 +51,7 @@ class EncodeFragment : Fragment() {
)
formatView = view.findViewById(R.id.format)
formatView.adapter = ArrayAdapter<String>(
formatView.adapter = ArrayAdapter(
ac,
android.R.layout.simple_list_item_1,
writers.map { it.name }
@@ -63,30 +61,30 @@ class EncodeFragment : Fragment() {
sizeBarView = view.findViewById(R.id.size_bar)
initSizeBar()
contentView = view.findViewById<EditText>(R.id.content)
contentView = view.findViewById(R.id.content)
val args = arguments
args?.also {
contentView.setText(it.getString(CONTENT))
}
val barcodeFormat = args?.getSerializable(FORMAT) as BarcodeFormat?
val barcodeFormat = args?.getString(FORMAT)
if (barcodeFormat != null) {
formatView.setSelection(writers.indexOf(barcodeFormat))
formatView.setSelection(
writers.indexOf(resolveFormat(barcodeFormat))
)
} else if (state == null) {
formatView.post {
formatView.setSelection(prefs.indexOfLastSelectedFormat)
}
}
view.findViewById<View>(R.id.encode).setOnClickListener { v ->
view.findViewById<View>(R.id.encode).setOnClickListener {
encode()
}
setWindowInsetListener { insets ->
(view.findViewById(R.id.inset_layout) as View).setPadding(insets)
(view.findViewById(R.id.scroll_view) as View).setPadding(insets)
}
(view.findViewById(R.id.inset_layout) as View).setPaddingFromWindowInsets()
(view.findViewById(R.id.scroll_view) as View).setPaddingFromWindowInsets()
return view
}
@@ -121,7 +119,7 @@ class EncodeFragment : Fragment() {
Toast.LENGTH_SHORT
).show()
} else {
hideSoftKeyboard(contentView)
activity?.hideSoftKeyboard(contentView)
addFragment(
fragmentManager,
BarcodeFragment.newInstance(content, format, size)
@@ -154,31 +152,26 @@ class EncodeFragment : Fragment() {
private fun getSize(power: Int) = 128 * (power + 1)
private fun hideSoftKeyboard(view: View) {
val im = activity?.getSystemService(
Context.INPUT_METHOD_SERVICE
) as InputMethodManager?
im?.let {
im.hideSoftInputFromWindow(view.windowToken, 0)
}
}
companion object {
private const val CONTENT = "content"
private const val FORMAT = "format"
fun newInstance(
content: String,
format: BarcodeFormat? = null
format: String? = null
): Fragment {
val args = Bundle()
args.putString(CONTENT, content)
format?.let {
args.putSerializable(FORMAT, format)
}
format?.let { args.putString(FORMAT, it) }
val fragment = EncodeFragment()
fragment.arguments = args
return fragment
}
}
}
private fun resolveFormat(name: String): BarcodeFormat = try {
BarcodeFormat.valueOf(name)
} catch (_: IllegalArgumentException) {
BarcodeFormat.QR_CODE
}
@@ -1,38 +1,117 @@
package de.markusfisch.android.binaryeye.fragment
import android.annotation.SuppressLint
import android.app.AlertDialog
import android.app.SearchManager
import android.content.Context
import android.database.Cursor
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import android.support.annotation.WorkerThread
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.widget.AbsListView
import android.widget.EditText
import android.widget.ListView
import android.widget.Toast
import com.google.zxing.BarcodeFormat
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.adapter.ScansAdapter
import de.markusfisch.android.binaryeye.app.*
import de.markusfisch.android.binaryeye.data.csv.csvBuilder
import de.markusfisch.android.binaryeye.repository.DatabaseRepository
import de.markusfisch.android.binaryeye.view.setPadding
import de.markusfisch.android.binaryeye.data.Database
import de.markusfisch.android.binaryeye.data.exportCsv
import de.markusfisch.android.binaryeye.data.exportDatabase
import de.markusfisch.android.binaryeye.data.exportJson
import de.markusfisch.android.binaryeye.view.setPaddingFromWindowInsets
import de.markusfisch.android.binaryeye.view.useVisibility
import de.markusfisch.android.binaryeye.widget.toast
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.mapNotNull
class HistoryFragment : Fragment() {
private lateinit var useHistorySwitch: SwitchCompat
private lateinit var listView: ListView
private lateinit var fab: View
private lateinit var progressView: View
private val parentJob = Job()
private val scope = CoroutineScope(Dispatchers.IO + parentJob)
private val actionModeCallback = object : ActionMode.Callback {
override fun onCreateActionMode(
mode: ActionMode,
menu: Menu
): Boolean {
mode.menuInflater.inflate(
R.menu.fragment_history_edit,
menu
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
lockStatusBarColor()
val ac = activity ?: return false
ac.window.statusBarColor = ContextCompat.getColor(
ac,
R.color.accent_dark
)
}
return true
}
override fun onPrepareActionMode(
mode: ActionMode,
menu: Menu
): Boolean {
return false
}
override fun onActionItemClicked(
mode: ActionMode,
item: MenuItem
): Boolean {
val ac = activity ?: return false
return when (item.itemId) {
R.id.copy_scan -> {
scansAdapter?.getSelectedContent()?.let {
ac.copyToClipboard(it)
ac.toast(R.string.copied_to_clipboard)
}
closeActionMode()
true
}
R.id.edit_scan -> {
scansAdapter?.let {
askForName(
ac,
it.selectedScanId,
getScanName(it.selectedScanPosition)
)
}
closeActionMode()
true
}
R.id.remove_scan -> {
scansAdapter?.let {
askToRemoveScan(ac, it.selectedScanId)
}
closeActionMode()
true
}
else -> false
}
}
override fun onDestroyActionMode(mode: ActionMode) {
closeActionMode()
}
}
private var scansAdapter: ScansAdapter? = null
private var listViewState: Parcelable? = null
private var actionMode: ActionMode? = null
private var filter: String? = null
private var clearListMenuItem: MenuItem? = null
private var exportHistoryMenuItem: MenuItem? = null
override fun onCreate(state: Bundle?) {
super.onCreate(state)
@@ -43,8 +122,9 @@ class HistoryFragment : Fragment() {
inflater: LayoutInflater,
container: ViewGroup?,
state: Bundle?
): View {
activity?.setTitle(R.string.history)
): View? {
val ac = activity ?: return null
ac.setTitle(R.string.history)
val view = inflater.inflate(
R.layout.fragment_history,
@@ -52,28 +132,23 @@ class HistoryFragment : Fragment() {
false
)
val useHistorySwitch = view.findViewById(
useHistorySwitch = view.findViewById(
R.id.use_history
) as SwitchCompat
initHistorySwitch(useHistorySwitch)
val headView = View(context)
val footView = View(context)
setWindowInsetListener { insets ->
(view.findViewById(R.id.inset_layout) as View).setPadding(insets)
headView.layoutParams = AbsListView.LayoutParams(0, insets.top)
footView.layoutParams = AbsListView.LayoutParams(0, insets.bottom)
}
listView = view.findViewById(R.id.scans)
listView.addHeaderView(headView, null, false)
listView.addFooterView(footView, null, false)
listView.emptyView = useHistorySwitch
listView.setOnItemClickListener { _, _, _, id ->
showScan(id)
}
listView.setOnItemLongClickListener { _, v, _, id ->
askToRemoveScan(v.context, id)
listView.setOnItemLongClickListener { _, v, position, id ->
v.isSelected = true
scansAdapter?.select(id, position)
if (actionMode == null && ac is AppCompatActivity) {
actionMode = ac.delegate.startSupportActionMode(
actionModeCallback
)
}
true
}
listView.setOnScrollListener(systemBarScrollListener)
@@ -85,6 +160,11 @@ class HistoryFragment : Fragment() {
progressView = view.findViewById(R.id.progress_view)
(view.findViewById(R.id.inset_layout) as View).setPaddingFromWindowInsets()
listView.setPaddingFromWindowInsets()
update()
return view
}
@@ -94,11 +174,6 @@ class HistoryFragment : Fragment() {
parentJob.cancel()
}
override fun onResume() {
super.onResume()
update(context)
}
override fun onPause() {
super.onPause()
listViewState = listView.onSaveInstanceState()
@@ -106,12 +181,43 @@ class HistoryFragment : Fragment() {
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.fragment_history, menu)
initSearchView(menu.findItem(R.id.search))
menu.setGroupVisible(R.id.scans_available, scansAdapter?.count != 0)
clearListMenuItem = menu.findItem(R.id.clear)
exportHistoryMenuItem = menu.findItem(R.id.export_history)
}
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
}
override fun onQueryTextChange(query: String): Boolean {
update(query)
return false
}
})
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.clear -> {
askToRemoveAllScans(context)
askToRemoveScans(context)
true
}
R.id.export_history -> {
@@ -131,19 +237,34 @@ class HistoryFragment : Fragment() {
}
}
private fun update(context: Context) {
private fun updateAndClearFilter() {
filter = null
update()
}
private fun update(query: String? = null) {
query?.let { filter = it }
scope.launch {
val cursor = db.getScansCursor()
val cursor = db.getScans(filter)
withContext(Dispatchers.Main) {
fab.visibility = if (cursor != null && cursor.count > 0) {
val ac = activity ?: return@withContext
val hasScans = cursor != null && cursor.count > 0
if (filter == null) {
if (!hasScans) {
listView.emptyView = useHistorySwitch
}
ActivityCompat.invalidateOptionsMenu(ac)
}
enableMenuItems(hasScans)
fab.visibility = if (hasScans) {
View.VISIBLE
} else {
View.GONE
}
cursor?.let {
cursor?.let { cursor ->
// close previous cursor
scansAdapter?.also { it.changeCursor(null) }
scansAdapter = ScansAdapter(context, it)
scansAdapter = ScansAdapter(ac, cursor)
listView.adapter = scansAdapter
listViewState?.also {
listView.onRestoreInstanceState(it)
@@ -153,39 +274,83 @@ class HistoryFragment : Fragment() {
}
}
private fun enableMenuItems(enabled: Boolean) {
clearListMenuItem?.isEnabled = enabled
exportHistoryMenuItem?.isEnabled = enabled
}
private fun closeActionMode() {
unlockStatusBarColor()
scansAdapter?.clearSelection()
actionMode?.finish()
actionMode = null
scansAdapter?.notifyDataSetChanged()
}
private fun showScan(id: Long) = db.getScan(id)?.also { scan ->
closeActionMode()
try {
addFragment(
fragmentManager,
DecodeFragment.newInstance(
scan.content,
BarcodeFormat.valueOf(scan.format),
scan.raw
)
DecodeFragment.newInstance(scan)
)
} catch (e: IllegalArgumentException) {
// shouldn't ever happen
// ignore, can never happen
}
}
private fun getScanName(position: Int): String? {
val cursor = scansAdapter?.getItem(position) as Cursor?
return cursor?.getString(cursor.getColumnIndex(Database.SCANS_NAME))
}
// dialogs don't have a parent layout
@SuppressLint("InflateParams")
private fun askForName(context: Context, id: Long, text: String?) {
val view = LayoutInflater.from(context).inflate(
R.layout.dialog_enter_name, null
)
val nameView = view.findViewById<EditText>(R.id.name)
nameView.setText(text)
AlertDialog.Builder(context)
.setView(view)
.setPositiveButton(android.R.string.ok) { _, _ ->
val name = nameView.text.toString()
db.renameScan(id, name)
update()
}
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.show()
}
private fun askToRemoveScan(context: Context, id: Long) {
AlertDialog.Builder(context)
.setMessage(R.string.really_remove_scan)
.setPositiveButton(android.R.string.ok) { _, _ ->
db.removeScan(id)
update(context)
if (scansAdapter?.count == 1) {
updateAndClearFilter()
} else {
update()
}
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
}
.show()
}
private fun askToRemoveAllScans(context: Context) {
private fun askToRemoveScans(context: Context) {
AlertDialog.Builder(context)
.setMessage(R.string.really_remove_all_scans)
.setMessage(
if (filter == null) {
R.string.really_remove_all_scans
} else {
R.string.really_remove_selected_scans
}
)
.setPositiveButton(android.R.string.ok) { _, _ ->
db.removeScans()
update(context)
db.removeScans(filter)
updateAndClearFilter()
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
}
@@ -193,74 +358,44 @@ class HistoryFragment : Fragment() {
}
private fun askToExportToFile(context: Context) = scope.launch {
val ac = activity ?: return@launch
progressView.useVisibility {
if (!hasWritePermission(activity)) {
if (!hasWritePermission(ac)) {
return@useVisibility
}
val (getBinaries, scans) = getScans {
alertDialog(context) { resume ->
setTitle(R.string.csv_allow_binary)
setMessage(R.string.csv_allow_binary_message)
setPositiveButton(R.string.no) { _, _ ->
resume(false)
}
setNegativeButton(android.R.string.yes) { _, _ ->
resume(true)
}
setNeutralButton(android.R.string.cancel) { _, _ ->
resume(null)
}
}
} ?: return@useVisibility
val delimiters = context.resources.getStringArray(
R.array.csv_delimiters_values
val options = context.resources.getStringArray(
R.array.export_options_values
)
val delimiter = alertDialog<String>(context) { resume ->
setTitle(R.string.csv_delimiter)
setItems(R.array.csv_delimiters_names) { _, which ->
resume(delimiters[which])
setTitle(R.string.export_as)
setItems(R.array.export_options_names) { _, which ->
resume(options[which])
}
} ?: return@useVisibility
val name = withContext(Dispatchers.Main) {
activity.askForFileName(suffix = "csv")
ac.askForFileName(
when (delimiter) {
"db" -> ".db"
"json" -> ".json"
else -> ".csv"
}
)
} ?: return@useVisibility
val csv = scans.toCSV(delimiter, getBinaries)
val toastMessage = csv.writeToFile(name)
val message = when (delimiter) {
"db" -> exportDatabase(ac, name)
else -> db.getScansDetailed(filter)?.use {
when (delimiter) {
"json" -> exportJson(context, name, it)
else -> exportCsv(context, name, it, delimiter)
}
} ?: false
}.toSaveResult()
withContext(Dispatchers.Main) {
Toast.makeText(
context,
toastMessage,
Toast.LENGTH_SHORT
).show()
ac.toast(message)
}
}
}
private fun Flow<DatabaseRepository.Scan>.toCSV(
delimiter: String,
allowBinary: Boolean
): Flow<ByteArray> {
return csvBuilder<DatabaseRepository.Scan> {
column {
name = "DATE"
gettingByString { it.timestamp }
}
column {
name = "TYPE"
gettingByString { it.format }
}
column {
name = "CONTENT"
gettingByString { it.content }
}
if (allowBinary) column {
isBinary = true
name = "BINARY_CONTENT"
gettingBy { it.raw ?: ByteArray(0) }
}
}.buildWith(this, delimiter)
}
private fun pickListSeparatorAndShare(context: Context) {
val separators = context.resources.getStringArray(
R.array.list_separators_values
@@ -273,33 +408,37 @@ class HistoryFragment : Fragment() {
.show()
}
private fun shareScans(separator: String) {
scope.launch {
progressView.useVisibility {
val sb = StringBuilder()
getScans { false }?.second?.collect {
sb.append(it.content)
sb.append(separator)
}
val text = sb.toString()
if (text.isNotEmpty()) withContext(Dispatchers.Main) {
shareText(context, text)
private fun shareScans(format: String) = scope.launch {
progressView.useVisibility {
var text: String? = null
db.getScansDetailed(filter)?.use { cursor ->
val details = format.split(":")
text = when (details[0]) {
"text" -> exportText(cursor, details[1])
"csv" -> exportCsv(cursor, details[1])
else -> exportJson(cursor)
}
}
}
}
@WorkerThread
private suspend inline fun getScans(
crossinline binaryData: suspend () -> Boolean?
): Pair<Boolean, Flow<DatabaseRepository.Scan>>? {
val getBinaries: Boolean = db.hasBinaryData() && binaryData() ?: return null
return getBinaries to db.getScans().mapNotNull {
when {
it.content.isNotEmpty() -> db.getScan(it.id)
getBinaries -> db.getScan(it.id)
else -> null
text?.let {
withContext(Dispatchers.Main) {
shareText(context, it)
}
}
}
}
}
private fun exportText(cursor: Cursor, separator: String): String {
val sb = StringBuilder()
val contentIndex = cursor.getColumnIndex(Database.SCANS_CONTENT)
if (cursor.moveToFirst()) {
do {
val content = cursor.getString(contentIndex)
if (content?.isNotEmpty() == true) {
sb.append(content)
sb.append(separator)
}
} while (cursor.moveToNext())
}
return sb.toString()
}
@@ -9,12 +9,19 @@ import android.view.ViewGroup
import android.widget.EditText
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.app.prefs
import de.markusfisch.android.binaryeye.app.setWindowInsetListener
import de.markusfisch.android.binaryeye.view.setPadding
import de.markusfisch.android.binaryeye.view.setPaddingFromWindowInsets
class PreferencesFragment : Fragment() {
private lateinit var openImmediatelySwitch: SwitchCompat
private lateinit var showCropHandleSwitch: SwitchCompat
private lateinit var zoomBySwipingSwitch: SwitchCompat
private lateinit var autoResizeSwitch: SwitchCompat
private lateinit var tryHarderSwitch: SwitchCompat
private lateinit var vibrateSwitch: SwitchCompat
private lateinit var useHistorySwitch: SwitchCompat
private lateinit var ignoreConsecutiveDuplicatesSwitch: SwitchCompat
private lateinit var openImmediatelySwitch: SwitchCompat
private lateinit var showMetaDataSwitch: SwitchCompat
private lateinit var showHexDumpSwitch: SwitchCompat
private lateinit var openWithUrlInput: EditText
override fun onCreateView(
@@ -30,9 +37,29 @@ class PreferencesFragment : Fragment() {
false
)
openImmediatelySwitch = view.findViewById(R.id.open_immediately)
if (prefs.openImmediately) {
openImmediatelySwitch.toggle()
showCropHandleSwitch = view.findViewById(R.id.show_crop_handle)
if (prefs.showCropHandle) {
showCropHandleSwitch.toggle()
}
zoomBySwipingSwitch = view.findViewById(R.id.zoom_by_swiping)
if (prefs.zoomBySwiping) {
zoomBySwipingSwitch.toggle()
}
autoResizeSwitch = view.findViewById(R.id.auto_rotate)
if (prefs.autoRotate) {
autoResizeSwitch.toggle()
}
tryHarderSwitch = view.findViewById(R.id.try_harder)
if (prefs.tryHarder) {
tryHarderSwitch.toggle()
}
vibrateSwitch = view.findViewById(R.id.vibrate)
if (prefs.vibrate) {
vibrateSwitch.toggle()
}
useHistorySwitch = view.findViewById(R.id.use_history)
@@ -40,20 +67,48 @@ class PreferencesFragment : Fragment() {
useHistorySwitch.toggle()
}
ignoreConsecutiveDuplicatesSwitch = view.findViewById(
R.id.ignore_consecutive_duplicates
)
if (prefs.ignoreConsecutiveDuplicates) {
ignoreConsecutiveDuplicatesSwitch.toggle()
}
openImmediatelySwitch = view.findViewById(R.id.open_immediately)
if (prefs.openImmediately) {
openImmediatelySwitch.toggle()
}
showMetaDataSwitch = view.findViewById(R.id.show_meta_data)
if (prefs.showMetaData) {
showMetaDataSwitch.toggle()
}
showHexDumpSwitch = view.findViewById(R.id.show_hex_dump)
if (prefs.showHexDump) {
showHexDumpSwitch.toggle()
}
openWithUrlInput = view.findViewById(R.id.open_with_url)
openWithUrlInput.setText(prefs.openWithUrl)
setWindowInsetListener { insets ->
(view.findViewById(R.id.scroll_view) as View).setPadding(insets)
}
(view.findViewById(R.id.scroll_view) as View).setPaddingFromWindowInsets()
return view
}
override fun onPause() {
super.onPause()
prefs.openImmediately = openImmediatelySwitch.isChecked
override fun onStop() {
super.onStop()
prefs.showCropHandle = showCropHandleSwitch.isChecked
prefs.zoomBySwiping = zoomBySwipingSwitch.isChecked
prefs.autoRotate = autoResizeSwitch.isChecked
prefs.tryHarder = tryHarderSwitch.isChecked
prefs.vibrate = vibrateSwitch.isChecked
prefs.useHistory = useHistorySwitch.isChecked
prefs.ignoreConsecutiveDuplicates = ignoreConsecutiveDuplicatesSwitch.isChecked
prefs.openImmediately = openImmediatelySwitch.isChecked
prefs.showMetaData = showMetaDataSwitch.isChecked
prefs.showHexDump = showHexDumpSwitch.isChecked
prefs.openWithUrl = openWithUrlInput.text.toString()
}
}
@@ -16,9 +16,7 @@ fun loadImageUri(cr: ContentResolver, uri: Uri): Bitmap? = try {
BitmapFactory.decodeStream(it, null, options)
options.inSampleSize = calculateInSampleSize(
options.outWidth,
options.outHeight,
1024,
1024
options.outHeight
)
options.inJustDecodeBounds = false
}
@@ -32,8 +30,8 @@ fun loadImageUri(cr: ContentResolver, uri: Uri): Bitmap? = try {
private fun calculateInSampleSize(
width: Int,
height: Int,
reqWidth: Int,
reqHeight: Int
reqWidth: Int = 1024,
reqHeight: Int = 1024
): Int {
var inSampleSize = 1
if (height > reqHeight || width > reqWidth) {
@@ -4,12 +4,8 @@ import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Point
import android.graphics.Rect
import android.support.v4.content.ContextCompat
import com.google.zxing.Result
import com.google.zxing.ResultPoint
import de.markusfisch.android.binaryeye.R
import kotlin.math.roundToInt
class Candidates(context: Context) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
@@ -34,33 +30,3 @@ class Candidates(context: Context) {
}
}
}
fun mapResult(
width: Int,
height: Int,
viewRect: Rect,
result: Result
): List<Point> = frameToView(width, height, viewRect).map(result.resultPoints)
fun frameToView(width: Int, height: Int, viewRect: Rect) = Mapping(
viewRect.width().toFloat() / width.toFloat(),
viewRect.height().toFloat() / height.toFloat(),
viewRect.left,
viewRect.top
)
data class Mapping(
val ratioX: Float,
val ratioY: Float,
val offsetX: Int,
val offsetY: Int
) {
fun map(points: Array<ResultPoint>): List<Point> {
return points.map {
Point(
(it.x * ratioX).roundToInt() + offsetX,
(it.y * ratioY).roundToInt() + offsetY
)
}
}
}
@@ -0,0 +1,36 @@
package de.markusfisch.android.binaryeye.graphics
import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.os.Build
fun Resources.getBitmapFromDrawable(
resId: Int
): Bitmap = getBitmapFromDrawable(getDrawableCompat(resId))
fun Resources.getDrawableCompat(
resId: Int
): Drawable = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
getDrawable(resId, null)
} else {
@Suppress("DEPRECATION")
getDrawable(resId)
}
private fun getBitmapFromDrawable(drawable: Drawable): Bitmap {
if (drawable is BitmapDrawable) {
return drawable.bitmap
}
val bitmap = Bitmap.createBitmap(
drawable.intrinsicWidth,
drawable.intrinsicHeight,
Bitmap.Config.ARGB_8888
)
val canvas = Canvas(bitmap)
drawable.setBounds(0, 0, canvas.width, canvas.height)
drawable.draw(canvas)
return bitmap
}
@@ -0,0 +1,85 @@
package de.markusfisch.android.binaryeye.graphics
import android.graphics.Point
import android.graphics.Rect
import com.google.zxing.Result
import com.google.zxing.ResultPoint
import kotlin.math.roundToInt
fun mapResult(
frameWidth: Int,
frameHeight: Int,
frameOrientation: Int,
viewRect: Rect,
result: Result
): List<Point> = frameToView(
frameWidth,
frameHeight,
frameOrientation,
viewRect
).map(
result.resultPoints
)
fun frameToView(
frameWidth: Int,
frameHeight: Int,
frameOrientation: Int,
viewRect: Rect
) = Mapping(
frameWidth,
frameHeight,
frameOrientation,
viewRect.width().toFloat(),
viewRect.height().toFloat(),
viewRect.left,
viewRect.top
)
fun isPortrait(orientation: Int) = orientation == 90 || orientation == 270
data class Mapping(
val frameWidth: Int,
val frameHeight: Int,
val frameOrientation: Int,
val viewWidth: Float,
val viewHeight: Float,
val offsetX: Int,
val offsetY: Int
) {
fun map(resultPoint: ResultPoint): Point {
val point = Point(
resultPoint.x.roundToInt(),
resultPoint.y.roundToInt()
)
rotate(point)
val w: Int
val h: Int
if (isPortrait(frameOrientation)) {
w = frameHeight
h = frameWidth
} else {
w = frameWidth
h = frameHeight
}
val ratioX = viewWidth / w.toFloat()
val ratioY = viewHeight / h.toFloat()
point.set(
(point.x * ratioX).roundToInt() + offsetX,
(point.y * ratioY).roundToInt() + offsetY
)
return point
}
fun map(points: Array<ResultPoint?>): List<Point> =
// because ZXing apparently returns null in this array sometimes
points.filterNotNull().map { map(it) }
private fun rotate(point: Point) = when (frameOrientation) {
90 -> point.set(frameHeight - point.y, point.x)
180 -> point.set(frameWidth - point.x, frameHeight - point.y)
270 -> point.set(point.y, frameWidth - point.x)
else -> {
}
}
}
@@ -7,24 +7,75 @@ import android.preference.PreferenceManager
class Preferences {
lateinit var preferences: SharedPreferences
var openImmediately = false
var showCropHandle = true
set(value) {
setBoolean(OPEN_IMMEDIATELY, value)
apply(SHOW_CROP_HANDLE, value)
field = value
}
var zoomBySwiping = true
set(value) {
apply(ZOOM_BY_SWIPING, value)
field = value
}
var autoRotate = false
set(value) {
// immediately save this setting before it shouldn't change
// on the fly while scanning
commit(AUTO_ROTATE, value)
field = value
}
var tryHarder = false
set(value) {
// immediately save this setting because it's only ever read
// before the camera is opened
commit(TRY_HARDER, value)
field = value
}
var vibrate = true
set(value) {
apply(VIBRATE, value)
field = value
}
var useHistory = false
set(value) {
setBoolean(USE_HISTORY, value)
apply(USE_HISTORY, value)
field = value
}
var ignoreConsecutiveDuplicates = true
set(value) {
apply(IGNORE_CONSECUTIVE_DUPLICATES, value)
field = value
}
var openImmediately = false
set(value) {
apply(OPEN_IMMEDIATELY, value)
field = value
}
var showMetaData = true
set(value) {
apply(SHOW_META_DATA, value)
field = value
}
var showHexDump = true
set(value) {
apply(SHOW_HEX_DUMP, value)
field = value
}
var openWithUrl: String = ""
set(value) {
setString(OPEN_WITH_URL, value)
apply(OPEN_WITH_URL, value)
field = value
}
var indexOfLastSelectedFormat: Int = 0
set(value) {
setInt(INDEX_OF_LAST_SELECTED_FORMAT, value)
apply(INDEX_OF_LAST_SELECTED_FORMAT, value)
field = value
}
var forceCompat: Boolean = false
set(value) {
// since the app may be about to crash when forceCompat is set,
// it's necessary to `commit()` this synchronously
commit(FORCE_COMPAT, value)
field = value
}
@@ -34,42 +85,64 @@ class Preferences {
}
fun update() {
showCropHandle = preferences.getBoolean(SHOW_CROP_HANDLE, showCropHandle)
zoomBySwiping = preferences.getBoolean(ZOOM_BY_SWIPING, zoomBySwiping)
autoRotate = preferences.getBoolean(AUTO_ROTATE, autoRotate)
tryHarder = preferences.getBoolean(TRY_HARDER, tryHarder)
vibrate = preferences.getBoolean(VIBRATE, vibrate)
useHistory = preferences.getBoolean(USE_HISTORY, useHistory)
ignoreConsecutiveDuplicates = preferences.getBoolean(
IGNORE_CONSECUTIVE_DUPLICATES,
ignoreConsecutiveDuplicates
)
openImmediately = preferences.getBoolean(
OPEN_IMMEDIATELY,
openImmediately
)
useHistory = preferences.getBoolean(USE_HISTORY, useHistory)
showMetaData = preferences.getBoolean(SHOW_META_DATA, showMetaData)
showHexDump = preferences.getBoolean(SHOW_HEX_DUMP, showHexDump)
preferences.getString(OPEN_WITH_URL, openWithUrl)?.also {
openWithUrl = it
}
indexOfLastSelectedFormat = preferences.getInt(
INDEX_OF_LAST_SELECTED_FORMAT,
indexOfLastSelectedFormat
)
preferences.getString(OPEN_WITH_URL, openWithUrl)?.also {
openWithUrl = it
}
forceCompat = preferences.getBoolean(FORCE_COMPAT, forceCompat)
}
private fun setBoolean(label: String, value: Boolean) {
val editor = preferences.edit()
editor.putBoolean(label, value)
editor.apply()
private fun put(label: String, value: Boolean) =
preferences.edit().putBoolean(label, value)
private fun commit(label: String, value: Boolean) {
put(label, value).commit()
}
private fun setString(label: String, value: String) {
val editor = preferences.edit()
editor.putString(label, value)
editor.apply()
private fun apply(label: String, value: Boolean) {
put(label, value).apply()
}
private fun setInt(label: String, value: Int) {
val editor = preferences.edit()
editor.putInt(label, value)
editor.apply()
private fun apply(label: String, value: String) {
preferences.edit().putString(label, value).apply()
}
private fun apply(label: String, value: Int) {
preferences.edit().putInt(label, value).apply()
}
companion object {
const val OPEN_IMMEDIATELY = "open_immediately"
const val SHOW_CROP_HANDLE = "show_crop_handle"
const val ZOOM_BY_SWIPING = "zoom_by_swiping"
const val AUTO_ROTATE = "auto_rotate"
const val TRY_HARDER = "try_harder"
const val VIBRATE = "vibrate"
const val USE_HISTORY = "use_history"
const val IGNORE_CONSECUTIVE_DUPLICATES = "ignore_consecutive_duplicates"
const val OPEN_IMMEDIATELY = "open_immediately"
const val SHOW_HEX_DUMP = "show_hex_dump"
const val SHOW_META_DATA = "show_meta_data"
const val OPEN_WITH_URL = "open_with_url"
const val INDEX_OF_LAST_SELECTED_FORMAT = "index_of_last_selected_format"
const val FORCE_COMPAT = "force_compat"
}
}
@@ -1,128 +0,0 @@
package de.markusfisch.android.binaryeye.repository
import android.content.Context
import android.database.Cursor
import de.markusfisch.android.binaryeye.data.Database
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
class DatabaseRepository {
private val db = Database()
fun open(applicationContext: Context) {
db.open(applicationContext)
}
fun getScan(id: Long): Scan? = getScan(id) { _, cursor ->
val timeIndex = cursor.getColumnIndex(Database.SCANS_DATETIME)
val contentIndex = cursor.getColumnIndex(Database.SCANS_CONTENT)
val rawIndex = cursor.getColumnIndex(Database.SCANS_RAW)
val formatIndex = cursor.getColumnIndex(Database.SCANS_FORMAT)
Scan(
id = id,
timestamp = cursor.getString(timeIndex),
content = cursor.getString(contentIndex),
raw = cursor.getBlob(rawIndex),
format = cursor.getString(formatIndex)
)
}
fun <T> getScan(id: Long, map: (id: Long, Cursor) -> T): T? =
db.getScan(id)?.use { map(id, it.apply { moveToFirst() }) }
fun getScans(): Flow<SimpleScan> = getScans { cursor ->
val idIndex = cursor.getColumnIndex(Database.SCANS_ID)
val timeIndex = cursor.getColumnIndex(Database.SCANS_DATETIME)
val contentIndex = cursor.getColumnIndex(Database.SCANS_CONTENT)
val formatIndex = cursor.getColumnIndex(Database.SCANS_FORMAT)
SimpleScan(
id = cursor.getLong(idIndex),
timestamp = cursor.getString(timeIndex),
content = cursor.getString(contentIndex),
format = cursor.getString(formatIndex)
)
}
fun <T> getScans(map: suspend (Cursor) -> T): Flow<T> = flow {
db.getScans()?.use { results ->
results.asIterable.forEach {
emit(it)
}
}
}.map(map).flowOn(Dispatchers.IO)
fun getScansCursor(): Cursor? = db.getScans()
fun hasBinaryData() = db.hasBinaryData()?.use { it.count > 0 } ?: false
fun insertScan(
timestamp: Long,
content: String,
raw: ByteArray?,
format: String
): Long = db.insertScan(timestamp, content, raw, format)
fun removeScan(id: Long) = db.removeScan(id)
fun removeScans() = db.removeScans()
data class SimpleScan(
val id: Long,
val timestamp: String,
val content: String,
val format: String
)
data class Scan(
val id: Long,
val timestamp: String,
val content: String,
val raw: ByteArray?,
val format: String
) {
// Needed to be overwritten manually, as ByteArray is an array and this isn't handled well by Kotlin
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Scan
if (id != other.id) return false
if (timestamp != other.timestamp) return false
if (content != other.content) return false
if (raw != null) {
if (other.raw == null) return false
if (!raw.contentEquals(other.raw)) return false
} else if (other.raw != null) return false
if (format != other.format) return false
return true
}
// Needs to be overwritten manually, as ByteArray is an array and this
// isn't handled well by Kotlin
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + timestamp.hashCode()
result = 31 * result + content.hashCode()
result = 31 * result + (raw?.contentHashCode() ?: 0)
result = 31 * result + format.hashCode()
return result
}
}
}
private val Cursor.asIterable: Iterable<Cursor>
get() = object : Iterable<Cursor> {
override fun iterator(): Iterator<Cursor> = object : Iterator<Cursor> {
override fun hasNext(): Boolean = position + 1 < count
override fun next(): Cursor = if (moveToNext()) {
this@asIterable
} else {
throw IllegalStateException("You can't access more items, then there are")
}
}
}
@@ -1,15 +1,18 @@
package de.markusfisch.android.binaryeye.rs
import android.content.Context
import android.graphics.Rect
import android.support.v8.renderscript.*
import de.markusfisch.android.binaryeye.renderscript.ScriptC_rotator
import kotlin.math.roundToInt
private const val SCALE_FACTOR = .75f
class Preprocessor(
context: Context,
width: Int,
height: Int,
private val orientation: Int
private val roi: Rect?
) {
var outWidth = 0
var outHeight = 0
@@ -20,6 +23,8 @@ class Preprocessor(
private var yuvType: Type? = null
private var yuvAlloc: Allocation? = null
private var roiType: Type? = null
private var roiAlloc: Allocation? = null
private var resizedType: Type? = null
private var resizedAlloc: Allocation? = null
private var rotatedType: Type? = null
@@ -38,15 +43,38 @@ class Preprocessor(
Allocation.USAGE_SCRIPT
)
val f = .75f
var w = (width * f).roundToInt()
var h = (height * f).roundToInt()
if (roi != null) {
val roiWidth = roi.width()
val roiHeight = roi.height()
roiType = Type.createXY(
rs,
Element.U8(rs),
roiWidth,
roiHeight
)
roiAlloc = Allocation.createTyped(
rs,
roiType,
Allocation.USAGE_SCRIPT
)
outWidth = (roiWidth * SCALE_FACTOR).roundToInt()
outHeight = (roiHeight * SCALE_FACTOR).roundToInt()
// make sure the dimensions are always a multiple of 4
outWidth -= outWidth % 4
outHeight -= outHeight % 4
} else {
outWidth = (width * SCALE_FACTOR).roundToInt()
outHeight = (height * SCALE_FACTOR).roundToInt()
}
resizedType = Type.createXY(
rs,
Element.U8(rs),
w,
h
outWidth,
outHeight
)
resizedAlloc = Allocation.createTyped(
rs,
@@ -54,28 +82,17 @@ class Preprocessor(
Allocation.USAGE_SCRIPT
)
if (orientation == 90 || orientation == 270) {
val tmp = w
w = h
h = tmp
}
outWidth = w
outHeight = h
if (orientation != 0) {
rotatedType = Type.createXY(
rs,
Element.U8(rs),
w,
h
)
rotatedAlloc = Allocation.createTyped(
rs,
rotatedType,
Allocation.USAGE_SCRIPT
)
}
rotatedType = Type.createXY(
rs,
Element.U8(rs),
outHeight,
outWidth
)
rotatedAlloc = Allocation.createTyped(
rs,
rotatedType,
Allocation.USAGE_SCRIPT
)
}
fun destroy() {
@@ -83,6 +100,10 @@ class Preprocessor(
yuvType = null
yuvAlloc?.destroy()
yuvAlloc = null
roiType?.destroy()
roiType = null
roiAlloc?.destroy()
roiAlloc = null
resizedType?.destroy()
resizedType = null
resizedAlloc?.destroy()
@@ -96,34 +117,39 @@ class Preprocessor(
rs.destroy()
}
fun process(frame: ByteArray) {
fun resizeOnly(frame: ByteArray) {
resize(frame)
resizedAlloc?.copyTo(frame)
}
fun resizeAndRotate(frame: ByteArray) {
resize(frame)
val t = resizedType ?: return
rotatorScript._inImage = resizedAlloc
rotatorScript._inWidth = t.x
rotatorScript._inHeight = t.y
rotatorScript.forEach_rotate90(
rotatedAlloc, // ignored in kernel, just to satisfy forEach
rotatedAlloc
)
rotatedAlloc?.copyTo(frame)
}
private fun resize(frame: ByteArray) {
yuvAlloc?.copyFrom(frame)
resizeScript.setInput(yuvAlloc)
resizeScript.forEach_bicubic(resizedAlloc)
val t = resizedType
if (t == null || orientation == 0) {
resizedAlloc?.copyTo(frame)
} else {
rotatorScript._inImage = resizedAlloc
rotatorScript._inWidth = t.x
rotatorScript._inHeight = t.y
when (orientation) {
90 -> rotatorScript.forEach_rotate90(
rotatedAlloc,
rotatedAlloc
)
180 -> rotatorScript.forEach_rotate180(
rotatedAlloc,
rotatedAlloc
)
270 -> rotatorScript.forEach_rotate270(
rotatedAlloc,
rotatedAlloc
resizeScript.setInput(
if (roi != null) {
roiAlloc?.copy2DRangeFrom(
0, 0,
roi.width(), roi.height(),
yuvAlloc,
roi.left, roi.top
)
roiAlloc
} else {
yuvAlloc
}
rotatedAlloc?.copyTo(frame)
}
)
resizeScript.forEach_bicubic(resizedAlloc)
}
}
@@ -2,6 +2,28 @@ package de.markusfisch.android.binaryeye.view
import android.graphics.Rect
import android.view.View
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
suspend inline fun View.useVisibility(
whileExecuting: Int = View.VISIBLE,
otherwise: Int = View.GONE,
crossinline block: suspend () -> Unit
) {
if (visibility == whileExecuting) {
return
}
withContext(Dispatchers.Main) {
visibility = whileExecuting
}
try {
block()
} finally {
withContext(Dispatchers.Main) {
visibility = otherwise
}
}
}
fun View.setPadding(rect: Rect) {
this.setPadding(
@@ -0,0 +1,61 @@
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
private var toolbarHeight = 0
fun recordToolbarHeight(toolbar: Toolbar) {
toolbarHeight = toolbar.layoutParams.height
}
fun View.setPaddingFromWindowInsets() {
doOnApplyWindowInsets { v, insets -> 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()
}
}
private fun insetsWithToolbar(insets: WindowInsetsCompat? = null) = Rect(
insets?.systemWindowInsetLeft ?: 0,
(insets?.systemWindowInsetTop ?: 0) + toolbarHeight,
insets?.systemWindowInsetRight ?: 0,
insets?.systemWindowInsetBottom ?: 0
)
@RequiresApi(Build.VERSION_CODES.KITKAT)
private fun View.requestApplyInsetsWhenAttached() {
if (isAttachedToWindow) {
ViewCompat.requestApplyInsets(this)
} else {
addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {
v.removeOnAttachStateChangeListener(this)
ViewCompat.requestApplyInsets(v)
}
override fun onViewDetachedFromWindow(v: View) = Unit
})
}
}
@@ -23,7 +23,7 @@ class ConfinedScrollView : ScrollView {
) {
super.onLayout(changed, left, top, right, bottom)
if (changed) {
// give Android some time to settle down before running this;
// give Android some time to settle down before running this,
// not putting it on the queue makes it only work sometimes
post {
getChildAt(0)?.also { child ->
@@ -20,7 +20,7 @@ class CropImageView(context: Context, attr: AttributeSet) :
private val dp = context.resources.displayMetrics.density
private val padding: Int = (dp * 24f).roundToInt()
private val onScanRunnable = Runnable {
onScan?.invoke()?.also {
onScan?.invoke()?.let {
candidatePoints = it
invalidate()
}
@@ -1,16 +1,55 @@
package de.markusfisch.android.binaryeye.widget
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Point
import android.graphics.PorterDuff
import android.graphics.*
import android.os.Build
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import de.markusfisch.android.binaryeye.R
import de.markusfisch.android.binaryeye.app.prefs
import de.markusfisch.android.binaryeye.graphics.Candidates
import de.markusfisch.android.binaryeye.graphics.getBitmapFromDrawable
import kotlin.math.abs
import kotlin.math.min
import kotlin.math.round
import kotlin.math.roundToInt
class DetectorView : View {
val roi = Rect()
var updateRoi: (() -> Unit)? = null
private val candidates = Candidates(context)
private val invalidateRunnable: Runnable = Runnable {
marks = null
invalidate()
}
private val roiPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val handleBitmap = resources.getBitmapFromDrawable(
R.drawable.ic_crop_handle
)
private val handleXRadius = handleBitmap.width / 2
private val handleYRadius = handleBitmap.height / 2
private val distToFull: Float
private var marks: List<Point>? = null
private var center = PointF()
private var handlePos = PointF()
private var handleGrabbed = false
private var shadeColor = 0
init {
val dp = context.resources.displayMetrics.density
distToFull = 24f * dp
roiPaint.apply {
style = Paint.Style.STROKE
color = 0xffffffff.toInt()
strokeWidth = 2f * dp
pathEffect = DashPathEffect(floatArrayOf(10f, 20f), 0f)
}
}
constructor(context: Context, attrs: AttributeSet) :
super(context, attrs)
@@ -21,16 +60,123 @@ class DetectorView : View {
fun mark(points: List<Point>) {
marks = points
invalidate()
postDelayed({
marks = null
removeCallbacks(invalidateRunnable)
postDelayed(invalidateRunnable, 500)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent?): Boolean {
event ?: return super.onTouchEvent(event)
return when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
if (prefs.showCropHandle) {
handleGrabbed = abs(event.x - handlePos.x) < handleXRadius &&
abs(event.y - handlePos.y) < handleYRadius
handleGrabbed
} else {
false
}
}
MotionEvent.ACTION_MOVE -> {
if (handleGrabbed) {
handlePos.set(event.x, event.y)
invalidate()
true
} else {
false
}
}
MotionEvent.ACTION_CANCEL -> {
if (handleGrabbed) {
snap(event.x, event.y)
handleGrabbed = false
}
false
}
MotionEvent.ACTION_UP -> {
if (handleGrabbed) {
snap(event.x, event.y)
updateRoi?.invoke()
handleGrabbed = false
}
false
}
else -> super.onTouchEvent(event)
}
}
private fun snap(x: Float, y: Float) {
if (abs(x - center.x) < distToFull) {
handlePos.x = center.x
invalidate()
}, 500)
}
if (abs(y - center.y) < distToFull) {
handlePos.y = center.y
invalidate()
}
}
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
center.set(
(left + (width / 2)).toFloat(),
(top + (height / 2)).toFloat()
)
if (width > height) {
handlePos.set(round(right * .75f), center.y)
} else {
handlePos.set(center.x, round(bottom * .75f))
}
}
override fun onDraw(canvas: Canvas) {
canvas.drawColor(0, PorterDuff.Mode.CLEAR)
updateClipRect()
if (roi.height() > 0 && roi.width() > 0) {
// canvas.clipRect() doesn't work reliably below KITKAT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
canvas.save()
canvas.clipOutRectCompat(roi)
canvas.drawColor(shadeColor, PorterDuff.Mode.SRC)
canvas.restore()
} else {
canvas.drawRect(roi, roiPaint)
}
}
marks?.let {
candidates.draw(canvas, it)
}
if (prefs.showCropHandle) {
canvas.drawBitmap(
handleBitmap,
handlePos.x - handleXRadius,
handlePos.y - handleYRadius,
null
)
}
}
private fun updateClipRect() {
val dx = abs(handlePos.x - center.x)
val dy = abs(handlePos.y - center.y)
val d = min(dx, dy)
shadeColor = (min(1f, d / distToFull) * 128f).toInt() shl 24
roi.set(
(center.x - dx).roundToInt(),
(center.y - dy).roundToInt(),
(center.x + dx).roundToInt(),
(center.y + dy).roundToInt()
)
}
}
private fun Canvas.clipOutRectCompat(rect: Rect) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
clipOutRect(rect)
} else {
@Suppress("DEPRECATION")
clipRect(rect, Region.Op.DIFFERENCE)
}
}
@@ -0,0 +1,16 @@
package de.markusfisch.android.binaryeye.widget
import android.content.Context
import android.widget.Toast
fun Context.toast(message: Int) = Toast.makeText(
this,
message,
Toast.LENGTH_LONG
).show()
fun Context.toast(message: String) = Toast.makeText(
this,
message,
Toast.LENGTH_LONG
).show()
@@ -5,7 +5,8 @@ import com.google.zxing.*
import com.google.zxing.common.HybridBinarizer
import java.util.*
class Zxing {
class Zxing(possibleResultPoint: ResultPointCallback? = null) {
private val hints = EnumMap<DecodeHintType, Any>(DecodeHintType::class.java)
private val multiFormatReader = MultiFormatReader()
init {
@@ -35,11 +36,18 @@ class Zxing {
)
)
)
val hints = EnumMap<DecodeHintType, Any>(DecodeHintType::class.java)
hints[DecodeHintType.POSSIBLE_FORMATS] = decodeFormats
possibleResultPoint?.let {
hints[DecodeHintType.NEED_RESULT_POINT_CALLBACK] = it
}
}
multiFormatReader.setHints(hints)
fun updateHints(tryHarder: Boolean) {
if (tryHarder) {
hints[DecodeHintType.TRY_HARDER] = java.lang.Boolean.TRUE
} else {
hints.remove(DecodeHintType.TRY_HARDER)
}
}
fun decode(
@@ -61,21 +69,15 @@ class Zxing {
return decodeLuminanceSource(source, invert)
}
fun decodePositiveNegative(bitmap: Bitmap): Result? {
val result = decode(bitmap, false)
return if (result != null) {
result
} else {
decode(bitmap, true)
}
}
fun decodePositiveNegative(bitmap: Bitmap): Result? =
decode(bitmap, false) ?: decode(bitmap, true)
fun decode(bitmap: Bitmap, invert: Boolean = false): Result? {
private fun decode(bitmap: Bitmap, invert: Boolean = false): Result? {
val pixels = IntArray(bitmap.width * bitmap.height)
return decode(pixels, bitmap, invert)
}
fun decode(
private fun decode(
pixels: IntArray,
bitmap: Bitmap,
invert: Boolean = false
@@ -109,7 +111,7 @@ class Zxing {
private fun decodeLuminanceSource(source: LuminanceSource): Result? {
val bitmap = BinaryBitmap(HybridBinarizer(source))
return try {
multiFormatReader.decodeWithState(bitmap)
multiFormatReader.decode(bitmap, hints)
} catch (e: ReaderException) {
null
} finally {
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 899 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

@@ -1,5 +0,0 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2L18,7L6,7v12zM8.46,11.88l1.41,-1.41L12,12.59l2.12,-2.12 1.41,1.41L13.41,14l2.12,2.12 -1.41,1.41L12,15.41l-2.12,2.12 -1.41,-1.41L10.59,14l-2.13,-2.12zM15.5,4l-1,-1h-5l-1,1L5,4v2h14L19,4z"/>
</vector>
+2 -8
View File
@@ -1,9 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M16,1L4,1c-1.1,0 -2,0.9 -2,2v14h2L4,3h12L16,1zM19,5L8,5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h11c1.1,0 2,-0.9 2,-2L21,7c0,-1.1 -0.9,-2 -2,-2zM19,21L8,21L8,7h11v14z"/>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24.0" android:viewportHeight="24.0">
<path android:fillColor="#FFFFFFFF" android:pathData="M16 1H4C2.9 1 2 1.9 2 3v14h2V3h12V1zm3 4H8C6.9 5 6 5.9 6 7v14c0 1.1 0.9 2 2 2h11c1.1 0 2-0.9 2-2V7c0-1.1-0.9-2-2-2zm0 16H8V7h11v14z"/>
</vector>
@@ -1,9 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24.0" android:viewportHeight="24.0">
<path android:fillColor="#FFFFFFFF" android:pathData="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</vector>
@@ -0,0 +1,3 @@
<vector android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c0.39-0.39 0.39-1.02 0-1.41l-2.34-2.34c-0.39-0.39-1.02-0.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/>
</vector>
@@ -0,0 +1,3 @@
<vector android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M9 3L5 6.99h3V14h2V6.99h3L9 3zm7 14.01V10h-2v7.01h-3L15 21l4-3.99h-3z"/>
</vector>
@@ -1,9 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M7,2v11h3v9l7,-12h-4l4,-8z"/>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24.0" android:viewportHeight="24.0">
<path android:fillColor="#FFFFFFFF" android:pathData="M7 2v11h3v9l7-12h-4l4-8z"/>
</vector>
@@ -1,5 +1,3 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M13,3c-4.97,0 -9,4.03 -9,9L1,12l3.89,3.89 0.07,0.14L9,12L6,12c0,-3.87 3.13,-7 7,-7s7,3.13 7,7 -3.13,7 -7,7c-1.93,0 -3.68,-0.79 -4.94,-2.06l-1.42,1.42C8.27,19.99 10.51,21 13,21c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,8v5l4.28,2.54 0.72,-1.21 -3.5,-2.08L13.5,8L12,8z"/>
<vector android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89 0.07 0.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-0.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.28 2.54 0.72-1.21-3.5-2.08V8H12z"/>
</vector>
+2 -8
View File
@@ -1,9 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-6h2v6zM13,9h-2L11,7h2v2z"/>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24.0" android:viewportHeight="24.0">
<path android:fillColor="#FFFFFFFF" android:pathData="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/>
</vector>
+2 -5
View File
@@ -1,6 +1,3 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000"
android:pathData="M20,4L4,4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,6c0,-1.1 -0.9,-2 -2,-2zM20,18L4,18L4,8l8,5 8,-5v10zM12,11L4,6h16l-8,5z"/>
<vector android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M20 4H4C2.9 4 2.01 4.9 2.01 6L2 18c0 1.1 0.9 2 2 2h16c1.1 0 2-0.9 2-2V6c0-1.1-0.9-2-2-2zm0 14H4V8l8 5 8-5v10zm-8-7L4 6h16l-8 5z"/>
</vector>
+2 -4
View File
@@ -1,5 +1,3 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M10,6L8.59,7.41 13.17,12l-4.58,4.59L10,18l6,-6z"/>
<vector android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
</vector>
+2 -8
View File
@@ -1,9 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M19,4L5,4c-1.11,0 -2,0.9 -2,2v12c0,1.1 0.89,2 2,2h4v-2L5,18L5,8h14v10h-4v2h4c1.1,0 2,-0.9 2,-2L21,6c0,-1.1 -0.89,-2 -2,-2zM12,10l-4,4h3v6h2v-6h3l-4,-4z"/>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24.0" android:viewportHeight="24.0">
<path android:fillColor="#FFFFFFFF" android:pathData="M19 4H5C3.89 4 3 4.9 3 6v12c0 1.1 0.89 2 2 2h4v-2H5V8h14v10h-4v2h4c1.1 0 2-0.9 2-2V6c0-1.1-0.89-2-2-2zm-7 6l-4 4h3v6h2v-6h3l-4-4z"/>
</vector>
@@ -1,9 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:tint="#FFFFFF"
android:viewportHeight="24.0"
android:viewportWidth="24.0"
android:width="24dp">
<path android:fillColor="#FF000000"
android:pathData="M14,10L2,10v2h12v-2zM14,6L2,6v2h12L14,6zM18,14v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zM2,16h8v-2L2,14v2z"/>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp">
<path android:fillColor="#FF000000" android:pathData="M14 10H2v2h12v-2zm0-4H2v2h12V6zm4 8v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zM2 16h8v-2H2v2z"/>
</vector>
@@ -1,5 +1,3 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M6,2c-1.1,0 -1.99,0.9 -1.99,2L4,20c0,1.1 0.89,2 1.99,2L18,22c1.1,0 2,-0.9 2,-2L20,8l-6,-6L6,2zM13,9L13,3.5L18.5,9L13,9z"/>
<vector android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M6 2C4.9 2 4.01 2.9 4.01 4L4 20c0 1.1 0.89 2 1.99 2H18c1.1 0 2-0.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/>
</vector>
@@ -1,5 +1,3 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M19.1,12.9a2.8,2.8 0,0 0,0.1 -0.9,2.8 2.8,0 0,0 -0.1,-0.9l2.1,-1.6a0.7,0.7 0,0 0,0.1 -0.6L19.4,5.5a0.7,0.7 0,0 0,-0.6 -0.2l-2.4,1a6.5,6.5 0,0 0,-1.6 -0.9l-0.4,-2.6a0.5,0.5 0,0 0,-0.5 -0.4H10.1a0.5,0.5 0,0 0,-0.5 0.4L9.3,5.4a5.6,5.6 0,0 0,-1.7 0.9l-2.4,-1a0.4,0.4 0,0 0,-0.5 0.2l-2,3.4c-0.1,0.2 0,0.4 0.2,0.6l2,1.6a2.8,2.8 0,0 0,-0.1 0.9,2.8 2.8,0 0,0 0.1,0.9L2.8,14.5a0.7,0.7 0,0 0,-0.1 0.6l1.9,3.4a0.7,0.7 0,0 0,0.6 0.2l2.4,-1a6.5,6.5 0,0 0,1.6 0.9l0.4,2.6a0.5,0.5 0,0 0,0.5 0.4h3.8a0.5,0.5 0,0 0,0.5 -0.4l0.3,-2.6a5.6,5.6 0,0 0,1.7 -0.9l2.4,1a0.4,0.4 0,0 0,0.5 -0.2l2,-3.4c0.1,-0.2 0,-0.4 -0.2,-0.6ZM12,15.6A3.6,3.6 0,1 1,15.6 12,3.6 3.6,0 0,1 12,15.6Z"/>
<vector android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M19.1 12.9a2.8 2.8 0 0 0 0.1-0.9 2.8 2.8 0 0 0-0.1-0.9l2.1-1.6a0.7 0.7 0 0 0 0.1-0.6l-1.9-3.4a0.7 0.7 0 0 0-0.6-0.2l-2.4 1a6.5 6.5 0 0 0-1.6-0.9l-0.4-2.6a0.5 0.5 0 0 0-0.5-0.4h-3.8a0.5 0.5 0 0 0-0.5 0.4L9.3 5.4a5.6 5.6 0 0 0-1.7 0.9l-2.4-1a0.4 0.4 0 0 0-0.5 0.2l-2 3.4c-0.1 0.2 0 0.4 0.2 0.6l2 1.6A2.8 2.8 0 0 0 4.8 12a2.8 2.8 0 0 0 0.1 0.9l-2.1 1.6a0.7 0.7 0 0 0-0.1 0.6l1.9 3.4a0.7 0.7 0 0 0 0.6 0.2l2.4-1a6.5 6.5 0 0 0 1.6 0.9l0.4 2.6a0.5 0.5 0 0 0 0.5 0.4h3.8a0.5 0.5 0 0 0 0.5-0.4l0.3-2.6a5.6 5.6 0 0 0 1.7-0.9l2.4 1a0.4 0.4 0 0 0 0.5-0.2l2-3.4c0.1-0.2 0-0.4-0.2-0.6zM12 15.6a3.6 3.6 0 1 1 3.6-3.6 3.6 3.6 0 0 1-3.6 3.6z"/>
</vector>
@@ -1,5 +1,3 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM17,13L7,13v-2h10v2z"/>
<vector android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M6 19c0 1.1 0.9 2 2 2h8c1.1 0 2-0.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
</vector>
@@ -1,5 +1,3 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M15.55,5.55L11,1v3.07C7.06,4.56 4,7.92 4,12s3.05,7.44 7,7.93v-2.02c-2.84,-0.48 -5,-2.94 -5,-5.91s2.16,-5.43 5,-5.91L11,10l4.55,-4.45zM19.93,11c-0.17,-1.39 -0.72,-2.73 -1.62,-3.89l-1.42,1.42c0.54,0.75 0.88,1.6 1.02,2.47h2.02zM13,17.9v2.02c1.39,-0.17 2.74,-0.71 3.9,-1.61l-1.44,-1.44c-0.75,0.54 -1.59,0.89 -2.46,1.03zM16.89,15.48l1.42,1.41c0.9,-1.16 1.45,-2.5 1.62,-3.89h-2.02c-0.14,0.87 -0.48,1.72 -1.02,2.48z"/>
<vector android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M15.55 5.55L11 1v3.07C7.06 4.56 4 7.92 4 12s3.05 7.44 7 7.93v-2.02C8.16 17.43 6 14.97 6 12s2.16-5.43 5-5.91V10l4.55-4.45zM19.93 11c-0.17-1.39-0.72-2.73-1.62-3.89l-1.42 1.42c0.54 0.75 0.88 1.6 1.02 2.47h2.02zM13 17.9v2.02c1.39-0.17 2.74-0.71 3.9-1.61l-1.44-1.44c-0.75 0.54-1.59 0.89-2.46 1.03zm3.89-2.42l1.42 1.41c0.9-1.16 1.45-2.5 1.62-3.89h-2.02c-0.14 0.87-0.48 1.72-1.02 2.48z"/>
</vector>
+2 -4
View File
@@ -1,5 +1,3 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M17,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2L21,7l-4,-4zM12,19c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3zM15,9L5,9L5,5h10v4z"/>
<vector android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M17 3H5C3.89 3 3 3.9 3 5v14c0 1.1 0.89 2 2 2h14c1.1 0 2-0.9 2-2V7l-4-4zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm3-10H5V5h10v4z"/>
</vector>
+2 -4
View File
@@ -1,5 +1,3 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z"/>
<vector android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M15.5 14h-0.79l-0.28-0.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-0.59 4.23-1.57L14 14.71v0.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
</vector>
@@ -1,6 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp"
android:tint="#FFFFFF" android:viewportHeight="24.0"
android:viewportWidth="24.0" android:width="24dp">
<path android:fillColor="#FF000000"
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z"/>
<vector android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M15.5 14h-0.79l-0.28-0.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-0.59 4.23-1.57L14 14.71v0.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
</vector>
@@ -1,9 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92 1.61,0 2.92,-1.31 2.92,-2.92s-1.31,-2.92 -2.92,-2.92z"/>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24.0" android:viewportHeight="24.0">
<path android:fillColor="#FFFFFFFF" android:pathData="M18 16.08c-0.76 0-1.44 0.3-1.96 0.77L8.91 12.7C8.96 12.47 9 12.24 9 12s-0.04-0.47-0.09-0.7l7.05-4.11C16.5 7.69 17.21 8 18 8c1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 0.24 0.04 0.47 0.09 0.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c0.79 0 1.5-0.31 2.04-0.81l7.12 4.16c-0.05 0.21-0.08 0.43-0.08 0.65 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z"/>
</vector>
+2 -5
View File
@@ -1,6 +1,3 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000"
android:pathData="M20,2L4,2c-1.1,0 -1.99,0.9 -1.99,2L2,22l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM9,11L7,11L7,9h2v2zM13,11h-2L11,9h2v2zM17,11h-2L15,9h2v2z"/>
<vector android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M20 2H4C2.9 2 2.01 2.9 2.01 4L2 22l4-4h14c1.1 0 2-0.9 2-2V4c0-1.1-0.9-2-2-2zM9 11H7V9h2v2zm4 0h-2V9h2v2zm4 0h-2V9h2v2z"/>
</vector>
@@ -1,5 +1,3 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M20,4h-3.17L15,2L9,2L7.17,4L4,4c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,6c0,-1.1 -0.9,-2 -2,-2zM15,15.5L15,13L9,13v2.5L5.5,12 9,8.5L9,11h6L15,8.5l3.5,3.5 -3.5,3.5z"/>
<vector android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M20 4h-3.17L15 2H9L7.17 4H4C2.9 4 2 4.9 2 6v12c0 1.1 0.9 2 2 2h16c1.1 0 2-0.9 2-2V6c0-1.1-0.9-2-2-2zm-5 11.5V13H9v2.5L5.5 12 9 8.5V11h6V8.5l3.5 3.5-3.5 3.5z"/>
</vector>
+2 -5
View File
@@ -1,6 +1,3 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000"
android:pathData="M6.62,10.79c1.44,2.83 3.76,5.14 6.59,6.59l2.2,-2.2c0.27,-0.27 0.67,-0.36 1.02,-0.24 1.12,0.37 2.33,0.57 3.57,0.57 0.55,0 1,0.45 1,1V20c0,0.55 -0.45,1 -1,1 -9.39,0 -17,-7.61 -17,-17 0,-0.55 0.45,-1 1,-1h3.5c0.55,0 1,0.45 1,1 0,1.25 0.2,2.45 0.57,3.57 0.11,0.35 0.03,0.74 -0.25,1.02l-2.2,2.2z"/>
<vector android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c0.27-0.27 0.67-0.36 1.02-0.24 1.12 0.37 2.33 0.57 3.57 0.57 0.55 0 1 0.45 1 1V20c0 0.55-0.45 1-1 1-9.39 0-17-7.61-17-17 0-0.55 0.45-1 1-1h3.5c0.55 0 1 0.45 1 1 0 1.25 0.2 2.45 0.57 3.57 0.11 0.35 0.03 0.74-0.25 1.02l-2.2 2.2z"/>
</vector>
@@ -1,6 +1,3 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000"
android:pathData="M19,3h-1L18,1h-2v2L8,3L8,1L6,1v2L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2L21,5c0,-1.1 -0.9,-2 -2,-2zM12,6c1.66,0 3,1.34 3,3s-1.34,3 -3,3 -3,-1.34 -3,-3 1.34,-3 3,-3zM18,18L6,18v-1c0,-2 4,-3.1 6,-3.1s6,1.1 6,3.1v1z"/>
<vector android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24.0" android:viewportWidth="24.0" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M19 3h-1V1h-2v2H8V1H6v2H5C3.89 3 3 3.9 3 5v14c0 1.1 0.89 2 2 2h14c1.1 0 2-0.9 2-2V5c0-1.1-0.9-2-2-2zm-7 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm6 12H6v-1c0-2 4-3.1 6-3.1s6 1.1 6 3.1v1z"/>
</vector>

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