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)
{