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
1 change: 1 addition & 0 deletions src/LageBuch.App.Android/MainActivity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ protected override AppBuilder CustomizeAppBuilder(AppBuilder builder)
new NoopIncidentHostController(),
new LageBuch.App.Shared.Services.AvaloniaUiDispatcher(),
typeof(MainActivity).Assembly.GetName().Version?.ToString() ?? "0.0.0",
AndroidAppPaths.RegionsDir(this),
lastSaveFolder: null,
attachmentCacheRoot: AndroidAppPaths.AttachmentCacheDir(this));
return base.CustomizeAppBuilder(builder).WithInterFont();
Expand Down
3 changes: 3 additions & 0 deletions src/LageBuch.App.Android/Services/AndroidAppPaths.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,7 @@ public static string RecentFilesJsonPath(Context context) =>

public static string AttachmentCacheDir(Context context) =>
System.IO.Path.Combine(CacheDir(context), "attachment-cache");

public static string RegionsDir(Context context) =>
System.IO.Path.Combine(context.FilesDir!.AbsolutePath, "regions");
}
13 changes: 12 additions & 1 deletion src/LageBuch.App.Shared/CompositionRoot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ namespace LageBuch.App.Shared;
/// </summary>
public static class CompositionRoot
{
/// <summary>
/// Raw-served manifest of published Wasserförderung region packs (#150 follow-up) — see
/// tools/build-region-pack/README.md for how a pack is built and published here.
/// </summary>
public const string RegionPackManifestUrl =
"https://raw.githubusercontent.com/CodeForFire/lagebuch-regions/main/regions.json";

public static MainWindowViewModel CreateMainWindowViewModel(
IIncidentStore store,
IMasterDataProvider masterData,
Expand All @@ -26,11 +33,15 @@ public static MainWindowViewModel CreateMainWindowViewModel(
IIncidentHostController hostController,
IUiDispatcher uiDispatcher,
string appVersion,
string regionsDir,
ILastSaveFolderStore? lastSaveFolder = null,
string? attachmentCacheRoot = null)
{
var home = new HomeViewModel(store, masterData, recent, dialogs, clock, ticker, alarm, hostController, appVersion, uiDispatcher, lastSaveFolder, attachmentCacheRoot, new RouteOverviewRenderer());
var editor = new MasterDataEditorViewModel(masterData, dialogs, masterDataFileService);
var httpClient = new HttpClient();
var regionCatalog = new RegionPackCatalogService(httpClient, RegionPackManifestUrl);
var regionInstaller = new RegionPackInstaller(httpClient, regionsDir);
var editor = new MasterDataEditorViewModel(masterData, dialogs, masterDataFileService, regionCatalog, regionInstaller);
return new MainWindowViewModel(home, editor, dialogs, appVersion);
}
}
66 changes: 56 additions & 10 deletions src/LageBuch.App.Shared/Views/MasterDataEditorView.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LageBuch.AppLogic.ViewModels;assembly=LageBuch.AppLogic"
xmlns:md="clr-namespace:LageBuch.Persistence.MasterData;assembly=LageBuch.Persistence"
xmlns:services="clr-namespace:LageBuch.AppLogic.Services;assembly=LageBuch.AppLogic"
x:Class="LageBuch.App.Shared.Views.MasterDataEditorView"
x:DataType="vm:MasterDataEditorViewModel">
<Panel>
Expand Down Expand Up @@ -108,18 +109,63 @@
</ScrollViewer>
</DataTemplate>

<!-- Wasserförderung region of operation (#150 phase 2) -->
<!-- Wasserförderung region of operation (#150 phase 2 + region-pack follow-up):
pick a published pack from the catalog and download it; manual folder entry
stays available under "Erweitert" for a self-built or hand-placed pack. -->
<DataTemplate x:DataType="vm:EinsatzgebietSection">
<StackPanel Spacing="12" MaxWidth="440" HorizontalAlignment="Left">
<StackPanel Spacing="4">
<TextBlock Text="NAME" Classes="overline" FontSize="10" />
<TextBox Text="{Binding Name}" PlaceholderText="z. B. Landkreis Fürstenfeldbruck" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="ORDNER (region.mbtiles + region.dem)" Classes="overline" FontSize="10" />
<TextBox Text="{Binding FolderPath}" PlaceholderText="z. B. /var/lib/lagebuch/regions/ffb" />
<ScrollViewer>
<StackPanel Spacing="12" MaxWidth="480" HorizontalAlignment="Left">
<StackPanel Spacing="4">
<TextBlock Text="KARTENPAKET" Classes="overline" FontSize="10" />
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="8">
<ComboBox x:Name="RegionComboBox" Grid.Column="0" HorizontalAlignment="Stretch"
ItemsSource="{Binding AvailableRegions}"
SelectedItem="{Binding SelectedRegion}"
PlaceholderText="Region auswählen…">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="services:RegionPackInfo">
<TextBlock Text="{Binding Name}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button x:Name="DownloadButton" Grid.Column="1" Content="HERUNTERLADEN"
Command="{Binding DownloadSelectedRegionCommand}" />
</Grid>
</StackPanel>

<Border x:Name="CatalogStatusBanner"
IsVisible="{Binding CatalogStatus, Converter={x:Static ObjectConverters.IsNotNull}}"
Background="#241608" BorderBrush="{StaticResource AmberBrush}" BorderThickness="1"
CornerRadius="{StaticResource RadiusSm}" Padding="8,6">
<TextBlock Text="{Binding CatalogStatus}" FontSize="12"
Foreground="{StaticResource AmberBrush}" TextWrapping="Wrap" />
</Border>

<ProgressBar x:Name="DownloadProgressBar" Minimum="0" Maximum="1"
Value="{Binding DownloadProgress}" Height="4" />

<TextBlock x:Name="AttributionText" Text="{Binding SelectedRegion.Attribution}"
IsVisible="{Binding SelectedRegion, Converter={x:Static ObjectConverters.IsNotNull}}"
Classes="secondary" FontSize="11" TextWrapping="Wrap" />

<TextBlock x:Name="KartendatenStatusText" Text="{Binding KartendatenStatus}"
IsVisible="{Binding KartendatenStatus, Converter={x:Static ObjectConverters.IsNotNull}}"
FontSize="12" TextWrapping="Wrap" />

<Expander Header="Erweitert (manueller Ordnerpfad)">
<StackPanel Spacing="12" Margin="0,12,0,0">
<StackPanel Spacing="4">
<TextBlock Text="NAME" Classes="overline" FontSize="10" />
<TextBox Text="{Binding Name}" PlaceholderText="z. B. Landkreis Fürstenfeldbruck" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="ORDNER (region.mbtiles + region.dem)" Classes="overline" FontSize="10" />
<TextBox Text="{Binding FolderPath}" PlaceholderText="z. B. /var/lib/lagebuch/regions/ffb" />
</StackPanel>
</StackPanel>
</Expander>
</StackPanel>
</StackPanel>
</ScrollViewer>
</DataTemplate>

<!-- String-list / checklist editor -->
Expand Down
3 changes: 3 additions & 0 deletions src/LageBuch.App/AppPaths.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ public static class AppPaths

public static string AttachmentCacheDir => Path.Combine(AppDataDir, "attachment-cache");

/// <summary>Where downloaded Wasserförderung region packs (#150 follow-up) get extracted, one subfolder per slug.</summary>
public static string RegionsDir => Path.Combine(AppDataDir, "regions");

public static string GetAppDataDir(string baseDir)
{
var dir = Path.Combine(baseDir, "Lagebuch");
Expand Down
1 change: 1 addition & 0 deletions src/LageBuch.App/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ private static MainWindowViewModel CreateMainViewModel()
new IncidentHostController(clock, version, uiDispatcher),
uiDispatcher,
version,
AppPaths.RegionsDir,
new JsonLastSaveFolderStore(AppPaths.LastSaveFolderJsonPath),
AppPaths.AttachmentCacheDir);
}
Expand Down
28 changes: 28 additions & 0 deletions src/LageBuch.AppLogic/Services/IRegionPackCatalogService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace LageBuch.AppLogic.Services;

/// <summary>
/// Lists the region packs (map tiles + elevation) publicly available for download (#150 follow-up)
/// — the map-side counterpart to Stammdaten's Einsatzgebiet, which used to require hand-preparing
/// region.mbtiles/region.dem with no guidance at all.
/// </summary>
public interface IRegionPackCatalogService
{
/// <summary>
/// Never throws — a Stammdaten editor must stay usable offline, so a fetch/parse failure
/// yields an empty list rather than propagating an exception.
/// </summary>
Task<IReadOnlyList<RegionPackInfo>> GetAvailableRegionsAsync(CancellationToken ct = default);
}

/// <summary>One published, downloadable region pack.</summary>
public sealed record RegionPackInfo(
string Name,
string Slug,
string DownloadUrl,
long SizeBytes,
double MinLat,
double MinLon,
double MaxLat,
double MaxLon,
string BuiltAt,
string Attribution);
8 changes: 8 additions & 0 deletions src/LageBuch.AppLogic/Services/IRegionPackInstaller.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace LageBuch.AppLogic.Services;

/// <summary>Downloads and unpacks a region pack (#150 follow-up) into a local folder.</summary>
public interface IRegionPackInstaller
{
/// <summary>Returns the folder the pack was extracted into (ready to use as Einsatzgebiet.FolderPath).</summary>
Task<string> DownloadAndInstallAsync(RegionPackInfo pack, IProgress<double>? progress, CancellationToken ct = default);
}
85 changes: 85 additions & 0 deletions src/LageBuch.AppLogic/Services/RegionPackCatalogJson.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
using System.Text.Json;
using System.Text.RegularExpressions;

namespace LageBuch.AppLogic.Services;

/// <summary>
/// Reads the region-pack manifest format (#150 follow-up) — a flat JSON array, each entry
/// describing one downloadable pack. Defensive like <c>MasterDataJson</c>: malformed input, or an
/// entry missing a required field, is skipped rather than thrown — the manifest is fetched from a
/// third-party-controlled URL, so a partially bad response must degrade gracefully, not crash
/// Stammdaten.
/// </summary>
public static class RegionPackCatalogJson
{
// RegionPackInstaller joins this straight onto a base directory (<regionsBaseDir>/<slug>) —
// reject anything that could escape that directory (path separators, "..", empty) here rather
// than trusting the installer alone to catch it.
private static readonly Regex SafeSlug = new("^[a-z0-9][a-z0-9_-]{0,63}$", RegexOptions.Compiled);

public static IReadOnlyList<RegionPackInfo> Parse(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Array)
return Array.Empty<RegionPackInfo>();

var result = new List<RegionPackInfo>();
foreach (var entry in doc.RootElement.EnumerateArray())
{
if (TryParseEntry(entry, out var region))
result.Add(region);
}
return result;
}
catch (JsonException)
{
return Array.Empty<RegionPackInfo>();
}
}

private static bool TryParseEntry(JsonElement entry, out RegionPackInfo region)
{
region = null!;
if (entry.ValueKind != JsonValueKind.Object)
return false;

if (!TryGetString(entry, "name", out var name) ||
!TryGetString(entry, "slug", out var slug) || !SafeSlug.IsMatch(slug) ||
!TryGetString(entry, "downloadUrl", out var downloadUrl) ||
!TryGetString(entry, "builtAt", out var builtAt) ||
!TryGetString(entry, "attribution", out var attribution) ||
!entry.TryGetProperty("sizeBytes", out var sizeBytesEl) || sizeBytesEl.ValueKind != JsonValueKind.Number ||
!entry.TryGetProperty("boundingBox", out var bbox) || bbox.ValueKind != JsonValueKind.Object ||
!TryGetNumber(bbox, "minLat", out var minLat) ||
!TryGetNumber(bbox, "minLon", out var minLon) ||
!TryGetNumber(bbox, "maxLat", out var maxLat) ||
!TryGetNumber(bbox, "maxLon", out var maxLon))
{
return false;
}

region = new RegionPackInfo(name, slug, downloadUrl, sizeBytesEl.GetInt64(),
minLat, minLon, maxLat, maxLon, builtAt, attribution);
return true;
}

private static bool TryGetString(JsonElement e, string prop, out string value)
{
value = string.Empty;
if (!e.TryGetProperty(prop, out var v) || v.ValueKind != JsonValueKind.String)
return false;
value = v.GetString()!;
return true;
}

private static bool TryGetNumber(JsonElement e, string prop, out double value)
{
value = 0;
if (!e.TryGetProperty(prop, out var v) || v.ValueKind != JsonValueKind.Number)
return false;
value = v.GetDouble();
return true;
}
}
23 changes: 23 additions & 0 deletions src/LageBuch.AppLogic/Services/RegionPackCatalogService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace LageBuch.AppLogic.Services;

/// <summary>
/// Fetches the region-pack manifest over HTTP (#150 follow-up). The one place this offline-first
/// app makes an unprompted network call — but only when the operator opens the Einsatzgebiet
/// section, and it degrades to an empty list rather than surfacing any error, so Stammdaten stays
/// fully usable without a connection.
/// </summary>
public sealed class RegionPackCatalogService(HttpClient httpClient, string manifestUrl) : IRegionPackCatalogService
{
public async Task<IReadOnlyList<RegionPackInfo>> GetAvailableRegionsAsync(CancellationToken ct = default)
{
try
{
var json = await httpClient.GetStringAsync(manifestUrl, ct);
return RegionPackCatalogJson.Parse(json);
}
catch (HttpRequestException)
{
return Array.Empty<RegionPackInfo>();
}
}
}
63 changes: 63 additions & 0 deletions src/LageBuch.AppLogic/Services/RegionPackInstaller.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using System.IO.Compression;

namespace LageBuch.AppLogic.Services;

/// <summary>
/// Downloads a region pack's zip (region.mbtiles + region.dem) and extracts it into
/// <c>&lt;regionsBaseDir&gt;/&lt;slug&gt;</c> (#150 follow-up). A re-install of the same slug replaces the
/// folder outright, so a pack update never leaves stale files from a previous version behind.
/// </summary>
public sealed class RegionPackInstaller(HttpClient httpClient, string regionsBaseDir) : IRegionPackInstaller
{
// Downloading is the slow part; reserve a small tail of the progress range for extraction so
// the caller sees forward motion continue past "download done" instead of jumping straight to 1.0.
private const double DownloadProgressShare = 0.9;

public async Task<string> DownloadAndInstallAsync(RegionPackInfo pack, IProgress<double>? progress, CancellationToken ct = default)
{
progress?.Report(0.0);

var zipBytes = await DownloadAsync(pack.DownloadUrl, progress, ct);

// Defense in depth: RegionPackCatalogJson already rejects unsafe slugs when parsing the
// manifest, but a slug ending up here from anywhere else must not be able to escape
// regionsBaseDir either.
var baseFull = Path.GetFullPath(regionsBaseDir) + Path.DirectorySeparatorChar;
var folder = Path.GetFullPath(Path.Combine(regionsBaseDir, pack.Slug));
if (!folder.StartsWith(baseFull, StringComparison.Ordinal))
throw new InvalidOperationException($"Region slug '{pack.Slug}' escapes the regions directory.");

if (Directory.Exists(folder))
Directory.Delete(folder, recursive: true);
Directory.CreateDirectory(folder);

using (var zip = new ZipArchive(new MemoryStream(zipBytes), ZipArchiveMode.Read))
zip.ExtractToDirectory(folder);

progress?.Report(1.0);
return folder;
}

private async Task<byte[]> DownloadAsync(string url, IProgress<double>? progress, CancellationToken ct)
{
using var response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
response.EnsureSuccessStatusCode();

var totalBytes = response.Content.Headers.ContentLength;
await using var source = await response.Content.ReadAsStreamAsync(ct);
using var buffer = new MemoryStream();

var chunk = new byte[81920];
long readSoFar = 0;
int read;
while ((read = await source.ReadAsync(chunk, ct)) > 0)
{
await buffer.WriteAsync(chunk.AsMemory(0, read), ct);
readSoFar += read;
if (totalBytes is > 0)
progress?.Report(Math.Min(DownloadProgressShare, (double)readSoFar / totalBytes.Value * DownloadProgressShare));
}

return buffer.ToArray();
}
}
Loading
Loading