diff --git a/src/keepass2android-app/AttachmentPreview.cs b/src/keepass2android-app/AttachmentPreview.cs new file mode 100644 index 000000000..fff010b48 --- /dev/null +++ b/src/keepass2android-app/AttachmentPreview.cs @@ -0,0 +1,264 @@ +// 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.Text; +using Android.Content; +using Android.Graphics; +using Android.Graphics.Pdf; +using Android.Media; +using Android.OS; +using Android.Views; +using Android.Widget; +using Google.Android.Material.Dialog; + +namespace keepass2android +{ + /// + /// A tile for an attachment in the row under the attachments of an entry: an image, the first page of a PDF, a frame of + /// a video, the first lines of a text. A tap shows it bigger: the image viewer, the page in a window, the whole text in + /// a window; a video is handed to another app like «Open» does. Other attachments get no tile. + /// + public static class AttachmentPreview + { + /// Text above this is shown cut in the window. + const int MaxText = 256 * 1024; + + public static string SizeText(long bytes) => + bytes < 1024 ? bytes + " B" + : bytes < 1024 * 1024 ? (bytes + 512) / 1024 + " KB" + : (bytes / (1024.0 * 1024.0)).ToString("0.0") + " MB"; + + /// The tile of the attachment `key`, `side` pixels square; null - nothing to show of it. + public static View Tile(EntryActivity activity, string key, byte[] data, int side) + { + try + { + Bitmap image = Image(data, side); + if (image != null) + return Picture(activity, image, false, () => activity.ShowAttachedImage(key)); + + if (IsPdf(data)) + { + Bitmap page = PdfPage(activity, data, side, out _); + if (page != null) + return Picture(activity, page, false, () => ShowPdf(activity, key, data)); + } + + if (IsVideo(data) && Build.VERSION.SdkInt >= BuildVersionCodes.M) + { + Bitmap frame = VideoFrame(data); + if (frame != null) + return Picture(activity, frame, true, () => + { + var uri = activity.WriteBinaryToFile(key, true); + if (uri != null) + activity.OpenBinaryFile(uri); + }); + } + + string text = Text(data, 2048); + if (text != null) + { + var tile = new TextView(activity) + { + Text = text, + Typeface = Typeface.Monospace, + TextSize = 7 + }; + tile.SetBackgroundColor(Color.Argb(40, 128, 128, 128)); + tile.SetPadding(8, 8, 8, 8); + tile.Click += (s, e) => ShowText(activity, key, data); + return tile; + } + } + catch (Exception e) + { + Kp2aLog.Log(e.ToString()); + } + return null; + } + + static View Picture(Context ctx, Bitmap bitmap, bool play, Action open) + { + var image = new ImageView(ctx); + image.SetImageBitmap(bitmap); + image.SetScaleType(ImageView.ScaleType.CenterCrop); + View view = image; + if (play) + { + var frame = new FrameLayout(ctx); + frame.AddView(image); + var mark = new TextView(ctx) { Text = "▶", TextSize = 24, Gravity = GravityFlags.Center }; + mark.SetTextColor(Color.White); + mark.SetShadowLayer(4, 0, 0, Color.Black); + frame.AddView(mark, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MatchParent, ViewGroup.LayoutParams.MatchParent)); + view = frame; + } + view.Click += (s, e) => open(); + return view; + } + + static Bitmap Image(byte[] data, int side) + { + var bounds = new BitmapFactory.Options { InJustDecodeBounds = true }; + BitmapFactory.DecodeByteArray(data, 0, data.Length, bounds); + if (bounds.OutWidth <= 0 || bounds.OutHeight <= 0) + return null; + int sample = 1; + while (Math.Min(bounds.OutWidth, bounds.OutHeight) / (sample * 2) >= side) + sample *= 2; + return BitmapFactory.DecodeByteArray(data, 0, data.Length, new BitmapFactory.Options { InSampleSize = sample }); + } + + static bool IsPdf(byte[] d) => d.Length > 5 && d[0] == '%' && d[1] == 'P' && d[2] == 'D' && d[3] == 'F' && d[4] == '-'; + + /// MP4, MOV, 3GP (a box `ftyp` first) or Matroska, WebM. + static bool IsVideo(byte[] d) => + d.Length > 12 && ((d[4] == 'f' && d[5] == 't' && d[6] == 'y' && d[7] == 'p') + || (d[0] == 0x1A && d[1] == 0x45 && d[2] == 0xDF && d[3] == 0xA3)); + + /// + /// The first page `width` pixels wide. The renderer reads only a file: the PDF is in the private cache for the moment + /// it is read, as «Open» keeps an attachment there too. + /// + static Bitmap PdfPage(Context ctx, byte[] data, int width, out int pages) + { + pages = 0; + var dir = new Java.IO.File(ctx.CacheDir, "AttachmentPreview"); + dir.Mkdirs(); + var file = new Java.IO.File(dir, "preview.pdf"); + try + { + System.IO.File.WriteAllBytes(file.AbsolutePath, data); + using var fd = ParcelFileDescriptor.Open(file, ParcelFileMode.ReadOnly); + using var renderer = new PdfRenderer(fd); + pages = renderer.PageCount; + if (pages == 0) + return null; + using var page = renderer.OpenPage(0); + int height = Math.Max(1, width * page.Height / Math.Max(1, page.Width)); + var bitmap = Bitmap.CreateBitmap(width, height, Bitmap.Config.Argb8888); + bitmap.EraseColor(Color.White); + page.Render(bitmap, null, null, PdfRenderMode.ForDisplay); + page.Close(); + return bitmap; + } + finally + { + file.Delete(); + } + } + + static Bitmap VideoFrame(byte[] data) + { + using var retriever = new MediaMetadataRetriever(); + retriever.SetDataSource(new BytesSource(data)); + return retriever.GetFrameAtTime(-1); + } + + /// The start of `data` as text, at most `max` bytes of it; null - not text (not UTF-8, or binary codes). + static string Text(byte[] data, int max) + { + int n = Math.Min(data.Length, max); + if (n == 0) + return null; + int controls = 0; + for (int i = 0; i < n; i++) + { + byte b = data[i]; + if (b == 0) + return null; + if (b < 0x20 && b != '\n' && b != '\r' && b != '\t') + controls++; + } + if (controls > n / 100) + return null; + try + { + // a strict decoder that leaves a character cut at the end for later instead of failing on it + var decoder = new UTF8Encoding(false, true).GetDecoder(); + var chars = new char[n]; + int count = decoder.GetChars(data, 0, n, chars, 0, false); + string s = new string(chars, 0, count); + return s.StartsWith('') ? s.Substring(1) : s; + } + catch (DecoderFallbackException) + { + return null; + } + } + + static void ShowText(EntryActivity activity, string key, byte[] data) + { + string text = Text(data, MaxText); + if (text == null) + return; + if (data.Length > MaxText) + text += "\n…"; + var view = new TextView(activity) { Text = text, Typeface = Typeface.Monospace, TextSize = 12 }; + view.SetTextIsSelectable(true); + int pad = (int)(16 * activity.Resources.DisplayMetrics.Density); + view.SetPadding(pad, pad, pad, pad); + var scroll = new ScrollView(activity); + scroll.AddView(view); + new MaterialAlertDialogBuilder(activity) + .SetTitle(key) + .SetView(scroll) + .SetPositiveButton(Android.Resource.String.Ok, (s, e) => { }) + .Show(); + } + + static void ShowPdf(EntryActivity activity, string key, byte[] data) + { + Bitmap page = PdfPage(activity, data, activity.Resources.DisplayMetrics.WidthPixels, out int pages); + if (page == null) + return; + var image = new ImageView(activity); + image.SetImageBitmap(page); + image.SetAdjustViewBounds(true); + var scroll = new ScrollView(activity); + scroll.AddView(image); + new MaterialAlertDialogBuilder(activity) + .SetTitle(pages > 1 ? $"{key} · 1 / {pages}" : key) + .SetView(scroll) + .SetPositiveButton(Android.Resource.String.Ok, (s, e) => { }) + .Show(); + } + + /// A video straight from memory: no file of it anywhere. + class BytesSource : MediaDataSource + { + readonly byte[] _data; + + public BytesSource(byte[] data) => _data = data; + + public override long Size => _data.Length; + + public override int ReadAt(long position, byte[] buffer, int offset, int size) + { + if (position >= _data.Length) + return -1; + int n = (int)Math.Min(size, _data.Length - position); + Array.Copy(_data, position, buffer, offset, n); + return n; + } + + public override void Close() + { + } + } + } +} diff --git a/src/keepass2android-app/EntryActivity.cs b/src/keepass2android-app/EntryActivity.cs index 27b70e47a..aa4f858ee 100644 --- a/src/keepass2android-app/EntryActivity.cs +++ b/src/keepass2android-app/EntryActivity.cs @@ -923,7 +923,7 @@ private void PopulateBinaries() (RelativeLayout)LayoutInflater.Inflate(Resource.Layout.entry_extrastring_value, null); var valueView = valueViewContainer.FindViewById(Resource.Id.entry_extra); if (key != null) - valueView.Text = key; + valueView.Text = key + " · " + AttachmentPreview.SizeText(pair.Value.Length); string popupKey = Strings.PrefixBinary + key; @@ -969,9 +969,34 @@ private void PopulateBinaries() */ } + AddThumbnails(binariesGroup); FindViewById(Resource.Id.entry_binaries_label).Visibility = Entry.Binaries.Any() ? ViewStates.Visible : ViewStates.Gone; } + /// A row of tiles of the attachments that can be shown (); a tap shows one bigger. + private void AddThumbnails(ViewGroup binariesGroup) + { + int side = (int)(96 * Resources.DisplayMetrics.Density); + int gap = (int)(4 * Resources.DisplayMetrics.Density); + LinearLayout row = null; + foreach (KeyValuePair pair in Entry.Binaries.OrderBy(p => p.Key)) + { + View tile = AttachmentPreview.Tile(this, pair.Key, pair.Value.ReadData(), side); + if (tile == null) + continue; + if (row == null) + { + row = new LinearLayout(this) { Orientation = Orientation.Horizontal }; + var scroll = new HorizontalScrollView(this); + scroll.AddView(row); + binariesGroup.AddView(scroll); + } + var lp = new LinearLayout.LayoutParams(side, side); + lp.SetMargins(gap, gap, gap, gap); + row.AddView(tile, lp); + } + } + // url = file path or whatever suitable URL you want. public static String GetMimeType(String url) {