From 305b0f550b8861120a9b14cdfbdbe689e67e5b41 Mon Sep 17 00:00:00 2001 From: serge Date: Fri, 2 Oct 2026 19:49:39 +0800 Subject: [PATCH] Take photos from the camera as attachments "Take photo" under the attachments of the entry editor starts the camera app; the shot goes to a non-exported provider in the cache. A new screen crops it by a frame, turns it by quarters and stores it at one of five sizes, each showing the bytes it takes for this crop; the choice is kept. The full-size shot is deleted, nothing leaves the app. --- src/keepass2android-app/EntryEditActivity.cs | 60 ++++ src/keepass2android-app/PhotoCapture.cs | 178 ++++++++++ src/keepass2android-app/PhotoEditActivity.cs | 328 ++++++++++++++++++ .../Resources/values-ru/strings.xml | 10 + .../Resources/values/strings.xml | 10 + src/keepass2android-app/intents/Intents.cs | 1 + 6 files changed, 587 insertions(+) create mode 100644 src/keepass2android-app/PhotoCapture.cs create mode 100644 src/keepass2android-app/PhotoEditActivity.cs diff --git a/src/keepass2android-app/EntryEditActivity.cs b/src/keepass2android-app/EntryEditActivity.cs index 9aba9ae6b..22b9bd685 100644 --- a/src/keepass2android-app/EntryEditActivity.cs +++ b/src/keepass2android-app/EntryEditActivity.cs @@ -1275,6 +1275,46 @@ void AddBinary(Uri filename, bool overwrite) State.EntryModified = true; PopulateBinaries(); } + const int RequestCodeCameraPermission = 37326; + const int RequestCodePhotoEdit = 37327; + + void StartPhoto() + { + try + { + if (!PhotoCapture.Start(this, Intents.RequestCodePhotoForBinary)) + App.Kp2a.ShowMessage(this, GetString(Resource.String.no_camera_app), MessageSeverity.Error); + } + catch (Java.Lang.SecurityException) + { + // the app holds the camera permission in its manifest (a library may add it): then the camera app may be started + // only once the user has granted it + RequestPermissions(new[] { Android.Manifest.Permission.Camera }, RequestCodeCameraPermission); + } + } + + void AddPhoto() + { + string result = PhotoCapture.ResultPath(this); + byte[] photo; + try + { + photo = File.ReadAllBytes(result); + } + catch (Exception ex) + { + App.Kp2a.ShowMessage(this, GetString(Resource.String.AttachFailed) + " " + Util.GetErrorMessage(ex), MessageSeverity.Error); + return; + } + finally + { + File.Delete(result); + } + State.Entry.Binaries.Set(PhotoCapture.Name(), new ProtectedBinary(false, photo)); + State.EntryModified = true; + PopulateBinaries(); + } + public static byte[] ReadFully(Stream input) { byte[] buffer = new byte[16 * 1024]; @@ -1406,6 +1446,16 @@ protected override void OnActivityResult(int requestCode, Result resultCode, Int return; case Result.Ok: + if (requestCode == Intents.RequestCodePhotoForBinary) + { + StartActivityForResult(new Intent(this, typeof(PhotoEditActivity)), RequestCodePhotoEdit); + return; + } + if (requestCode == RequestCodePhotoEdit) + { + AddPhoto(); + return; + } if (requestCode == Intents.RequestCodeFileBrowseForBinary) { Uri uri = data.Data; @@ -1423,6 +1473,8 @@ protected override void OnActivityResult(int requestCode, Result resultCode, Int } return; case Result.Canceled: + if (requestCode == Intents.RequestCodePhotoForBinary) + PhotoCapture.DeleteShot(this); return; } @@ -1492,6 +1544,12 @@ void PopulateBinaries() binariesGroup.AddView(addBinaryButton, layoutParams); + Button addPhotoButton = (Button)LayoutInflater.Inflate(Resource.Layout.EntryEditButtonAdd, null); + addPhotoButton.Text = GetString(Resource.String.add_photo); + addPhotoButton.Enabled = addBinaryButton.Enabled; + addPhotoButton.Click += (sender, e) => StartPhoto(); + binariesGroup.AddView(addPhotoButton, layoutParams); + var binariesLabel = FindViewById(Resource.Id.entry_binaries_label); if (binariesLabel != null) binariesLabel.Visibility = State.Entry.Binaries.UCount > 0 ? ViewStates.Visible : ViewStates.Gone; @@ -1625,6 +1683,8 @@ public string[] AdditionalKeys public override void OnRequestPermissionsResult(int requestCode, string[] permissions, [GeneratedEnum] Android.Content.PM.Permission[] grantResults) { base.OnRequestPermissionsResult(requestCode, permissions, grantResults); + if (requestCode == RequestCodeCameraPermission && grantResults.Length > 0 && grantResults[0] == Android.Content.PM.Permission.Granted) + StartPhoto(); } diff --git a/src/keepass2android-app/PhotoCapture.cs b/src/keepass2android-app/PhotoCapture.cs new file mode 100644 index 000000000..6dea23fa7 --- /dev/null +++ b/src/keepass2android-app/PhotoCapture.cs @@ -0,0 +1,178 @@ +// This file is part of Keepass2Android. +// +// Keepass2Android is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Keepass2Android is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Keepass2Android. If not, see . + +using System; +using System.IO; +using Android.App; +using Android.Content; +using Android.Graphics; +using Android.Media; +using Android.OS; +using Android.Provider; +using Uri = Android.Net.Uri; + +namespace keepass2android +{ + /// + /// A photo from the camera as an attachment of an entry. The camera app writes the full-size shot into one file of the + /// cache through ; crops, turns and shrinks it to the + /// size the user picks, and the shot is deleted, so it leaves no trace outside the database. + /// + public static class PhotoCapture + { + /// The long side the shot is loaded with for editing: the largest size offered is not above it. + public const int WorkEdge = 2048; + + const string SubDir = "PhotoCapture"; + const string ShotName = "shot.jpg"; + const string ResultName = "result.jpg"; + + static Java.IO.File InDir(Context ctx, string name) + { + var dir = new Java.IO.File(ctx.CacheDir, SubDir); + dir.Mkdirs(); + return new Java.IO.File(dir, name); + } + + static Java.IO.File ShotFile(Context ctx) => InDir(ctx, ShotName); + + public static string ShotPath(Context ctx) => ShotFile(ctx).AbsolutePath; + + /// Where the edited photo goes back to the entry editor. + public static string ResultPath(Context ctx) => InDir(ctx, ResultName).AbsolutePath; + + /// Starts the camera app for one shot; false - there is no camera app. + public static bool Start(Activity activity, int requestCode) + { + var shot = ShotFile(activity); + shot.Delete(); + var uri = Uri.Parse("content://" + PhotoCaptureProvider.Authority + "/" + ShotName); + var intent = new Intent(MediaStore.ActionImageCapture); + intent.PutExtra(MediaStore.ExtraOutput, uri); + intent.ClipData = ClipData.NewRawUri("", uri); + intent.AddFlags(ActivityFlags.GrantWriteUriPermission | ActivityFlags.GrantReadUriPermission); + try + { + activity.StartActivityForResult(intent, requestCode); + return true; + } + catch (ActivityNotFoundException) + { + return false; + } + } + + /// The shot upright, the long side at most ; null - no shot. + public static Bitmap LoadUpright(string path, int maxEdge) + { + if (!File.Exists(path)) + return null; + var bounds = new BitmapFactory.Options { InJustDecodeBounds = true }; + BitmapFactory.DecodeFile(path, bounds); + int longSide = Math.Max(bounds.OutWidth, bounds.OutHeight); + if (longSide <= 0) + return null; + // the power of two that still leaves at least maxEdge: decoding at full size would take tens of megabytes + int sample = 1; + while (longSide / (sample * 2) >= maxEdge) + sample *= 2; + Bitmap b = BitmapFactory.DecodeFile(path, new BitmapFactory.Options { InSampleSize = sample }); + if (b == null) + return null; + + float k = (float)maxEdge / Math.Max(b.Width, b.Height); + var m = new Matrix(); + if (k < 1f) + m.PostScale(k, k); + int turn = Rotation(path); + if (turn != 0) + m.PostRotate(turn); + if (m.IsIdentity) + return b; + Bitmap t = Bitmap.CreateBitmap(b, 0, 0, b.Width, b.Height, m, true); + if (t != b) + b.Recycle(); + return t; + } + + /// The part of , its long side at most , as JPEG. + public static byte[] Encode(Bitmap src, Rect crop, int edge, int quality) + { + float k = Math.Min(1f, (float)edge / Math.Max(crop.Width(), crop.Height())); + var m = new Matrix(); + m.PostScale(k, k); + Bitmap part = Bitmap.CreateBitmap(src, crop.Left, crop.Top, crop.Width(), crop.Height(), m, true); + using var ms = new MemoryStream(); + part.Compress(Bitmap.CompressFormat.Jpeg, quality, ms); + if (part != src) + part.Recycle(); + return ms.ToArray(); + } + + public static void DeleteShot(Context ctx) => ShotFile(ctx).Delete(); + + /// The name of a new photo: when it was taken. + public static string Name() => "photo_" + DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss") + ".jpg"; + + static int Rotation(string path) + { + try + { + switch (new ExifInterface(path).GetAttributeInt(ExifInterface.TagOrientation, 1)) + { + case 6: return 90; + case 3: return 180; + case 8: return 270; + } + } + catch (Exception e) + { + Kp2aLog.Log(e.ToString()); + } + return 0; + } + } + + /// + /// The one file the camera app writes a shot into: not exported, reachable only by the app the write permission of + /// is granted to. + /// + [ContentProvider(new[] { "keepass2android." + AppNames.PackagePart + ".photocapture" }, Exported = false, GrantUriPermissions = true)] + public class PhotoCaptureProvider : ContentProvider + { + public const string Authority = "keepass2android." + AppNames.PackagePart + ".photocapture"; + + public override bool OnCreate() => true; + + public override ParcelFileDescriptor OpenFile(Uri uri, string mode) + { + if (uri.LastPathSegment != "shot.jpg") + throw new Java.IO.FileNotFoundException("Unsupported uri: " + uri); + var dir = new Java.IO.File(Context.CacheDir, "PhotoCapture"); + dir.Mkdirs(); + var file = new Java.IO.File(dir, "shot.jpg"); + var fileMode = mode != null && mode.Contains("w") + ? ParcelFileMode.WriteOnly | ParcelFileMode.Create | ParcelFileMode.Truncate + : ParcelFileMode.ReadOnly; + return ParcelFileDescriptor.Open(file, fileMode); + } + + public override string GetType(Uri uri) => "image/jpeg"; + public override Android.Database.ICursor Query(Uri uri, string[] projection, string selection, string[] selectionArgs, string sortOrder) => null; + public override Uri Insert(Uri uri, ContentValues values) => null; + public override int Delete(Uri uri, string selection, string[] selectionArgs) => 0; + public override int Update(Uri uri, ContentValues values, string selection, string[] selectionArgs) => 0; + } +} diff --git a/src/keepass2android-app/PhotoEditActivity.cs b/src/keepass2android-app/PhotoEditActivity.cs new file mode 100644 index 000000000..b0b4e6e37 --- /dev/null +++ b/src/keepass2android-app/PhotoEditActivity.cs @@ -0,0 +1,328 @@ +// This file is part of Keepass2Android. +// +// Keepass2Android is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Keepass2Android is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Keepass2Android. If not, see . + +using System; +using System.IO; +using System.Threading.Tasks; +using Android.App; +using Android.Content; +using Android.Content.PM; +using Android.Graphics; +using Android.OS; +using Android.Preferences; +using Android.Views; +using Android.Widget; + +namespace keepass2android +{ + /// + /// The shot of the camera before it goes into the entry: cropped by a frame, turned by quarters, and stored at one of + /// - each shows how many bytes it takes in the database, for this very crop. The choice is kept + /// for the next shot. The photo never leaves the app: the shot is read from the cache and deleted when done. + /// + [Activity(Label = "@string/app_name", ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.Keyboard | ConfigChanges.KeyboardHidden, + Theme = "@style/Kp2aTheme_ActionBar")] + public class PhotoEditActivity : LockCloseActivity + { + /// The sizes offered: the long side in pixels and the JPEG quality. + static readonly (int Name, int Edge, int Quality)[] Sizes = + { + (Resource.String.photo_size_tiny, 320, 40), + (Resource.String.photo_size_small, 480, 50), + (Resource.String.photo_size_medium, 800, 60), + (Resource.String.photo_size_large, 1280, 70), + (Resource.String.photo_size_full, PhotoCapture.WorkEdge, 85), + }; + + const string PrefSize = "photo_size"; + + CropView _crop; + RadioButton[] _choices; + Bitmap _photo; + /// Counts the edits: sizes measured for an older crop are not shown. + int _generation; + + protected override void OnCreate(Bundle savedInstanceState) + { + base.OnCreate(savedInstanceState); + SupportActionBar.Title = GetString(Resource.String.photo_edit_title); + + _photo = PhotoCapture.LoadUpright(PhotoCapture.ShotPath(this), PhotoCapture.WorkEdge); + if (_photo == null) + { + Finish(); + return; + } + + float dp = Resources.DisplayMetrics.Density; + var root = new LinearLayout(this) { Orientation = Orientation.Vertical }; + + _crop = new CropView(this) { Changed = Measure }; + _crop.SetPhoto(_photo); + root.AddView(_crop, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MatchParent, 0, 1f)); + + var tools = new LinearLayout(this) { Orientation = Orientation.Horizontal }; + tools.AddView(ToolButton("↺", () => Turn(false)), new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WrapContent, 1f)); + tools.AddView(ToolButton("↻", () => Turn(true)), new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WrapContent, 1f)); + tools.AddView(ToolButton(GetString(Resource.String.photo_reset), () => { _crop.SetPhoto(_photo); Measure(); }), + new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WrapContent, 1f)); + root.AddView(tools); + + var group = new RadioGroup(this) { Orientation = Orientation.Vertical }; + group.SetPadding((int)(16 * dp), 0, (int)(16 * dp), 0); + _choices = new RadioButton[Sizes.Length]; + int chosen = Math.Clamp(PreferenceManager.GetDefaultSharedPreferences(this).GetInt(PrefSize, 1), 0, Sizes.Length - 1); + for (int i = 0; i < Sizes.Length; i++) + { + _choices[i] = new RadioButton(this) { Id = View.GenerateViewId() }; + group.AddView(_choices[i]); + } + _choices[chosen].Checked = true; + root.AddView(group); + + var actions = new LinearLayout(this) { Orientation = Orientation.Horizontal }; + actions.AddView(ToolButton(GetString(Android.Resource.String.Cancel), Cancel), new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WrapContent, 1f)); + actions.AddView(ToolButton(GetString(Resource.String.photo_save), Save), new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WrapContent, 1f)); + root.AddView(actions); + + // edge to edge since Android 15: keep the buttons above the navigation bar + root.SetOnApplyWindowInsetsListener(new BottomInset()); + SetContentView(root); + Measure(); + } + + Button ToolButton(string text, Action click) + { + var b = new Button(this) { Text = text }; + b.SetAllCaps(false); + b.Click += (s, e) => click(); + return b; + } + + void Turn(bool clockwise) + { + var m = new Matrix(); + m.PostRotate(clockwise ? 90 : 270); + Rect crop = _crop.Crop; + int w = _photo.Width; + _photo = Bitmap.CreateBitmap(_photo, 0, 0, _photo.Width, _photo.Height, m, true); + // the frame turns with the photo + var turned = clockwise + ? new Rect(_photo.Width - crop.Bottom, crop.Left, _photo.Width - crop.Top, crop.Right) + : new Rect(crop.Top, w - crop.Right, crop.Bottom, w - crop.Left); + _crop.SetPhoto(_photo, turned); + Measure(); + } + + /// The bytes of each size for the current crop, measured off the main thread. + void Measure() + { + int generation = ++_generation; + Bitmap photo = _photo; + Rect crop = new Rect(_crop.Crop); + int longSide = Math.Max(crop.Width(), crop.Height()); + for (int i = 0; i < Sizes.Length; i++) + _choices[i].Text = Label(i, Math.Min(Sizes[i].Edge, longSide), null); + Task.Run(() => + { + for (int i = 0; i < Sizes.Length && generation == _generation; i++) + { + int bytes = PhotoCapture.Encode(photo, crop, Sizes[i].Edge, Sizes[i].Quality).Length; + int index = i; + RunOnUiThread(() => + { + if (generation == _generation) + _choices[index].Text = Label(index, Math.Min(Sizes[index].Edge, longSide), bytes); + }); + } + }); + } + + string Label(int i, int edge, int? bytes) + { + string size = bytes == null ? "…" : bytes < 1024 * 1024 ? $"{(bytes.Value + 512) / 1024} KB" : $"{bytes.Value / (1024f * 1024f):0.0} MB"; + return $"{GetString(Sizes[i].Name)} · {edge} px · {size}"; + } + + int Chosen() + { + for (int i = 0; i < _choices.Length; i++) + if (_choices[i].Checked) + return i; + return 0; + } + + void Save() + { + int i = Chosen(); + PreferenceManager.GetDefaultSharedPreferences(this).Edit().PutInt(PrefSize, i).Apply(); + File.WriteAllBytes(PhotoCapture.ResultPath(this), PhotoCapture.Encode(_photo, _crop.Crop, Sizes[i].Edge, Sizes[i].Quality)); + PhotoCapture.DeleteShot(this); + SetResult(Result.Ok); + Finish(); + } + + void Cancel() + { + PhotoCapture.DeleteShot(this); + SetResult(Result.Canceled); + Finish(); + } + + public override void OnBackPressed() => Cancel(); + + class BottomInset : Java.Lang.Object, View.IOnApplyWindowInsetsListener + { + public WindowInsets OnApplyWindowInsets(View v, WindowInsets insets) + { + if (Build.VERSION.SdkInt >= BuildVersionCodes.R) + v.SetPadding(0, 0, 0, insets.GetInsets(WindowInsets.Type.SystemBars()).Bottom); + return insets; + } + } + } + + /// + /// The photo fitted into the view with a frame over it: a corner or a side is dragged to crop, the inside to move the + /// frame. The part outside the frame is dimmed. is called when a drag ends. + /// + public class CropView : View + { + public Action Changed; + + Bitmap _photo; + /// The frame in pixels of the photo. + Rect _crop = new Rect(); + float _scale, _ox, _oy; + + // what the finger holds: the sides of the frame it moves + bool _left, _top, _right, _bottom, _all; + float _lastX, _lastY; + + readonly Paint _dim = new Paint { Color = Color.Argb(150, 0, 0, 0) }; + readonly Paint _frame = new Paint { Color = Color.White, StrokeWidth = 2 }; + readonly Paint _handle = new Paint { Color = Color.White, StrokeWidth = 6 }; + readonly float _grip; + + public CropView(Context ctx) : base(ctx) + { + float dp = ctx.Resources.DisplayMetrics.Density; + _frame.StrokeWidth = 2 * dp; + _frame.SetStyle(Paint.Style.Stroke); + _handle.StrokeWidth = 4 * dp; + _grip = 28 * dp; + } + + public Rect Crop => _crop; + + public void SetPhoto(Bitmap photo, Rect crop = null) + { + _photo = photo; + _crop = crop ?? new Rect(0, 0, photo.Width, photo.Height); + Fit(); + Invalidate(); + } + + protected override void OnSizeChanged(int w, int h, int oldw, int oldh) + { + base.OnSizeChanged(w, h, oldw, oldh); + Fit(); + } + + void Fit() + { + if (_photo == null || Width == 0) + return; + float margin = _grip / 2; + _scale = Math.Min((Width - 2 * margin) / _photo.Width, (Height - 2 * margin) / _photo.Height); + _ox = (Width - _photo.Width * _scale) / 2; + _oy = (Height - _photo.Height * _scale) / 2; + } + + float X(int px) => _ox + px * _scale; + float Y(int px) => _oy + px * _scale; + + protected override void OnDraw(Canvas canvas) + { + if (_photo == null) + return; + var dst = new RectF(_ox, _oy, _ox + _photo.Width * _scale, _oy + _photo.Height * _scale); + canvas.DrawBitmap(_photo, null, dst, null); + float l = X(_crop.Left), t = Y(_crop.Top), r = X(_crop.Right), b = Y(_crop.Bottom); + canvas.DrawRect(dst.Left, dst.Top, dst.Right, t, _dim); + canvas.DrawRect(dst.Left, b, dst.Right, dst.Bottom, _dim); + canvas.DrawRect(dst.Left, t, l, b, _dim); + canvas.DrawRect(r, t, dst.Right, b, _dim); + canvas.DrawRect(l, t, r, b, _frame); + float c = _grip / 2; + foreach (var (x, y, dx, dy) in new[] { (l, t, 1, 1), (r, t, -1, 1), (l, b, 1, -1), (r, b, -1, -1) }) + { + canvas.DrawLine(x, y, x + dx * c, y, _handle); + canvas.DrawLine(x, y, x, y + dy * c, _handle); + } + } + + public override bool OnTouchEvent(MotionEvent e) + { + if (_photo == null) + return false; + switch (e.Action) + { + case MotionEventActions.Down: + float l = X(_crop.Left), t = Y(_crop.Top), r = X(_crop.Right), b = Y(_crop.Bottom); + bool inY = e.GetY() > t - _grip && e.GetY() < b + _grip; + bool inX = e.GetX() > l - _grip && e.GetX() < r + _grip; + _left = inY && Math.Abs(e.GetX() - l) < _grip; + _right = inY && !_left && Math.Abs(e.GetX() - r) < _grip; + _top = inX && Math.Abs(e.GetY() - t) < _grip; + _bottom = inX && !_top && Math.Abs(e.GetY() - b) < _grip; + _all = !(_left || _right || _top || _bottom) && e.GetX() > l && e.GetX() < r && e.GetY() > t && e.GetY() < b; + _lastX = e.GetX(); + _lastY = e.GetY(); + return _left || _right || _top || _bottom || _all; + case MotionEventActions.Move: + int dx = (int)((e.GetX() - _lastX) / _scale), dy = (int)((e.GetY() - _lastY) / _scale); + if (dx == 0 && dy == 0) + return true; + _lastX += dx * _scale; + _lastY += dy * _scale; + Drag(dx, dy); + Invalidate(); + return true; + case MotionEventActions.Up: + case MotionEventActions.Cancel: + Changed?.Invoke(); + return true; + } + return base.OnTouchEvent(e); + } + + void Drag(int dx, int dy) + { + int min = Math.Max(16, Math.Min(_photo.Width, _photo.Height) / 20); + if (_all) + { + dx = Math.Clamp(dx, -_crop.Left, _photo.Width - _crop.Right); + dy = Math.Clamp(dy, -_crop.Top, _photo.Height - _crop.Bottom); + _crop.Offset(dx, dy); + return; + } + if (_left) _crop.Left = Math.Clamp(_crop.Left + dx, 0, _crop.Right - min); + if (_right) _crop.Right = Math.Clamp(_crop.Right + dx, _crop.Left + min, _photo.Width); + if (_top) _crop.Top = Math.Clamp(_crop.Top + dy, 0, _crop.Bottom - min); + if (_bottom) _crop.Bottom = Math.Clamp(_crop.Bottom + dy, _crop.Top + min, _photo.Height); + } + } +} diff --git a/src/keepass2android-app/Resources/values-ru/strings.xml b/src/keepass2android-app/Resources/values-ru/strings.xml index 3cb9a1dc3..07fb528b7 100644 --- a/src/keepass2android-app/Resources/values-ru/strings.xml +++ b/src/keepass2android-app/Resources/values-ru/strings.xml @@ -369,6 +369,16 @@ Значение поля Защищённое поле Добавить вложение + Сделать снимок + Не найдено приложение камеры. + Снимок + Сбросить + Сохранить + Мини + Малый + Средний + Крупный + Полный Добавить доп строку Настроить TOTP Секретный ключ diff --git a/src/keepass2android-app/Resources/values/strings.xml b/src/keepass2android-app/Resources/values/strings.xml index 25467a0d7..1c5bf8377 100644 --- a/src/keepass2android-app/Resources/values/strings.xml +++ b/src/keepass2android-app/Resources/values/strings.xml @@ -374,6 +374,16 @@ Field value Protected field Add file attachment + Take photo + No camera app found. + Photo + Reset + Save + Tiny + Small + Medium + Large + Full Add additional string Configure TOTP Secret key diff --git a/src/keepass2android-app/intents/Intents.cs b/src/keepass2android-app/intents/Intents.cs index 3cae24739..22cfd1453 100644 --- a/src/keepass2android-app/intents/Intents.cs +++ b/src/keepass2android-app/intents/Intents.cs @@ -58,6 +58,7 @@ public class Intents public const int RequestCodeFileBrowseForCreate = 37322; public const int RequestCodeFileBrowseForBinary = 37323; public const int RequestCodeFileBrowseForKeyfile = 37324; + public const int RequestCodePhotoForBinary = 37325; public const String ShowNotification = "keepass2android.show_notification"; public const String UpdateKeyboard = "keepass2android.update_keyboard";