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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,6 @@ Docs/security-backlog.md
deploy/.env
deploy/models/*
!deploy/models/.gitkeep
# The Google Home HomeGraph service-account key lands here. It is a live Google credential.
deploy/secrets/

37 changes: 37 additions & 0 deletions App/serval_app/lib/data/live_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../models/activity.dart';
import '../models/alert.dart';
import '../models/camera.dart';
import '../models/cast_target.dart';
import '../models/clip_selection.dart';
import '../models/config_backup.dart';
import '../models/conversation.dart';
import '../models/saved_clip.dart';
import '../media/media_saver.dart';
import '../media/media_sharer.dart';
import '../models/google_home.dart';
import '../models/ptz.dart';
import '../models/push.dart';
import '../models/server_settings.dart';
Expand Down Expand Up @@ -2163,6 +2165,16 @@ class LiveServalRepository implements ServalRepository {
);
}

@override
Future<GoogleHomeStatus> googleHomeStatus() => _api.googleHomeStatus();

@override
Future<List<GoogleHomeLink>> googleHomeLinks() => _api.googleHomeLinks();

@override
Future<void> unlinkGoogleHome(String agentUserId) =>
_api.unlinkGoogleHome(agentUserId);

@override
Future<PushConfig> pushConfig() => _api.pushConfig();

Expand Down Expand Up @@ -2565,6 +2577,31 @@ class LiveServalRepository implements ServalRepository {
@override
Future<void> deleteClip(String id) => _api.deleteClip(id);

@override
Future<String?> castReceiverAppId() => _api.castReceiverAppId();

@override
Future<Uri?> castVodUrl(
String cameraId, {
required DateTime from,
required DateTime to,
required DateTime at,
}) async {
final token = await _auth.mintStreamToken();
if (token == null) return null;

return _api.castVodUrl(
cameraId,
from: from,
to: to,
at: at,
streamToken: token,
);
}

@override
Future<CastTarget?> castTarget(String cameraId) => _api.castTarget(cameraId);

@override
Future<Uri?> savedClipUrl(String id) async =>
_api.savedClipUrl(id, streamToken: await _auth.mintStreamToken());
Expand Down
45 changes: 45 additions & 0 deletions App/serval_app/lib/data/sample_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import 'package:flutter/painting.dart';
import '../models/activity.dart';
import '../models/alert.dart';
import '../models/camera.dart';
import '../models/cast_target.dart';
import '../models/clip_selection.dart';
import '../models/saved_clip.dart';
import '../models/config_backup.dart';
import '../models/conversation.dart';
import '../media/media_saver.dart';
import '../models/google_home.dart';
import '../models/ptz.dart';
import '../models/push.dart';
import '../models/server_settings.dart';
Expand Down Expand Up @@ -249,6 +251,33 @@ class SampleServalRepository implements ServalRepository {
List<CameraNotificationRule>? rules,
}) async {}

/// **Switched off, which is the state nearly every deployment is in** — and the more useful one
/// to draw, because it is the state the card exists to explain. Turning it on needs a public
/// HTTPS endpoint and a Nest Hub on the LAN; a sample that pretended to have both would show the
/// one layout an operator is least likely to see.
///
/// The sentence is the Server's own wording, copied rather than invented: the App renders
/// whatever reason it is handed, so the sample has to prove that path rather than a local map.
@override
Future<GoogleHomeStatus> googleHomeStatus() async => const GoogleHomeStatus(
effective: false,
blocker: 'disabled',
reason:
'Serval:GoogleHome:Enabled is false, so the Google Home routes are closed. '
'This is the default. See Docs/google-home.md for what it needs before turning it on.',
publicBaseUrl: null,
homeGraphKeyConfigured: false,
castReceiverConfigured: false,
);

/// Nothing linked, matching the status above — a linked account on a disabled integration would
/// be a state the Server cannot produce.
@override
Future<List<GoogleHomeLink>> googleHomeLinks() async => const [];

@override
Future<void> unlinkGoogleHome(String agentUserId) async {}

/// A deployment with notifications on and a key that looks like a real one. The sample never
/// reaches a browser's push machinery — `PushClient` is the stub under `flutter test` — so this
/// only has to be well-formed enough for the screen to draw its enabled state.
Expand Down Expand Up @@ -467,6 +496,22 @@ class SampleServalRepository implements ServalRepository {
@override
Future<void> deleteClip(String id) async {}

/// No server, so no receiver to launch and nothing for one to fetch — the button stays absent in
/// samples, the same posture [canStreamLive] takes on the wall.
@override
Future<String?> castReceiverAppId() async => null;

@override
Future<Uri?> castVodUrl(
String cameraId, {
required DateTime from,
required DateTime to,
required DateTime at,
}) async => null;

@override
Future<CastTarget?> castTarget(String cameraId) async => null;

/// Null, so the sample clip screens draw their placeholder rather than reaching for a Server —
/// the same posture [canStreamLive] takes on the wall.
@override
Expand Down
76 changes: 76 additions & 0 deletions App/serval_app/lib/data/serval_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import 'package:http/http.dart' as http;

import '../models/alert.dart';
import '../models/camera.dart';
import '../models/cast_target.dart';
import '../models/clip_selection.dart';
import '../models/config_backup.dart';
import '../models/google_home.dart';
import '../models/ptz.dart';
import '../models/push.dart';
import '../models/saved_clip.dart';
Expand Down Expand Up @@ -325,6 +327,32 @@ class ServalApi {
PushDevice.fromJson(device as Map<String, dynamic>),
];

// --------------------------------------------------------------- google home
//
// Three reads and one action, and deliberately no writes: every Serval:GoogleHome:* key is
// environment-only, so there is nothing here for a form to submit. See Docs/google-home.md.

Future<GoogleHomeStatus> googleHomeStatus() async =>
GoogleHomeStatus.fromJson(
await _getJson('/api/google/status') as Map<String, dynamic>,
);

Future<List<GoogleHomeLink>> googleHomeLinks() async => [
for (final link in await _getJson('/api/google/links') as List<dynamic>)
GoogleHomeLink.fromJson(link as Map<String, dynamic>),
];

/// Unlinks the Google account, revoking every credential issued to it. Google keeps showing the
/// cameras until it next calls and is refused.
Future<void> unlinkGoogleHome(String agentUserId) async {
final response = await _client.delete(
config.resolve('/api/google/links/$agentUserId'),
);
if (response.statusCode != 204) {
throw ServalApiException(response.statusCode, _errorMessage(response));
}
}

/// Registers this browser, or refreshes what the Server holds for it.
///
/// Called on every launch, not only when somebody turns notifications on: the row is keyed by a
Expand Down Expand Up @@ -722,6 +750,35 @@ class ServalApi {
await _getJson('/api/clips/$id/status') as Map<String, dynamic>,
);

/// The Cast application this deployment casts with, or null where none is registered.
///
/// Read before anything is cast, because Google's sender SDK finds no devices until it knows
/// which application to look for. 404 is the ordinary answer on a deployment that has not set one
/// up, so it is not a failure.
Future<String?> castReceiverAppId() async {
final response = await _client.get(config.resolve('/api/cast/receiver'));
if (response.statusCode == 404 || response.statusCode == 503) return null;

final json = _decode(response) as Map<String, dynamic>;
final id = json['receiverAppId']?.toString();
return (id == null || id.isEmpty) ? null : id;
}

/// Which Cast receiver to launch for a camera, and what to hand it.
///
/// Null where this deployment cannot cast — no Cast application registered (501), or the
/// configuration the receiver is served behind is incomplete (503). Both are ordinary states
/// rather than faults, and the button is simply absent in each, so neither throws.
Future<CastTarget?> castTarget(String cameraId) async {
final response = await _client.post(
config.resolve('/api/cameras/$cameraId/cast'),
);

if (response.statusCode == 501 || response.statusCode == 503) return null;

return CastTarget.fromJson(_decode(response) as Map<String, dynamic>);
}

Future<void> renameClip(String id, String name) async {
final response = await _client.patch(
config.resolve('/api/clips/$id'),
Expand Down Expand Up @@ -871,6 +928,25 @@ class ServalApi {
'to': to.toUtc().toIso8601String(),
});

/// The same window, re-encoded for a television.
///
/// A separate route rather than a flag on [vodUrl], because the two want opposite things: this
/// player wants the recording untouched, and a Cast device cannot decode it at all — every camera
/// here records above the 1080p a Cast device will take. [streamToken] rides in the URL because a
/// Cast receiver cannot set a header, and the segments inherit it.
Uri castVodUrl(
String cameraId, {
required DateTime from,
required DateTime to,
required DateTime at,
required String streamToken,
}) => config.resolve('/api/cameras/$cameraId/cast.m3u8', {
'from': from.toUtc().toIso8601String(),
'to': to.toUtc().toIso8601String(),
'at': at.toUtc().toIso8601String(),
'stream_token': streamToken,
});

/// How far into the playlist the instant it was asked for actually sits.
///
/// Segments are four seconds and a window can be asked for at any instant inside one, so the
Expand Down
41 changes: 41 additions & 0 deletions App/serval_app/lib/data/serval_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import 'package:flutter/foundation.dart';
import '../models/activity.dart';
import '../models/alert.dart';
import '../models/camera.dart';
import '../models/cast_target.dart';
import '../models/clip_selection.dart';
import '../models/config_backup.dart';
import '../models/conversation.dart';
import '../media/media_saver.dart';
import '../models/google_home.dart';
import '../models/ptz.dart';
import '../models/push.dart';
import '../models/saved_clip.dart';
Expand Down Expand Up @@ -419,6 +421,22 @@ abstract interface class ServalRepository {
// only: which alerts reach this person is [notificationPreferences] above, because that belongs
// to the account and this belongs to one of their browsers.

// ----------------------------------------------------------- google home
//
// Read-only, on purpose. Every Serval:GoogleHome:* key is environment-only — see
// Docs/google-home.md — so what the App offers is diagnosis, not configuration: which one
// condition is unmet, and whether an account is linked. Unlinking is the single exception,
// because it is an act rather than a setting.

/// Whether the Google Home integration is live, and the sentence naming what is stopping it.
Future<GoogleHomeStatus> googleHomeStatus();

/// The linked Google account. At most one, and usually none.
Future<List<GoogleHomeLink>> googleHomeLinks();

/// Unlinks, revoking every credential issued to Google.
Future<void> unlinkGoogleHome(String agentUserId);

/// The deployment's VAPID public key and whether notifications are switched on at all.
Future<PushConfig> pushConfig();

Expand Down Expand Up @@ -576,6 +594,29 @@ abstract interface class ServalRepository {

Future<void> deleteClip(String id);

/// The Cast application this deployment casts with, or null where none is registered — which is
/// also what a deployment with no Server answers.
Future<String?> castReceiverAppId();

/// A VOD playlist a Cast device can fetch on its own, for a past window.
///
/// Separate from [vodUrlFor] because the credential differs, not the playlist: a Cast receiver
/// cannot set an `Authorization` header, so the token has to ride in the URL — and the segments
/// inherit it, since the receiver resolves their names against this URL. Null where there is no
/// Server to mint one against.
Future<Uri?> castVodUrl(
String cameraId, {
required DateTime from,
required DateTime to,
required DateTime at,
});

/// Which Cast receiver to launch for a camera, and what to hand it.
///
/// Null where this deployment cannot cast: no Cast application registered, or no Server at all.
/// That is the ordinary case rather than a failure — the button is absent, not broken.
Future<CastTarget?> castTarget(String cameraId);

/// The clip's video, ready for a player or a download. Null where there is no Server.
Future<Uri?> savedClipUrl(String id);

Expand Down
7 changes: 7 additions & 0 deletions App/serval_app/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import 'data/sample_repository.dart';
import 'data/serval_api.dart';
import 'data/serval_config.dart';
import 'data/serval_repository.dart';
import 'platform/frame_watchdog.dart';
import 'playback/vod_player.dart';
import 'push/push_client.dart';
import 'router/serval_router.dart';
Expand Down Expand Up @@ -121,6 +122,12 @@ class _ServalMaterialAppState extends State<_ServalMaterialApp> {
//
// A no-op off the web and on a browser with no push, so there is no platform branch here.
PushClient.onNavigate(_router.go);

// The other half of a tap landing where it was sent. Routing it is only half the job: a
// browser that has come back from the background unable to paint holds the screen it was on
// however correct the route underneath it is. See [watchFrames], including why the route it
// recovers to has to come from the router rather than from the address bar.
watchFrames(() => _router.routeInformationProvider.value.uri.toString());
}

@override
Expand Down
Loading
Loading