Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@
<PackageVersion Include="SSH.NET" Version="2025.1.0" />
<!-- Avalonia.Skia 12.0.3 pulls SkiaSharp 3.119.4-preview.1.1 transitively; match it explicitly. -->
<PackageVersion Include="SkiaSharp" Version="3.119.4-preview.1.1" />
<!-- Linux native libSkiaSharp for the Avalonia-less test process. The
SkiaSharp package bundles win/osx natives but not Linux; the app itself
loads Avalonia.Skia's native at runtime, so this is test-only. -->
<PackageVersion Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.4-preview.1.1" />
<PackageVersion Include="System.Reactive" Version="6.0.1" />

<PackageVersion Include="Serilog" Version="4.2.0" />
Expand Down
4 changes: 2 additions & 2 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Polish items still open before tagging the betas:
- [ ] In-app auto-update (Velopack) + native installers
- [ ] Code signing (Windows / macOS) and Play / TestFlight signing
- [ ] F-Droid packaging
- [ ] Snapshot share sheet
- [x] Snapshot share sheet
- [ ] Localization polish (English / Russian)

> **Validation caveat.** Linux / macOS / Android / iOS code paths build and
Expand All @@ -55,7 +55,7 @@ and scope live in the planning docs (`dashboard-ideas-roadmap-ru.md`).
|---|---|---|:---:|
| 12 | Streaming hardening | Smart-pause hidden tiles, auto SD/HD, watchdog + backoff, last-frame hold, error tile | ✅ Done |
| 13 | SSH device suite | SSH terminal, SCP file manager, open-in-browser, config push | ✅ Done |
| 14 | Snapshots & viewer | Always-HD snapshot, snapshot browser, built-in viewer + basic editor | 📋 Planned |
| 14 | Snapshots & viewer | Always-HD snapshot, snapshot browser, built-in viewer + basic editor | ✅ Done |
| 15 | Local AI analytics | ONNX object detection per camera, auto-record, control center, CPU fallback | 📋 Planned |
| 16 | Archive pro | Fragmented MP4, activity calendar, timeline zoom, clip export | 📋 Planned |
| 17 | Community & app-level | Tabbed layouts, config export/import, notifications, white-label, issue reporter, RBAC | 📋 Planned |
Expand Down
1 change: 1 addition & 0 deletions src/OpenIPC.Viewer.Android/Composition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public static ServiceProvider Build(Context context)
services.AddSingleton<ISecretsStore>(sp =>
new AndroidSecretsStore(context, sp.GetRequiredService<IFileSystem>().AppDataDir));
services.AddSingleton<IHwDecoderFactory, MediaCodecDecoderFactory>();
services.AddSingleton<IShareService>(_ => new AndroidShareService(context));

// Recording — in-process libavformat (no subprocess on Android) +
// foreground service for OS keep-alive. Phase 9c.
Expand Down
44 changes: 44 additions & 0 deletions src/OpenIPC.Viewer.Android/Platform/AndroidShareService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System.Runtime.Versioning;
using System.Threading;
using System.Threading.Tasks;
using Android.Content;
using AndroidX.Core.Content;
using OpenIPC.Viewer.Core.Platform;

namespace OpenIPC.Viewer.Android.Platform;

/// <summary>
/// Android native share (Phase 14.6 / Phase 11 "snapshot share sheet"). Hands
/// the receiving app a temporary content:// URI via FileProvider — file:// URIs
/// throw FileUriExposedException on modern Android.
/// </summary>
[SupportedOSPlatform("android")]
public sealed class AndroidShareService : IShareService
{
private readonly Context _context;

public AndroidShareService(Context context) => _context = context;

public bool SupportsSystemShare => true;

public Task ShareFileAsync(string path, string? mimeType, CancellationToken ct)
{
var file = new Java.IO.File(path);
var authority = _context.PackageName + ".fileprovider";
var uri = FileProvider.GetUriForFile(_context, authority, file);

var intent = new Intent(Intent.ActionSend);
intent.SetType(mimeType ?? "image/jpeg");
intent.PutExtra(Intent.ExtraStream, uri);
intent.AddFlags(ActivityFlags.GrantReadUriPermission);

var chooser = Intent.CreateChooser(intent, (string?)null);
// Started from the application context (not an Activity), so a new task
// is required.
chooser?.AddFlags(ActivityFlags.NewTask);
_context.StartActivity(chooser);
return Task.CompletedTask;
}

public Task RevealInFolderAsync(string path) => Task.CompletedTask;
}
9 changes: 9 additions & 0 deletions src/OpenIPC.Viewer.Android/Properties/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,14 @@
android:allowBackup="true"
android:supportsRtl="true"
android:name=".MainApplication">
<!-- Phase 14.6 — share sheet. FileProvider grants the receiving app a
scoped, temporary read URI to a snapshot instead of a file:// path. -->
<provider android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
9 changes: 9 additions & 0 deletions src/OpenIPC.Viewer.Android/Resources/xml/file_paths.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- FileProvider exposure for the share sheet (Phase 14.6). Snapshots live in
the app's external files dir under Pictures/; cache covers temporary copies. -->
<paths>
<external-files-path name="snapshots" path="Pictures/" />
<external-files-path name="external_root" path="." />
<files-path name="internal" path="." />
<cache-path name="cache" path="." />
</paths>
39 changes: 39 additions & 0 deletions src/OpenIPC.Viewer.App/Converters/PathToThumbnailConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using System;
using System.Globalization;
using System.IO;
using Avalonia.Data.Converters;
using Avalonia.Media.Imaging;

namespace OpenIPC.Viewer.App.Converters;

/// <summary>
/// Turns a file-path string into a decoded <see cref="Bitmap"/> for an
/// <c>Image.Source</c> binding. Decodes capped at <see cref="DecodeWidth"/> so
/// the gallery never loads full-resolution stills even when a thumbnail is
/// missing. Returns null (blank cell) on any failure. Only the realized
/// (visible) ItemsRepeater items hit this, so virtualization keeps it cheap.
/// </summary>
public sealed class PathToThumbnailConverter : IValueConverter
{
public static readonly PathToThumbnailConverter Instance = new();

private const int DecodeWidth = 320;

public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is not string path || string.IsNullOrEmpty(path) || !File.Exists(path))
return null;
try
{
using var stream = File.OpenRead(path);
return Bitmap.DecodeToWidth(stream, DecodeWidth);
}
catch
{
return null;
}
}

public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
throw new NotSupportedException();
}
29 changes: 29 additions & 0 deletions src/OpenIPC.Viewer.App/Services/DialogService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
using OpenIPC.Viewer.App.ViewModels.Dialogs;
using OpenIPC.Viewer.App.Views.Dialogs;

Expand Down Expand Up @@ -261,6 +262,34 @@ public async Task OpenFileManagerAsync(ViewModels.FileManagerViewModel viewModel
else window.Show(owner);
}

public async Task ShowImageViewerAsync(ViewModels.ImageViewerViewModel viewModel)
{
if (OverlayDialogPresenter.IsMobile)
{
var content = new ImageViewerContent { DataContext = viewModel };
await OverlayDialogPresenter.ShowAsync(content, viewModel.Completion, fullScreen: true).ConfigureAwait(true);
viewModel.Cleanup();
return;
}

var owner = ResolveOwner();
var window = new ImageViewerWindow { DataContext = viewModel };
// Bridge both directions: the VM's Close command closes the window, and
// closing the window (X / Esc-less) completes the VM so the awaiter
// returns and cleanup runs.
_ = viewModel.Completion.ContinueWith(
_ => Dispatcher.UIThread.Post(window.Close),
TaskScheduler.Default);
window.Closing += (_, _) => viewModel.RequestClose();

if (owner is null)
window.Show();
else
await window.ShowDialog(owner).ConfigureAwait(true);

viewModel.Cleanup();
}

public async Task<bool> OpenUrlAsync(string url)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
Expand Down
4 changes: 4 additions & 0 deletions src/OpenIPC.Viewer.App/Services/IDialogService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ public interface IDialogService
// Opens the remote file manager (Phase 13.4) — window on desktop, overlay on mobile.
Task OpenFileManagerAsync(FileManagerViewModel viewModel);

// Opens the in-app snapshot viewer (Phase 14.4) — modal window on desktop,
// full-screen overlay on mobile. Completes when the viewer is closed.
Task ShowImageViewerAsync(ImageViewerViewModel viewModel);

Task<CameraEditorResult?> ShowCameraEditorAsync(CameraEditorViewModel viewModel);

Task<DiscoveryDialogResult?> ShowDiscoveryDialogAsync(DiscoveryDialogViewModel viewModel);
Expand Down
34 changes: 34 additions & 0 deletions src/OpenIPC.Viewer.App/Services/ImageViewerFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using System.Collections.Generic;
using Microsoft.Extensions.Logging;
using OpenIPC.Viewer.App.ViewModels;
using OpenIPC.Viewer.Core.Platform;
using OpenIPC.Viewer.Core.Snapshots;

namespace OpenIPC.Viewer.App.Services;

/// <summary>Builds <see cref="ImageViewerViewModel"/>s with their DI dependencies.</summary>
public sealed class ImageViewerFactory
{
private readonly ISnapshotRepository _repo;
private readonly ISnapshotService _snapshots;
private readonly IDialogService _dialogs;
private readonly IShareService _share;
private readonly ILoggerFactory _loggerFactory;

public ImageViewerFactory(
ISnapshotRepository repo,
ISnapshotService snapshots,
IDialogService dialogs,
IShareService share,
ILoggerFactory loggerFactory)
{
_repo = repo;
_snapshots = snapshots;
_dialogs = dialogs;
_share = share;
_loggerFactory = loggerFactory;
}

public ImageViewerViewModel Create(IReadOnlyList<SnapshotViewEntry> items, int startIndex) =>
new(items, startIndex, _repo, _snapshots, _dialogs, _share, _loggerFactory.CreateLogger<ImageViewerViewModel>());
}
86 changes: 86 additions & 0 deletions src/OpenIPC.Viewer.App/Services/Localizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,50 @@ private static LangCode DetectSystem()

["Snapshot.SaveAsTitle"] = "Save snapshot",

["Snapshots.Title"] = "Snapshots",
["Snapshots.AllCameras"] = "All cameras",
["Snapshots.PresetAll"] = "All",
["Snapshots.PresetToday"] = "Today",
["Snapshots.Preset7"] = "7 days",
["Snapshots.Preset30"] = "30 days",
["Snapshots.SortNewest"] = "Newest first",
["Snapshots.SortOldest"] = "Oldest first",
["Snapshots.Empty"] = "No snapshots yet",
["Snapshots.LoadError"] = "Couldn't load snapshots",
["Snapshots.SelectedFormat"] = "{0} selected",
["Snapshots.Dialog.DeleteTitle"] = "Delete snapshots",
["Snapshots.Dialog.DeleteOneMessage"] = "Delete this snapshot? This can't be undone.",
["Snapshots.Dialog.DeleteManyMessageFormat"] = "Delete {0} snapshots? This can't be undone.",

["Viewer.ZoomIn"] = "Zoom in",
["Viewer.ZoomOut"] = "Zoom out",
["Viewer.Fit"] = "Fit",
["Viewer.RotateLeft"] = "Rotate left",
["Viewer.RotateRight"] = "Rotate right",
["Viewer.FlipH"] = "Flip horizontal",
["Viewer.FlipV"] = "Flip vertical",
["Viewer.Slideshow"] = "Slideshow",
["Viewer.Copy"] = "Copy",
["Viewer.Properties"] = "Details",
["Viewer.Prev"] = "Previous",
["Viewer.Next"] = "Next",
["Viewer.Edit"] = "Edit",
["Viewer.Share"] = "Share",
["Viewer.Reveal"] = "Reveal",
["Snapshots.Share"] = "Share",
["Snapshots.Reveal"] = "Reveal",

["Edit.Arrow"] = "Arrow",
["Edit.Rect"] = "Box",
["Edit.Text"] = "Text",
["Edit.Crop"] = "Crop",
["Edit.Undo"] = "Undo",
["Edit.Save"] = "Save copy",
["Edit.TextPrompt"] = "Annotation text",
["Edit.AddText"] = "Add",

["Grid.EmptySlot"] = "no camera",
["Tile.Snapshot"] = "Snapshot (HD)",

["Welcome.Language"] = "Language",

Expand Down Expand Up @@ -542,7 +585,50 @@ private static LangCode DetectSystem()

["Snapshot.SaveAsTitle"] = "Сохранить снимок",

["Snapshots.Title"] = "Снимки",
["Snapshots.AllCameras"] = "Все камеры",
["Snapshots.PresetAll"] = "Все",
["Snapshots.PresetToday"] = "Сегодня",
["Snapshots.Preset7"] = "7 дней",
["Snapshots.Preset30"] = "30 дней",
["Snapshots.SortNewest"] = "Сначала новые",
["Snapshots.SortOldest"] = "Сначала старые",
["Snapshots.Empty"] = "Снимков пока нет",
["Snapshots.LoadError"] = "Не удалось загрузить снимки",
["Snapshots.SelectedFormat"] = "Выбрано: {0}",
["Snapshots.Dialog.DeleteTitle"] = "Удаление снимков",
["Snapshots.Dialog.DeleteOneMessage"] = "Удалить этот снимок? Действие необратимо.",
["Snapshots.Dialog.DeleteManyMessageFormat"] = "Удалить снимков: {0}? Действие необратимо.",

["Viewer.ZoomIn"] = "Увеличить",
["Viewer.ZoomOut"] = "Уменьшить",
["Viewer.Fit"] = "Вписать",
["Viewer.RotateLeft"] = "Повернуть влево",
["Viewer.RotateRight"] = "Повернуть вправо",
["Viewer.FlipH"] = "Отразить по горизонтали",
["Viewer.FlipV"] = "Отразить по вертикали",
["Viewer.Slideshow"] = "Слайд-шоу",
["Viewer.Copy"] = "Копировать",
["Viewer.Properties"] = "Детали",
["Viewer.Prev"] = "Назад",
["Viewer.Next"] = "Вперёд",
["Viewer.Edit"] = "Редактор",
["Viewer.Share"] = "Поделиться",
["Viewer.Reveal"] = "Показать в папке",
["Snapshots.Share"] = "Поделиться",
["Snapshots.Reveal"] = "Показать в папке",

["Edit.Arrow"] = "Стрелка",
["Edit.Rect"] = "Рамка",
["Edit.Text"] = "Текст",
["Edit.Crop"] = "Обрезка",
["Edit.Undo"] = "Отменить",
["Edit.Save"] = "Сохранить копию",
["Edit.TextPrompt"] = "Текст подписи",
["Edit.AddText"] = "Добавить",

["Grid.EmptySlot"] = "нет камеры",
["Tile.Snapshot"] = "Снимок (HD)",

["Welcome.Language"] = "Язык",

Expand Down
10 changes: 5 additions & 5 deletions src/OpenIPC.Viewer.App/Services/SingleCameraPageFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
using OpenIPC.Viewer.Core.Entities;
using OpenIPC.Viewer.Core.Majestic;
using OpenIPC.Viewer.Core.Onvif;
using OpenIPC.Viewer.Core.Platform;
using OpenIPC.Viewer.Core.Recording;
using OpenIPC.Viewer.Core.Services;
using OpenIPC.Viewer.Core.Snapshots;
using OpenIPC.Viewer.Core.Video;

namespace OpenIPC.Viewer.App.Services;
Expand All @@ -18,9 +18,9 @@ public sealed class SingleCameraPageFactory
private readonly IMajesticClient _majestic;
private readonly IMajesticSshConfigClient _majesticSsh;
private readonly RecordingService _recordings;
private readonly IFileSystem _fs;
private readonly UserSettingsService _userSettings;
private readonly IDialogService _dialogs;
private readonly ISnapshotService _snapshots;
private readonly ILoggerFactory _loggerFactory;

public SingleCameraPageFactory(
Expand All @@ -30,9 +30,9 @@ public SingleCameraPageFactory(
IMajesticClient majestic,
IMajesticSshConfigClient majesticSsh,
RecordingService recordings,
IFileSystem fs,
UserSettingsService userSettings,
IDialogService dialogs,
ISnapshotService snapshots,
ILoggerFactory loggerFactory)
{
_coordinator = coordinator;
Expand All @@ -41,12 +41,12 @@ public SingleCameraPageFactory(
_majestic = majestic;
_majesticSsh = majesticSsh;
_recordings = recordings;
_fs = fs;
_userSettings = userSettings;
_dialogs = dialogs;
_snapshots = snapshots;
_loggerFactory = loggerFactory;
}

public SingleCameraPageViewModel Create(Camera camera) =>
new(camera, _coordinator, _directory, _onvif, _majestic, _majesticSsh, _recordings, _fs, _userSettings, _dialogs, _loggerFactory.CreateLogger<SingleCameraPageViewModel>());
new(camera, _coordinator, _directory, _onvif, _majestic, _majesticSsh, _recordings, _userSettings, _dialogs, _snapshots, _loggerFactory.CreateLogger<SingleCameraPageViewModel>());
}
4 changes: 4 additions & 0 deletions src/OpenIPC.Viewer.App/Themes/Theme.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,9 @@
<StreamGeometry x:Key="IconTerminal">M4,17 L10,11 L4,5 M12,19 H20</StreamGeometry>
<!-- IconPlugOff — Lucide "unplug", for the grid error/Offline cell. -->
<StreamGeometry x:Key="IconPlugOff">M19,5 L22,2 M2,22 L5,19 M6.3,20.3 a2.4,2.4 0 0 0 3.4,0 L12,18 L6,12 L3.7,14.3 a2.4,2.4 0 0 0 0,3.4 Z M7.5,13.5 L10,11 M10.5,16.5 L13,14 M12,6 L18,12 L20.3,9.7 a2.4,2.4 0 0 0 0,-3.4 L17.7,3.7 a2.4,2.4 0 0 0 -3.4,0 Z</StreamGeometry>
<!-- IconCamera — Lucide "camera", for the snapshot button on tiles. -->
<StreamGeometry x:Key="IconCamera">M14.5,4 H9.5 L7,7 H4 A2,2 0 0 0 2,9 V18 A2,2 0 0 0 4,20 H20 A2,2 0 0 0 22,18 V9 A2,2 0 0 0 20,7 H17 L14.5,4 Z M15,13 A3,3 0 1 1 9,13 A3,3 0 1 1 15,13 Z</StreamGeometry>
<!-- IconImage — Lucide "image", for the Snapshots segment / gallery. -->
<StreamGeometry x:Key="IconImage">M5,3 H19 A2,2 0 0 1 21,5 V19 A2,2 0 0 1 19,21 H5 A2,2 0 0 1 3,19 V5 A2,2 0 0 1 5,3 Z M10,9 A1.5,1.5 0 1 1 7,9 A1.5,1.5 0 1 1 10,9 Z M21,15 L16,10 L5,21</StreamGeometry>

</ResourceDictionary>
Loading
Loading