From ab69108f421113a087f14d511543ec0a2ccc048b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:07:51 +0300 Subject: [PATCH 01/24] Core: streaming media with DRM (StreamRequest, DrmConfig, DrmScheme) AES-128 HLS on every port whose player decrypts it, Widevine/ClearKey on Android through the framework MediaPlayer DRM API, FairPlay on iOS through AVContentKeySession, and stream request headers on Android. Co-Authored-By: Claude Opus 5.5 --- .../impl/CodenameOneImplementation.java | 55 +++ .../src/com/codename1/media/DrmConfig.java | 162 ++++++ .../src/com/codename1/media/DrmScheme.java | 54 ++ .../src/com/codename1/media/MediaManager.java | 24 + .../com/codename1/media/StreamRequest.java | 238 +++++++++ CodenameOne/src/com/codename1/ui/Display.java | 41 ++ .../impl/android/AndroidImplementation.java | 59 +++ .../codename1/impl/android/DrmVideoView.java | 465 ++++++++++++++++++ .../com/codename1/impl/javase/JavaSEPort.java | 8 + Ports/iOSPort/nativeSources/IOSNative.m | 179 +++++++ .../codename1/impl/ios/IOSImplementation.java | 52 +- .../src/com/codename1/impl/ios/IOSNative.java | 4 + .../codename1/media/StreamRequestTest.java | 70 +++ 13 files changed, 1410 insertions(+), 1 deletion(-) create mode 100644 CodenameOne/src/com/codename1/media/DrmConfig.java create mode 100644 CodenameOne/src/com/codename1/media/DrmScheme.java create mode 100644 CodenameOne/src/com/codename1/media/StreamRequest.java create mode 100644 Ports/Android/src/com/codename1/impl/android/DrmVideoView.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/media/StreamRequestTest.java diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index d546b46a245..d3bb504ee0f 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -5744,6 +5744,61 @@ public Media createMedia(String uri, boolean isVideo, Runnable onCompletion) thr return null; } + /// Creates the platform player for a streaming request. The default plays an + /// unprotected or [com.codename1.media.DrmScheme#AES_128] stream through + /// `#createMedia(String, boolean, Runnable)`, since the platform players that + /// play HLS decrypt AES-128 themselves, and seeks to the start position once the + /// player exists. A port that supports more schemes or headers overrides this + /// together with `#isDrmSupported` and `#isStreamHeadersSupported`. + /// + /// #### Parameters + /// + /// - `request`: the stream to play + /// + /// #### Returns + /// + /// the media, or null when the platform cannot create a player + /// + /// #### Throws + /// + /// - `java.io.IOException`: if the stream cannot be opened + public Media createStreamingMedia(com.codename1.media.StreamRequest request) throws IOException { + com.codename1.media.DrmConfig drm = request.getDrm(); + if (drm != null && drm.getScheme() != com.codename1.media.DrmScheme.AES_128) { + throw new IOException(drm.getScheme() + " is not supported on this platform"); + } + Media m = createMedia(request.getUrl(), true, request.getOnCompletion()); + if (m != null && request.getStartPosition() > 0) { + m.setTime(request.getStartPosition()); + } + return m; + } + + /// Answers whether the platform player can play streams protected by a scheme. + /// The default answers false for every scheme; a port whose player decrypts HLS + /// AES-128 answers true for [com.codename1.media.DrmScheme#AES_128]. + /// + /// #### Parameters + /// + /// - `scheme`: the protection scheme + /// + /// #### Returns + /// + /// true when the scheme is supported + public boolean isDrmSupported(com.codename1.media.DrmScheme scheme) { + return false; + } + + /// Answers whether the platform player can attach HTTP headers to the manifest + /// and segment requests of a stream. The default answers false. + /// + /// #### Returns + /// + /// true when stream request headers are supported + public boolean isStreamHeadersSupported() { + return false; + } + /// Creates media asynchronously. /// /// #### Parameters diff --git a/CodenameOne/src/com/codename1/media/DrmConfig.java b/CodenameOne/src/com/codename1/media/DrmConfig.java new file mode 100644 index 00000000000..d08a18211bf --- /dev/null +++ b/CodenameOne/src/com/codename1/media/DrmConfig.java @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.media; + +import java.util.HashMap; +import java.util.Map; + +/// How a [StreamRequest] obtains the keys of a protected stream: the scheme, the +/// license server URL, and whatever that server needs to authorize the request. +/// +/// The license exchange is made by the platform player, or by the port on the +/// player's behalf. The request body is the opaque challenge the scheme defines and +/// the response goes back to the player unread, so the license server is the only +/// party that interprets either: +/// +/// ```java +/// DrmConfig drm = new DrmConfig(DrmScheme.WIDEVINE, "https://example.com/license/widevine") +/// .addLicenseHeader("Authorization", "Bearer " + playbackToken); +/// ``` +/// +/// [DrmScheme#AES_128] needs no license URL: the key URL is in the playlist, and any +/// token it needs belongs in that URL. +public final class DrmConfig { + private final DrmScheme scheme; + private final String licenseUrl; + private String certificateUrl; + private String contentId; + private final Map licenseHeaders = new HashMap(); + + /// Creates a configuration for the given scheme. + /// + /// #### Parameters + /// + /// - `scheme`: the protection scheme the stream is packaged with + /// + /// - `licenseUrl`: where the player posts its license challenge; may be null only + /// for [DrmScheme#AES_128], whose key URL is part of the playlist + public DrmConfig(DrmScheme scheme, String licenseUrl) { + if (scheme == null) { + throw new IllegalArgumentException("scheme is required"); + } + if (licenseUrl == null && scheme != DrmScheme.AES_128) { + throw new IllegalArgumentException(scheme + " needs a license URL"); + } + this.scheme = scheme; + this.licenseUrl = licenseUrl; + } + + /// The protection scheme. + /// + /// #### Returns + /// + /// the scheme this configuration was created with + public DrmScheme getScheme() { + return scheme; + } + + /// Where the player posts its license challenge. + /// + /// #### Returns + /// + /// the license URL, or null for [DrmScheme#AES_128] + public String getLicenseUrl() { + return licenseUrl; + } + + /// Sets the URL of the FairPlay application certificate, the DER file Apple + /// issues to the content owner. Required for [DrmScheme#FAIRPLAY] and ignored by + /// every other scheme. + /// + /// #### Parameters + /// + /// - `certificateUrl`: the certificate URL + /// + /// #### Returns + /// + /// this configuration, for chaining + public DrmConfig setCertificateUrl(String certificateUrl) { + this.certificateUrl = certificateUrl; + return this; + } + + /// The FairPlay application certificate URL. + /// + /// #### Returns + /// + /// the certificate URL, or null when none was set + public String getCertificateUrl() { + return certificateUrl; + } + + /// Sets the FairPlay content identifier passed to the key request. When none is + /// set, the host of the `skd://` key URL in the playlist is used, which is the + /// common convention. + /// + /// #### Parameters + /// + /// - `contentId`: the content identifier + /// + /// #### Returns + /// + /// this configuration, for chaining + public DrmConfig setContentId(String contentId) { + this.contentId = contentId; + return this; + } + + /// The FairPlay content identifier. + /// + /// #### Returns + /// + /// the content identifier, or null to derive it from the key URL + public String getContentId() { + return contentId; + } + + /// Adds an HTTP header sent with every license request, typically the + /// authorization the license server checks the viewer's entitlement with. + /// + /// #### Parameters + /// + /// - `name`: the header name + /// + /// - `value`: the header value + /// + /// #### Returns + /// + /// this configuration, for chaining + public DrmConfig addLicenseHeader(String name, String value) { + licenseHeaders.put(name, value); + return this; + } + + /// The headers sent with every license request. + /// + /// #### Returns + /// + /// a copy of the headers, keyed by name + public Map getLicenseHeaders() { + return new HashMap(licenseHeaders); + } +} diff --git a/CodenameOne/src/com/codename1/media/DrmScheme.java b/CodenameOne/src/com/codename1/media/DrmScheme.java new file mode 100644 index 00000000000..f5df4a1611c --- /dev/null +++ b/CodenameOne/src/com/codename1/media/DrmScheme.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.media; + +/// A content protection scheme a [StreamRequest] can ask the platform player to +/// honour. Not every platform plays every scheme; ask +/// [MediaManager#isDrmSupported(DrmScheme)] first and pick the strongest scheme the +/// device answers yes to. +/// +/// A streaming service usually packages each title once with CENC and serves it +/// with whichever scheme the requesting device supports: FairPlay to Apple +/// devices, Widevine to Android, and HLS AES-128 where neither is available. +public enum DrmScheme { + /// HLS segment encryption with the key fetched from the `URI` of the + /// playlist's `#EXT-X-KEY` tag. Every native player that plays HLS decrypts it, + /// so it works on the most devices. The key reaches the client in the clear + /// (over TLS), which makes it a deterrent rather than hardware-backed + /// protection: guard the key URL with a short-lived, per-session token. + AES_128, + + /// The W3C ClearKey key system: CENC-encrypted media with keys delivered as a + /// JSON Web Key set. The same packaging as Widevine without a license vendor, + /// which makes it the scheme to develop and test a license server against. + CLEARKEY, + + /// Google Widevine, the protection Android devices implement, with + /// hardware-backed keys (L1) on most phones. + WIDEVINE, + + /// Apple FairPlay Streaming, the protection iOS, iPadOS and tvOS implement. + /// Needs the application certificate from Apple's FairPlay program, set with + /// [DrmConfig#setCertificateUrl(String)]. + FAIRPLAY +} diff --git a/CodenameOne/src/com/codename1/media/MediaManager.java b/CodenameOne/src/com/codename1/media/MediaManager.java index 72ea3d66e1b..766b4158554 100644 --- a/CodenameOne/src/com/codename1/media/MediaManager.java +++ b/CodenameOne/src/com/codename1/media/MediaManager.java @@ -562,6 +562,30 @@ public static Media createMedia(String uri, boolean isVideo, Runnable onCompleti return Display.getInstance().createMedia(uri, isVideo, onCompletion); } + /// Answers whether this platform can play streams protected by a DRM scheme. + /// See [StreamRequest]. + /// + /// #### Parameters + /// + /// - `scheme`: the protection scheme + /// + /// #### Returns + /// + /// true when [StreamRequest#create()] can play a stream protected by the scheme + public static boolean isDrmSupported(DrmScheme scheme) { + return Display.getInstance().isDrmSupported(scheme); + } + + /// Answers whether this platform can attach HTTP headers to the requests a + /// stream's player makes. See [StreamRequest#addHeader(String, String)]. + /// + /// #### Returns + /// + /// true when stream request headers are supported + public static boolean isStreamHeadersSupported() { + return Display.getInstance().isStreamHeadersSupported(); + } + /// Creates a Media from a given URI asynchronously. /// /// #### Parameters diff --git a/CodenameOne/src/com/codename1/media/StreamRequest.java b/CodenameOne/src/com/codename1/media/StreamRequest.java new file mode 100644 index 00000000000..8abdaa35beb --- /dev/null +++ b/CodenameOne/src/com/codename1/media/StreamRequest.java @@ -0,0 +1,238 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.media; + +import com.codename1.ui.Display; +import com.codename1.util.AsyncResource; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +/// Describes an adaptive (HLS or DASH) or progressive video stream, optionally +/// protected by DRM, and creates the platform player for it. +/// +/// ```java +/// StreamRequest req = new StreamRequest(manifestUrl) +/// .setDrm(new DrmConfig(DrmScheme.WIDEVINE, licenseUrl) +/// .addLicenseHeader("Authorization", "Bearer " + token)) +/// .setStartPosition(resumeAtMillis); +/// if (!req.isSupported()) { +/// // ask the server for a scheme this device can play +/// } +/// Media video = req.create(); +/// form.add(BorderLayout.CENTER, video.getVideoComponent()); +/// ``` +/// +/// The player is the platform's own (AVPlayer on Apple platforms, the framework +/// media player on Android, ffmpeg in the simulator), so the protocols, codecs and +/// DRM schemes it plays are the platform's. [#isSupported()] answers for the scheme +/// and the headers; a codec the device cannot decode surfaces as a playback error. +/// +/// To hide a protected video from screenshots and screen recording while it is on +/// screen, call `Display.getInstance().setProperty("DisableScreenshots", "true")` +/// and set it back to `"false"` when the player closes. +public final class StreamRequest { + private final String url; + private DrmConfig drm; + private int startPosition; + private Runnable onCompletion; + private final Map headers = new HashMap(); + + /// Creates a request for the stream at the given URL. + /// + /// #### Parameters + /// + /// - `url`: the HLS (`.m3u8`) or DASH (`.mpd`) manifest, or a progressive file + public StreamRequest(String url) { + if (url == null || url.length() == 0) { + throw new IllegalArgumentException("url is required"); + } + this.url = url; + } + + /// The stream URL. + /// + /// #### Returns + /// + /// the manifest or file URL + public String getUrl() { + return url; + } + + /// Sets the content protection of the stream. + /// + /// #### Parameters + /// + /// - `drm`: the DRM configuration, or null for an unprotected stream + /// + /// #### Returns + /// + /// this request, for chaining + public StreamRequest setDrm(DrmConfig drm) { + this.drm = drm; + return this; + } + + /// The content protection of the stream. + /// + /// #### Returns + /// + /// the DRM configuration, or null for an unprotected stream + public DrmConfig getDrm() { + return drm; + } + + /// Sets where playback starts, for resuming a title the viewer left part way. + /// + /// #### Parameters + /// + /// - `millis`: the start position in milliseconds + /// + /// #### Returns + /// + /// this request, for chaining + public StreamRequest setStartPosition(int millis) { + this.startPosition = Math.max(0, millis); + return this; + } + + /// Where playback starts. + /// + /// #### Returns + /// + /// the start position in milliseconds + public int getStartPosition() { + return startPosition; + } + + /// Sets a callback invoked when playback reaches the end of the stream. + /// + /// #### Parameters + /// + /// - `onCompletion`: the callback, or null + /// + /// #### Returns + /// + /// this request, for chaining + public StreamRequest setOnCompletion(Runnable onCompletion) { + this.onCompletion = onCompletion; + return this; + } + + /// The completion callback. + /// + /// #### Returns + /// + /// the callback, or null + public Runnable getOnCompletion() { + return onCompletion; + } + + /// Adds an HTTP header to the manifest and segment requests. Only some platforms + /// can attach headers to the requests their player makes (see + /// [MediaManager#isStreamHeadersSupported()]); a URL that carries its own signed + /// token works everywhere and is the portable choice. + /// + /// #### Parameters + /// + /// - `name`: the header name + /// + /// - `value`: the header value + /// + /// #### Returns + /// + /// this request, for chaining + public StreamRequest addHeader(String name, String value) { + headers.put(name, value); + return this; + } + + /// The headers for the manifest and segment requests. + /// + /// #### Returns + /// + /// a copy of the headers, keyed by name + public Map getHeaders() { + return new HashMap(headers); + } + + /// Answers whether this platform can play the request: its DRM scheme, and its + /// headers if it has any. + /// + /// #### Returns + /// + /// true when [#create()] can be expected to return a player + public boolean isSupported() { + if (drm != null && !MediaManager.isDrmSupported(drm.getScheme())) { + return false; + } + return headers.isEmpty() || MediaManager.isStreamHeadersSupported(); + } + + /// Creates the player. Blocks until the platform has created it, so call it off + /// the EDT or use [#createAsync()]. + /// + /// #### Returns + /// + /// the media, whose `getVideoComponent()` is the view to place in a form + /// + /// #### Throws + /// + /// - `IOException`: when the platform cannot play the request, which + /// [#isSupported()] predicts, or the stream cannot be opened + public Media create() throws IOException { + if (drm != null && !MediaManager.isDrmSupported(drm.getScheme())) { + throw new IOException(drm.getScheme() + " is not supported on this platform"); + } + if (!headers.isEmpty() && !MediaManager.isStreamHeadersSupported()) { + throw new IOException("Stream request headers are not supported on this platform;" + + " carry the credential in the URL instead"); + } + Media m = Display.getInstance().createStreamingMedia(this); + if (m == null) { + throw new IOException("The platform could not create a player for " + url); + } + return m; + } + + /// Creates the player without blocking the calling thread. + /// + /// #### Returns + /// + /// a resource completed with the media, or with the error [#create()] would throw + public AsyncResource createAsync() { + final AsyncResource out = new AsyncResource(); + Display.getInstance().startThread(new Runnable() { + @Override + public void run() { + try { + out.complete(create()); + } catch (Throwable t) { + out.error(t); + } + } + }, "StreamRequest").start(); + return out; + } +} diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index 144056b7572..8f7356d7fde 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -6081,6 +6081,47 @@ public Media createMedia(String uri, boolean isVideo, Runnable onCompletion) thr return impl.createMedia(uri, isVideo, onCompletion); } + /// Creates the platform player for a streaming request. Applications call + /// `com.codename1.media.StreamRequest#create()`, which checks support first. + /// + /// #### Parameters + /// + /// - `request`: the stream to play + /// + /// #### Returns + /// + /// the media, or null when the platform cannot create a player + /// + /// #### Throws + /// + /// - `java.io.IOException`: if the stream cannot be opened + public Media createStreamingMedia(com.codename1.media.StreamRequest request) throws IOException { + return impl.createStreamingMedia(request); + } + + /// Answers whether the platform player can play streams protected by a scheme. + /// + /// #### Parameters + /// + /// - `scheme`: the protection scheme + /// + /// #### Returns + /// + /// true when the scheme is supported + public boolean isDrmSupported(com.codename1.media.DrmScheme scheme) { + return impl.isDrmSupported(scheme); + } + + /// Answers whether the platform player can attach HTTP headers to the manifest + /// and segment requests of a stream. + /// + /// #### Returns + /// + /// true when stream request headers are supported + public boolean isStreamHeadersSupported() { + return impl.isStreamHeadersSupported(); + } + /// Creates media asynchronously. /// /// #### Parameters diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index fa81cfe3ef6..cddb1b213ef 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -4769,6 +4769,65 @@ public void cleanup() { } + @Override + public boolean isDrmSupported(com.codename1.media.DrmScheme scheme) { + if (scheme == com.codename1.media.DrmScheme.AES_128) { + // The framework player's HLS extractor decrypts #EXT-X-KEY METHOD=AES-128. + return true; + } + return DrmVideoView.isSupported(scheme); + } + + @Override + public boolean isStreamHeadersSupported() { + // VideoView.setVideoURI(Uri, Map) and MediaPlayer.setDataSource(Context, Uri, Map) + // send the headers with the manifest and every segment request. + return true; + } + + @Override + public Media createStreamingMedia(final com.codename1.media.StreamRequest request) throws IOException { + if (getActivity() == null) { + return null; + } + final com.codename1.media.DrmConfig drm = request.getDrm(); + final boolean cenc = drm != null && drm.getScheme() != com.codename1.media.DrmScheme.AES_128; + if (cenc && !DrmVideoView.isSupported(drm.getScheme())) { + throw new IOException(drm.getScheme() + " is not supported on this device"); + } + final java.util.Map headers = request.getHeaders(); + final Uri videoUri = Uri.parse(request.getUrl()); + final AndroidImplementation.Video[] video = new AndroidImplementation.Video[1]; + final java.util.concurrent.CountDownLatch created = new java.util.concurrent.CountDownLatch(1); + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + try { + VideoView v; + if (cenc) { + v = new DrmVideoView(getActivity(), videoUri, headers, drm, request.getStartPosition()); + } else { + v = new VideoView(getActivity()); + v.setVideoURI(videoUri, headers); + if (request.getStartPosition() > 0) { + v.seekTo(request.getStartPosition()); + } + } + v.setZOrderMediaOverlay(true); + video[0] = new AndroidImplementation.Video(v, getActivity(), request.getOnCompletion()); + } finally { + created.countDown(); + } + } + }); + try { + created.await(); + } catch (InterruptedException ex) { + throw new IOException("Interrupted while creating the player"); + } + return video[0]; + } + /** * @inheritDoc */ diff --git a/Ports/Android/src/com/codename1/impl/android/DrmVideoView.java b/Ports/Android/src/com/codename1/impl/android/DrmVideoView.java new file mode 100644 index 00000000000..0628ba5b936 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/DrmVideoView.java @@ -0,0 +1,465 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.android; + +import android.content.Context; +import android.media.MediaDrm; +import android.media.MediaPlayer; +import android.net.Uri; +import android.os.Build; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.view.MotionEvent; +import android.view.SurfaceHolder; +import android.view.View; +import android.widget.MediaController; +import android.widget.VideoView; + +import com.codename1.media.DrmConfig; +import com.codename1.media.DrmScheme; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +/// A video view that plays a CENC-protected stream (Widevine or ClearKey) through +/// the framework `MediaPlayer` DRM API (API 26). +/// +/// It extends `VideoView` only so `AndroidImplementation.Video` can host it +/// unchanged: every playback method `Video` calls is overridden here, and +/// `VideoView`'s own player is never opened (its URI stays null, so its surface +/// callback returns early). The view is the surface; the player is ours. +/// +/// The player is created on a private looper thread, which is the thread +/// `MediaPlayer` delivers its events on. `onDrmInfo` must call `prepareDrm` and +/// provide the key response before it returns, and the license request is a +/// network round trip -- on the main thread that would throw +/// `NetworkOnMainThreadException`. Listener callbacks are reposted to the main +/// thread, where `VideoView` would have delivered them. +/// +/// The framework player plays progressive and fragmented MP4 here, not DASH, so +/// the stream is one rendition with no adaptive bitrate; an application that needs +/// adaptive DASH with Widevine on Android uses a Media3 based player instead. +class DrmVideoView extends VideoView { + static final UUID WIDEVINE_UUID = new UUID(0xEDEF8BA979D64ACEL, 0xA3C827DCD51D21EDL); + static final UUID CLEARKEY_UUID = new UUID(0xE2719D58A985B3C9L, 0x781AB030AF78D30EL); + + private final Uri uri; + private final Map headers; + private final DrmConfig drm; + private final int startPosition; + private final Handler main = new Handler(Looper.getMainLooper()); + private HandlerThread playerThread; + private Handler playerHandler; + // Written on the player thread and read on the main thread (and the reverse + // for the flags), so they are atomics rather than plain fields. + private final AtomicReference playerRef = new AtomicReference(); + private final AtomicBoolean prepared = new AtomicBoolean(); + private final AtomicBoolean startWhenPrepared = new AtomicBoolean(); + private final AtomicBoolean released = new AtomicBoolean(); + private MediaController controller; + private MediaPlayer.OnCompletionListener completionListener; + private MediaPlayer.OnErrorListener errorListener; + + DrmVideoView(Context ctx, Uri uri, Map headers, DrmConfig drm, int startPosition) { + super(ctx); + this.uri = uri; + this.headers = headers; + this.drm = drm; + this.startPosition = startPosition; + getHolder().addCallback(new SurfaceHolder.Callback() { + @Override + public void surfaceCreated(final SurfaceHolder holder) { + open(holder); + } + + @Override + public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { + } + + @Override + public void surfaceDestroyed(SurfaceHolder holder) { + final MediaPlayer p = playerRef.get(); + if (p != null) { + try { + p.setDisplay(null); + } catch (Throwable ignore) { + // the player may already be released + } + } + } + }); + } + + /// Maps a scheme to the UUID `MediaDrm` knows it by. + static UUID uuidFor(DrmScheme scheme) { + if (scheme == DrmScheme.WIDEVINE) { + return WIDEVINE_UUID; + } + if (scheme == DrmScheme.CLEARKEY) { + return CLEARKEY_UUID; + } + return null; + } + + /// Answers whether this device's framework player can play the scheme. + static boolean isSupported(DrmScheme scheme) { + UUID id = uuidFor(scheme); + if (id == null || Build.VERSION.SDK_INT < 26) { + return false; + } + try { + return MediaDrm.isCryptoSchemeSupported(id); + } catch (Throwable t) { + return false; + } + } + + private void open(final SurfaceHolder holder) { + if (released.get()) { + return; + } + if (playerRef.get() != null) { + playerHandler.post(new Runnable() { + @Override + public void run() { + MediaPlayer p = playerRef.get(); + if (p != null) { + p.setDisplay(holder); + } + } + }); + return; + } + playerThread = new HandlerThread("CN1DrmVideo"); + playerThread.start(); + playerHandler = new Handler(playerThread.getLooper()); + playerHandler.post(new Runnable() { + @Override + public void run() { + createPlayer(holder); + } + }); + } + + private void createPlayer(SurfaceHolder holder) { + try { + final MediaPlayer p = new MediaPlayer(); + playerRef.set(p); + p.setDisplay(holder); + p.setScreenOnWhilePlaying(true); + p.setOnDrmInfoListener(new MediaPlayer.OnDrmInfoListener() { + @Override + public void onDrmInfo(MediaPlayer mp, MediaPlayer.DrmInfo info) { + try { + acquireLicense(mp, info); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + postError(MediaPlayer.MEDIA_ERROR_UNKNOWN, MediaPlayer.MEDIA_ERROR_IO); + } + } + }); + p.setOnPreparedListener(new MediaPlayer.OnPreparedListener() { + @Override + public void onPrepared(MediaPlayer mp) { + prepared.set(true); + if (startPosition > 0) { + mp.seekTo(startPosition); + } + if (startWhenPrepared.get()) { + mp.start(); + } + main.post(new Runnable() { + @Override + public void run() { + if (controller != null) { + controller.setEnabled(true); + } + } + }); + } + }); + p.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { + @Override + public void onCompletion(final MediaPlayer mp) { + main.post(new Runnable() { + @Override + public void run() { + if (completionListener != null) { + completionListener.onCompletion(mp); + } + } + }); + } + }); + p.setOnErrorListener(new MediaPlayer.OnErrorListener() { + @Override + public boolean onError(MediaPlayer mp, int what, int extra) { + postError(what, extra); + return true; + } + }); + p.setDataSource(getContext(), uri, headers); + p.prepareAsync(); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + postError(MediaPlayer.MEDIA_ERROR_UNKNOWN, MediaPlayer.MEDIA_ERROR_IO); + } + } + + private void acquireLicense(MediaPlayer mp, MediaPlayer.DrmInfo info) throws Exception { + UUID scheme = uuidFor(drm.getScheme()); + boolean offered = false; + for (UUID u : info.getSupportedSchemes()) { + if (u.equals(scheme)) { + offered = true; + break; + } + } + if (!offered) { + throw new IllegalStateException("The stream is not packaged for " + drm.getScheme()); + } + mp.prepareDrm(scheme); + byte[] initData = info.getPssh().get(scheme); + MediaDrm.KeyRequest req = mp.getKeyRequest(null, initData, "video/mp4", + MediaDrm.KEY_TYPE_STREAMING, null); + byte[] response = post(drm.getLicenseUrl(), req.getData(), drm.getLicenseHeaders()); + mp.provideKeyResponse(null, response); + } + + private static byte[] post(String url, byte[] body, Map headers) throws Exception { + HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection(); + try { + c.setRequestMethod("POST"); + c.setDoOutput(true); + c.setConnectTimeout(15000); + c.setReadTimeout(15000); + c.setRequestProperty("Content-Type", "application/octet-stream"); + for (Map.Entry h : headers.entrySet()) { + c.setRequestProperty(h.getKey(), h.getValue()); + } + OutputStream os = c.getOutputStream(); + try { + os.write(body); + } finally { + os.close(); + } + int code = c.getResponseCode(); + if (code < 200 || code >= 300) { + throw new java.io.IOException("License server answered " + code); + } + InputStream is = c.getInputStream(); + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + int n; + while ((n = is.read(buf)) > 0) { + out.write(buf, 0, n); + } + return out.toByteArray(); + } finally { + is.close(); + } + } finally { + c.disconnect(); + } + } + + private void postError(final int what, final int extra) { + main.post(new Runnable() { + @Override + public void run() { + if (errorListener != null) { + errorListener.onError(playerRef.get(), what, extra); + } + } + }); + } + + private void onPlayer(final Runnable r) { + if (playerHandler != null) { + playerHandler.post(r); + } + } + + @Override + public void setMediaController(MediaController mc) { + if (controller != null) { + controller.hide(); + } + controller = mc; + if (mc != null) { + mc.setMediaPlayer(this); + View anchor = getParent() instanceof View ? (View) getParent() : this; + mc.setAnchorView(anchor); + mc.setEnabled(prepared.get()); + } + } + + @Override + public boolean onTouchEvent(MotionEvent ev) { + if (ev.getAction() == MotionEvent.ACTION_DOWN && prepared.get() && controller != null) { + if (controller.isShowing()) { + controller.hide(); + } else { + controller.show(); + } + } + return false; + } + + @Override + public void setOnCompletionListener(MediaPlayer.OnCompletionListener l) { + completionListener = l; + } + + @Override + public void setOnErrorListener(MediaPlayer.OnErrorListener l) { + errorListener = l; + } + + @Override + public void start() { + startWhenPrepared.set(true); + onPlayer(new Runnable() { + @Override + public void run() { + MediaPlayer p = playerRef.get(); + if (p != null && prepared.get()) { + p.start(); + } + } + }); + } + + @Override + public void pause() { + startWhenPrepared.set(false); + onPlayer(new Runnable() { + @Override + public void run() { + MediaPlayer p = playerRef.get(); + if (p != null && prepared.get() && p.isPlaying()) { + p.pause(); + } + } + }); + } + + @Override + public void stopPlayback() { + released.set(true); + onPlayer(new Runnable() { + @Override + public void run() { + MediaPlayer p = playerRef.getAndSet(null); + if (p != null) { + try { + if (Build.VERSION.SDK_INT >= 26) { + p.releaseDrm(); + } + } catch (Throwable ignore) { + // no DRM session was opened + } + p.release(); + } + prepared.set(false); + playerThread.quitSafely(); + } + }); + } + + @Override + public boolean canPause() { + return prepared.get(); + } + + @Override + public boolean canSeekBackward() { + return prepared.get(); + } + + @Override + public boolean canSeekForward() { + return prepared.get(); + } + + @Override + public int getCurrentPosition() { + MediaPlayer p = playerRef.get(); + if (p == null || !prepared.get()) { + return 0; + } + try { + return p.getCurrentPosition(); + } catch (IllegalStateException e) { + return 0; + } + } + + @Override + public int getDuration() { + MediaPlayer p = playerRef.get(); + if (p == null || !prepared.get()) { + return -1; + } + try { + return p.getDuration(); + } catch (IllegalStateException e) { + return -1; + } + } + + @Override + public boolean isPlaying() { + MediaPlayer p = playerRef.get(); + if (p == null || !prepared.get()) { + return false; + } + try { + return p.isPlaying(); + } catch (IllegalStateException e) { + return false; + } + } + + @Override + public void seekTo(final int msec) { + onPlayer(new Runnable() { + @Override + public void run() { + MediaPlayer p = playerRef.get(); + if (p != null && prepared.get()) { + p.seekTo(msec); + } + } + }); + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 13ab0a2c157..a0d8a70466d 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -16621,6 +16621,14 @@ public CN1JPanel() { + @Override + public boolean isDrmSupported(com.codename1.media.DrmScheme scheme) { + // The simulator plays through ffmpeg, whose HLS demuxer fetches the + // #EXT-X-KEY URI and decrypts AES-128 segments. It has no CDM, so the + // CENC schemes (ClearKey, Widevine, FairPlay) are answered honestly as no. + return scheme == com.codename1.media.DrmScheme.AES_128 && FFMPEGMedia.isConfigured(); + } + @Override public AsyncResource createMediaAsync(String uriAddress, final boolean isVideo, final Runnable onCompletion) { if (FFMPEGMedia.isConfigured() && !"cef".equalsIgnoreCase(System.getProperty("cn1.javase.mediaImplementation", ""))) { diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 661230b19b1..712d702d951 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -23782,3 +23782,182 @@ JAVA_INT com_codename1_impl_ios_IOSNative_getFoldRegion___int_1ARRAY_R_int( } return (JAVA_INT)kind; } + +// --------------------------------------------------------------------------- +// FairPlay Streaming (com.codename1.media.StreamRequest with DrmScheme.FAIRPLAY). +// +// The asset is registered with an AVContentKeySession before the player item +// exists, so the session -- not the player -- answers the playlist's skd:// key +// tag. For each key request the delegate fetches the application certificate, +// asks the request for the SPC, posts it to the license server with the caller's +// headers and hands the CKC back. Both network calls run on NSURLSession's queue; +// nothing here blocks the Java thread beyond creating the view. +// +// The session and its delegate are attached to the player with an associated +// object, so they live exactly as long as the player the Java peer releases. +// Only the AVKit iOS/tvOS path implements it: the macOS port and the watch have +// no FairPlay client here, and the simulator has no FairPlay CDM at all. +// --------------------------------------------------------------------------- +#if defined(CN1_USE_AVKIT) && !TARGET_OS_OSX +static char cn1FairPlaySessionKey; + +// One serial queue for every session's key callbacks, created once, so no +// per-player queue has to be released by hand in the MRC build. +static dispatch_queue_t cn1FairPlayQueue(void) { + static dispatch_queue_t q; + static dispatch_once_t once; + dispatch_once(&once, ^{ + q = dispatch_queue_create("com.codename1.fairplay", DISPATCH_QUEUE_SERIAL); + }); + return q; +} + +@interface CN1FairPlayKeyDelegate : NSObject +@property (nonatomic, retain) NSString* certificateUrl; +@property (nonatomic, retain) NSString* licenseUrl; +@property (nonatomic, retain) NSString* contentId; +@property (nonatomic, retain) NSDictionary* licenseHeaders; +@property (nonatomic, retain) AVContentKeySession* session; +@end + +@implementation CN1FairPlayKeyDelegate + +- (void)fail:(AVContentKeyRequest*)keyRequest reason:(NSString*)reason { + NSLog(@"CN1 FairPlay: %@", reason); + [keyRequest processContentKeyResponseError:[NSError errorWithDomain:@"CN1FairPlay" code:-1 + userInfo:@{NSLocalizedDescriptionKey: reason}]]; +} + +- (void)handleKeyRequest:(AVContentKeyRequest*)keyRequest { + NSString* cid = self.contentId; + id ident = keyRequest.identifier; + if (cid == nil && [ident isKindOfClass:[NSString class]]) { + NSString* s = (NSString*)ident; + NSURL* u = [NSURL URLWithString:s]; + cid = u.host != nil ? u.host : [s stringByReplacingOccurrencesOfString:@"skd://" withString:@""]; + } + if (cid == nil || self.certificateUrl == nil) { + [self fail:keyRequest reason:@"FairPlay needs a certificate URL and a content identifier"]; + return; + } + NSData* cidData = [cid dataUsingEncoding:NSUTF8StringEncoding]; + NSURLSession* http = [NSURLSession sharedSession]; + NSURL* certUrl = [NSURL URLWithString:self.certificateUrl]; + [[http dataTaskWithURL:certUrl completionHandler:^(NSData* cert, NSURLResponse* r, NSError* e) { + NSInteger certStatus = [r isKindOfClass:[NSHTTPURLResponse class]] ? ((NSHTTPURLResponse*)r).statusCode : 200; + if (cert == nil || certStatus < 200 || certStatus >= 300) { + [self fail:keyRequest reason:[NSString stringWithFormat:@"certificate request failed (%ld)", (long)certStatus]]; + return; + } + [keyRequest makeStreamingContentKeyRequestDataForApp:cert contentIdentifier:cidData options:nil + completionHandler:^(NSData* spc, NSError* spcError) { + if (spc == nil) { + [self fail:keyRequest reason:@"the key request produced no SPC"]; + return; + } + NSMutableURLRequest* req = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:self.licenseUrl]]; + req.HTTPMethod = @"POST"; + req.HTTPBody = spc; + [req setValue:@"application/octet-stream" forHTTPHeaderField:@"Content-Type"]; + for (NSString* k in self.licenseHeaders) { + [req setValue:[self.licenseHeaders objectForKey:k] forHTTPHeaderField:k]; + } + [[http dataTaskWithRequest:req completionHandler:^(NSData* ckc, NSURLResponse* lr, NSError* le) { + NSInteger status = [lr isKindOfClass:[NSHTTPURLResponse class]] ? ((NSHTTPURLResponse*)lr).statusCode : 0; + if (ckc == nil || status < 200 || status >= 300) { + [self fail:keyRequest reason:[NSString stringWithFormat:@"license server answered %ld", (long)status]]; + return; + } + [keyRequest processContentKeyResponse: + [AVContentKeyResponse contentKeyResponseWithFairPlayStreamingKeyResponseData:ckc]]; + }] resume]; + }]; + }] resume]; +} + +- (void)contentKeySession:(AVContentKeySession*)session didProvideContentKeyRequest:(AVContentKeyRequest*)keyRequest { + [self handleKeyRequest:keyRequest]; +} + +- (void)contentKeySession:(AVContentKeySession*)session didProvideRenewingContentKeyRequest:(AVContentKeyRequest*)keyRequest { + [self handleKeyRequest:keyRequest]; +} + +#ifndef CN1_USE_ARC +- (void)dealloc { + [_certificateUrl release]; + [_licenseUrl release]; + [_contentId release]; + [_licenseHeaders release]; + [_session release]; + [super dealloc]; +} +#endif +@end + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isFairPlaySupported___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { +#if TARGET_OS_SIMULATOR + return JAVA_FALSE; +#else + return useAVKit() ? JAVA_TRUE : JAVA_FALSE; +#endif +} + +JAVA_LONG com_codename1_impl_ios_IOSNative_createFairPlayVideoComponent___java_lang_String_java_lang_String_java_lang_String_java_lang_String_java_lang_String_int_R_long(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT url, JAVA_OBJECT certificateUrl, JAVA_OBJECT licenseUrl, JAVA_OBJECT contentId, JAVA_OBJECT licenseHeaders, JAVA_INT onCompletionCallbackId) { + if (!useAVKit()) { + return 0; + } + NSString* sUrl = toNSString(CN1_THREAD_STATE_PASS_ARG url); + NSString* sCert = certificateUrl == JAVA_NULL ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG certificateUrl); + NSString* sLicense = toNSString(CN1_THREAD_STATE_PASS_ARG licenseUrl); + NSString* sContent = contentId == JAVA_NULL ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG contentId); + NSString* sHeaders = licenseHeaders == JAVA_NULL ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG licenseHeaders); + __block AVPlayerViewController* moviePlayerInstance = nil; + dispatch_sync(dispatch_get_main_queue(), ^{ + POOL_BEGIN(); + // Headers arrive as name\nvalue\n pairs, which keeps the native signature + // free of arrays; header names and values cannot contain a newline. + NSMutableDictionary* headers = [NSMutableDictionary dictionary]; + if (sHeaders != nil) { + NSArray* parts = [sHeaders componentsSeparatedByString:@"\n"]; + for (NSUInteger i = 0; i + 1 < [parts count]; i += 2) { + [headers setObject:[parts objectAtIndex:i + 1] forKey:[parts objectAtIndex:i]]; + } + } + CN1FairPlayKeyDelegate* delegate = [[CN1FairPlayKeyDelegate alloc] init]; + delegate.certificateUrl = sCert; + delegate.licenseUrl = sLicense; + delegate.contentId = sContent; + delegate.licenseHeaders = headers; + AVContentKeySession* session = [AVContentKeySession contentKeySessionWithKeySystem:AVContentKeySystemFairPlayStreaming]; + delegate.session = session; + [session setDelegate:delegate queue:cn1FairPlayQueue()]; + AVURLAsset* asset = [AVURLAsset URLAssetWithURL:cn1URLForMediaString(sUrl) options:nil]; + [session addContentKeyRecipient:asset]; + AVPlayer* cn1Player = [[AVPlayer alloc] initWithPlayerItem:[AVPlayerItem playerItemWithAsset:asset]]; + // The session holds its delegate weakly; the player holds the delegate, + // and the delegate the session, so all three go when the player does. + objc_setAssociatedObject(cn1Player, &cn1FairPlaySessionKey, delegate, OBJC_ASSOCIATION_RETAIN_NONATOMIC); +#ifndef CN1_USE_ARC + [delegate release]; +#endif + moviePlayerInstance = [[AVPlayerViewController alloc] init]; + moviePlayerInstance.player = cn1Player; +#ifndef CN1_USE_ARC + [cn1Player release]; +#endif + registerVideoCallbackAV(CN1_THREAD_GET_STATE_PASS_ARG moviePlayerInstance.player, onCompletionCallbackId); + moviePlayerInstance.showsPlaybackControls = YES; + POOL_END(); + }); + return (JAVA_LONG)((BRIDGE_CAST void*)moviePlayerInstance); +} +#else +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isFairPlaySupported___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + return JAVA_FALSE; +} + +JAVA_LONG com_codename1_impl_ios_IOSNative_createFairPlayVideoComponent___java_lang_String_java_lang_String_java_lang_String_java_lang_String_java_lang_String_int_R_long(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT url, JAVA_OBJECT certificateUrl, JAVA_OBJECT licenseUrl, JAVA_OBJECT contentId, JAVA_OBJECT licenseHeaders, JAVA_INT onCompletionCallbackId) { + return 0; +} +#endif diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index f7680d13aac..dd73e9709e0 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -7227,6 +7227,9 @@ class IOSMedia extends AbstractMedia { private boolean embedNativeControls=true; private List completionHandlers; private boolean prepareToPlay; + // Set for a FairPlay stream: getVideoComponent creates the player through + // the content key session instead of a plain URL. + private com.codename1.media.DrmConfig fairPlay; @@ -7484,7 +7487,17 @@ public boolean isPlaying() { @Override public Component getVideoComponent() { if (component == null) { - if(uri != null) { + if(uri != null && fairPlay != null) { + moviePlayerPeer = nativeInstance.createFairPlayVideoComponent(uri, fairPlay.getCertificateUrl(), + fairPlay.getLicenseUrl(), fairPlay.getContentId(), encodeHeaders(fairPlay.getLicenseHeaders()), + onCompletionCallbackId); + if (moviePlayerPeer == 0) { + fireMediaError(new MediaException(MediaErrorType.SrcNotSupported, "FairPlay is not supported on this device")); + return null; + } + nativeInstance.setNativeVideoControlsEmbedded(moviePlayerPeer, embedNativeControls); + component = PeerComponent.create(new long[] { nativeInstance.getVideoViewPeer(moviePlayerPeer) }); + } else if(uri != null) { moviePlayerPeer = nativeInstance.createVideoComponent(uri, onCompletionCallbackId); nativeInstance.setNativeVideoControlsEmbedded(moviePlayerPeer, embedNativeControls); component = PeerComponent.create(new long[] { nativeInstance.getVideoViewPeer(moviePlayerPeer) }); @@ -7582,6 +7595,43 @@ public Media createMedia(String uri, boolean isVideo, Runnable onCompletion) thr return new IOSMedia(uri, isVideo, onCompletion); } + @Override + public boolean isDrmSupported(com.codename1.media.DrmScheme scheme) { + if (scheme == com.codename1.media.DrmScheme.AES_128) { + // AVPlayer decrypts HLS #EXT-X-KEY METHOD=AES-128 by itself. + return true; + } + return scheme == com.codename1.media.DrmScheme.FAIRPLAY && nativeInstance.isFairPlaySupported(); + } + + @Override + public Media createStreamingMedia(com.codename1.media.StreamRequest request) throws IOException { + com.codename1.media.DrmConfig drm = request.getDrm(); + if (drm == null || drm.getScheme() != com.codename1.media.DrmScheme.FAIRPLAY) { + return super.createStreamingMedia(request); + } + if (!nativeInstance.isFairPlaySupported()) { + throw new IOException("FairPlay is not supported on this device"); + } + IOSMedia m = new IOSMedia(request.getUrl(), true, request.getOnCompletion()); + m.fairPlay = drm; + if (request.getStartPosition() > 0) { + m.setTime(request.getStartPosition()); + } + return m; + } + + private static String encodeHeaders(java.util.Map headers) { + if (headers == null || headers.isEmpty()) { + return null; + } + StringBuilder sb = new StringBuilder(); + for (java.util.Map.Entry e : headers.entrySet()) { + sb.append(e.getKey()).append('\n').append(e.getValue()).append('\n'); + } + return sb.toString(); + } + @Override public void addCompletionHandler(Media media, Runnable onCompletion) { super.addCompletionHandler(media, onCompletion); diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index cf4b1c79484..1547f376e40 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -532,6 +532,10 @@ native void prepareNativeDrag(String mimeTypes, int allowedActions, byte[] dragI native long createVideoComponent(String url, int onCompletionCallbackId); native long createVideoComponent(byte[] video, int onCompletionCallbackId); native long createVideoComponentNSData(long video, int onCompletionCallbackId); + // FairPlay: licenseHeaders is name\nvalue\n pairs (null for none). + native long createFairPlayVideoComponent(String url, String certificateUrl, String licenseUrl, + String contentId, String licenseHeaders, int onCompletionCallbackId); + native boolean isFairPlaySupported(); native long createNativeVideoComponent(String url, int onCompletionCallbackId); native long createNativeVideoComponent(byte[] video, int onCompletionCallbackId); native long createNativeVideoComponentNSData(long video, int onCompletionCallbackId); diff --git a/maven/core-unittests/src/test/java/com/codename1/media/StreamRequestTest.java b/maven/core-unittests/src/test/java/com/codename1/media/StreamRequestTest.java new file mode 100644 index 00000000000..54b3fdbef25 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/media/StreamRequestTest.java @@ -0,0 +1,70 @@ +package com.codename1.media; + +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import org.junit.jupiter.api.BeforeEach; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +class StreamRequestTest extends UITestBase { + private Media media; + + @BeforeEach + void configureImplementation() { + media = mock(Media.class); + implementation.setMedia(media); + } + + @FormTest + void unprotectedStreamPlaysThroughCreateMediaAndSeeks() throws IOException { + StreamRequest req = new StreamRequest("https://example.com/v/master.m3u8").setStartPosition(42000); + assertTrue(req.isSupported()); + assertSame(media, req.create()); + verify(media).setTime(42000); + } + + @FormTest + void zeroStartPositionDoesNotSeek() throws IOException { + new StreamRequest("https://example.com/v.mp4").setStartPosition(-5).create(); + verify(media, never()).setTime(0); + } + + @FormTest + void defaultImplementationSupportsNoDrmScheme() { + for (DrmScheme s : DrmScheme.values()) { + assertFalse(MediaManager.isDrmSupported(s), s.name()); + } + StreamRequest req = new StreamRequest("https://example.com/v.mpd") + .setDrm(new DrmConfig(DrmScheme.WIDEVINE, "https://example.com/license")); + assertFalse(req.isSupported()); + IOException e = assertThrows(IOException.class, req::create); + assertTrue(e.getMessage().contains("WIDEVINE")); + } + + @FormTest + void headersNeedPlatformSupport() { + assertFalse(MediaManager.isStreamHeadersSupported()); + StreamRequest req = new StreamRequest("https://example.com/v.m3u8").addHeader("Authorization", "Bearer x"); + assertFalse(req.isSupported()); + assertThrows(IOException.class, req::create); + } + + @FormTest + void drmConfigValidatesAndCopies() { + assertThrows(IllegalArgumentException.class, () -> new DrmConfig(null, "u")); + assertThrows(IllegalArgumentException.class, () -> new DrmConfig(DrmScheme.FAIRPLAY, null)); + assertNull(new DrmConfig(DrmScheme.AES_128, null).getLicenseUrl()); + DrmConfig c = new DrmConfig(DrmScheme.FAIRPLAY, "https://l").setCertificateUrl("https://c") + .setContentId("title-7").addLicenseHeader("X-Token", "t"); + assertEquals("https://c", c.getCertificateUrl()); + assertEquals("title-7", c.getContentId()); + c.getLicenseHeaders().clear(); + assertEquals("t", c.getLicenseHeaders().get("X-Token")); + assertThrows(IllegalArgumentException.class, () -> new StreamRequest("")); + } +} From 2e143a99a94985a27e9d7e5a107f542a451eff1b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:13:07 +0300 Subject: [PATCH 02/24] Streaming DRM: fix resume position, surface lifecycle, aspect ratio, DASH and availability (red-team round 1) Co-Authored-By: Claude Opus 5.5 --- .../impl/CodenameOneImplementation.java | 3 + .../impl/android/AndroidImplementation.java | 5 + .../codename1/impl/android/DrmVideoView.java | 172 +++++++++++++----- Ports/iOSPort/nativeSources/IOSNative.m | 22 ++- .../codename1/impl/ios/IOSImplementation.java | 9 + 5 files changed, 161 insertions(+), 50 deletions(-) diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index d3bb504ee0f..63c7160f97c 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -5767,6 +5767,9 @@ public Media createStreamingMedia(com.codename1.media.StreamRequest request) thr if (drm != null && drm.getScheme() != com.codename1.media.DrmScheme.AES_128) { throw new IOException(drm.getScheme() + " is not supported on this platform"); } + if (!request.getHeaders().isEmpty() && !isStreamHeadersSupported()) { + throw new IOException("Stream request headers are not supported on this platform"); + } Media m = createMedia(request.getUrl(), true, request.getOnCompletion()); if (m != null && request.getStartPosition() > 0) { m.setTime(request.getStartPosition()); diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index cddb1b213ef..c8b9f8be276 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -4795,6 +4795,11 @@ public Media createStreamingMedia(final com.codename1.media.StreamRequest reques if (cenc && !DrmVideoView.isSupported(drm.getScheme())) { throw new IOException(drm.getScheme() + " is not supported on this device"); } + if (cenc && request.getUrl().toLowerCase(java.util.Locale.ROOT).indexOf(".mpd") > -1) { + // The framework MediaPlayer has no DASH extractor; failing here names + // the reason instead of a generic prepare error later. + throw new IOException("DASH is not supported by the framework player; serve HLS (fMP4) or MP4"); + } final java.util.Map headers = request.getHeaders(); final Uri videoUri = Uri.parse(request.getUrl()); final AndroidImplementation.Video[] video = new AndroidImplementation.Video[1]; diff --git a/Ports/Android/src/com/codename1/impl/android/DrmVideoView.java b/Ports/Android/src/com/codename1/impl/android/DrmVideoView.java index 0628ba5b936..3e8bb701e05 100644 --- a/Ports/Android/src/com/codename1/impl/android/DrmVideoView.java +++ b/Ports/Android/src/com/codename1/impl/android/DrmVideoView.java @@ -46,7 +46,10 @@ import java.net.URL; import java.util.Map; import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; /// A video view that plays a CENC-protected stream (Widevine or ClearKey) through @@ -64,9 +67,10 @@ /// `NetworkOnMainThreadException`. Listener callbacks are reposted to the main /// thread, where `VideoView` would have delivered them. /// -/// The framework player plays progressive and fragmented MP4 here, not DASH, so -/// the stream is one rendition with no adaptive bitrate; an application that needs -/// adaptive DASH with Widevine on Android uses a Media3 based player instead. +/// The framework player has no DASH extractor: it plays CENC progressive or +/// fragmented MP4 and HLS with fMP4 segments. `AndroidImplementation` refuses an +/// `.mpd` URL up front rather than failing in prepare; an application that needs +/// DASH with Widevine on Android uses a Media3 based player instead. class DrmVideoView extends VideoView { static final UUID WIDEVINE_UUID = new UUID(0xEDEF8BA979D64ACEL, 0xA3C827DCD51D21EDL); static final UUID CLEARKEY_UUID = new UUID(0xE2719D58A985B3C9L, 0x781AB030AF78D30EL); @@ -74,8 +78,8 @@ class DrmVideoView extends VideoView { private final Uri uri; private final Map headers; private final DrmConfig drm; - private final int startPosition; private final Handler main = new Handler(Looper.getMainLooper()); + // One looper thread for the life of the view; quit by stopPlayback. private HandlerThread playerThread; private Handler playerHandler; // Written on the player thread and read on the main thread (and the reverse @@ -84,6 +88,12 @@ class DrmVideoView extends VideoView { private final AtomicBoolean prepared = new AtomicBoolean(); private final AtomicBoolean startWhenPrepared = new AtomicBoolean(); private final AtomicBoolean released = new AtomicBoolean(); + // A seek asked for before the player was prepared, or the position the + // player had reached when its surface went away; -1 for none. VideoView + // keeps the same thing in mSeekWhenPrepared. + private final AtomicInteger pendingSeek = new AtomicInteger(-1); + private final AtomicInteger videoWidth = new AtomicInteger(); + private final AtomicInteger videoHeight = new AtomicInteger(); private MediaController controller; private MediaPlayer.OnCompletionListener completionListener; private MediaPlayer.OnErrorListener errorListener; @@ -93,7 +103,14 @@ class DrmVideoView extends VideoView { this.uri = uri; this.headers = headers; this.drm = drm; - this.startPosition = startPosition; + if (startPosition > 0) { + pendingSeek.set(startPosition); + } + // The player lives exactly as long as the surface, as VideoView's does: + // a view that is removed without cleanup() still lets go of its decoder + // and its DRM session (a scarce resource with hardware-backed Widevine) + // when its surface is destroyed, and a reparented view reopens where it + // was. Re-attaching means a new license request, which is cheap. getHolder().addCallback(new SurfaceHolder.Callback() { @Override public void surfaceCreated(final SurfaceHolder holder) { @@ -106,14 +123,7 @@ public void surfaceChanged(SurfaceHolder holder, int format, int width, int heig @Override public void surfaceDestroyed(SurfaceHolder holder) { - final MediaPlayer p = playerRef.get(); - if (p != null) { - try { - p.setDisplay(null); - } catch (Throwable ignore) { - // the player may already be released - } - } + closePlayer(true); } }); } @@ -146,27 +156,67 @@ private void open(final SurfaceHolder holder) { if (released.get()) { return; } - if (playerRef.get() != null) { - playerHandler.post(new Runnable() { - @Override - public void run() { - MediaPlayer p = playerRef.get(); - if (p != null) { - p.setDisplay(holder); - } + if (playerThread == null) { + playerThread = new HandlerThread("CN1DrmVideo"); + playerThread.start(); + playerHandler = new Handler(playerThread.getLooper()); + } + playerHandler.post(new Runnable() { + @Override + public void run() { + if (!released.get() && playerRef.get() == null) { + createPlayer(holder); } - }); + } + }); + } + + /// Releases the player on its own thread. From surfaceDestroyed this waits + /// (briefly) for that to finish, because the surface must not be used once + /// the callback returns. + private void closePlayer(boolean wait) { + if (playerHandler == null) { return; } - playerThread = new HandlerThread("CN1DrmVideo"); - playerThread.start(); - playerHandler = new Handler(playerThread.getLooper()); + final CountDownLatch done = new CountDownLatch(1); playerHandler.post(new Runnable() { @Override public void run() { - createPlayer(holder); + try { + MediaPlayer p = playerRef.getAndSet(null); + if (p != null) { + if (prepared.get() && pendingSeek.get() < 0) { + try { + pendingSeek.set(p.getCurrentPosition()); + } catch (IllegalStateException ignore) { + // the position is lost with the player + } + } + try { + p.setDisplay(null); + if (Build.VERSION.SDK_INT >= 26) { + p.releaseDrm(); + } + } catch (Throwable ignore) { + // no DRM session was opened + } + p.release(); + } + prepared.set(false); + } finally { + done.countDown(); + } } }); + if (wait) { + try { + if (!done.await(1, TimeUnit.SECONDS)) { + com.codename1.io.Log.p("DrmVideoView: the player did not release its surface within 1s"); + } + } catch (InterruptedException ignore) { + Thread.currentThread().interrupt(); + } + } } private void createPlayer(SurfaceHolder holder) { @@ -186,12 +236,29 @@ public void onDrmInfo(MediaPlayer mp, MediaPlayer.DrmInfo info) { } } }); + p.setOnVideoSizeChangedListener(new MediaPlayer.OnVideoSizeChangedListener() { + @Override + public void onVideoSizeChanged(MediaPlayer mp, final int width, final int height) { + videoWidth.set(width); + videoHeight.set(height); + main.post(new Runnable() { + @Override + public void run() { + if (width > 0 && height > 0) { + getHolder().setFixedSize(width, height); + } + requestLayout(); + } + }); + } + }); p.setOnPreparedListener(new MediaPlayer.OnPreparedListener() { @Override public void onPrepared(MediaPlayer mp) { prepared.set(true); - if (startPosition > 0) { - mp.seekTo(startPosition); + int seek = pendingSeek.getAndSet(-1); + if (seek > 0) { + mp.seekTo(seek); } if (startWhenPrepared.get()) { mp.start(); @@ -209,6 +276,7 @@ public void run() { p.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { @Override public void onCompletion(final MediaPlayer mp) { + startWhenPrepared.set(false); main.post(new Runnable() { @Override public void run() { @@ -234,6 +302,24 @@ public boolean onError(MediaPlayer mp, int what, int extra) { } } + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + // VideoView sizes itself from its own player's video size, which is + // never set here; keep the aspect ratio of ours instead. + int vw = videoWidth.get(); + int vh = videoHeight.get(); + int width = getDefaultSize(vw, widthMeasureSpec); + int height = getDefaultSize(vh, heightMeasureSpec); + if (vw > 0 && vh > 0) { + if (vw * height > width * vh) { + height = width * vh / vw; + } else if (vw * height < width * vh) { + width = height * vw / vh; + } + } + setMeasuredDimension(width, height); + } + private void acquireLicense(MediaPlayer mp, MediaPlayer.DrmInfo info) throws Exception { UUID scheme = uuidFor(drm.getScheme()); boolean offered = false; @@ -376,24 +462,15 @@ public void run() { @Override public void stopPlayback() { released.set(true); - onPlayer(new Runnable() { - @Override - public void run() { - MediaPlayer p = playerRef.getAndSet(null); - if (p != null) { - try { - if (Build.VERSION.SDK_INT >= 26) { - p.releaseDrm(); - } - } catch (Throwable ignore) { - // no DRM session was opened - } - p.release(); + closePlayer(false); + if (playerHandler != null) { + playerHandler.post(new Runnable() { + @Override + public void run() { + playerThread.quitSafely(); } - prepared.set(false); - playerThread.quitSafely(); - } - }); + }); + } } @Override @@ -452,12 +529,17 @@ public boolean isPlaying() { @Override public void seekTo(final int msec) { + if (!prepared.get()) { + pendingSeek.set(msec); + } onPlayer(new Runnable() { @Override public void run() { MediaPlayer p = playerRef.get(); if (p != null && prepared.get()) { p.seekTo(msec); + } else { + pendingSeek.set(msec); } } }); diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 712d702d951..794837e0fa8 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -23812,6 +23812,7 @@ static dispatch_queue_t cn1FairPlayQueue(void) { return q; } +API_AVAILABLE(ios(10.3), tvos(10.2)) @interface CN1FairPlayKeyDelegate : NSObject @property (nonatomic, retain) NSString* certificateUrl; @property (nonatomic, retain) NSString* licenseUrl; @@ -23899,14 +23900,14 @@ JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isFairPlaySupported___R_boolean(CN #if TARGET_OS_SIMULATOR return JAVA_FALSE; #else - return useAVKit() ? JAVA_TRUE : JAVA_FALSE; + if (@available(iOS 10.3, tvOS 10.2, *)) { + return useAVKit() ? JAVA_TRUE : JAVA_FALSE; + } + return JAVA_FALSE; #endif } -JAVA_LONG com_codename1_impl_ios_IOSNative_createFairPlayVideoComponent___java_lang_String_java_lang_String_java_lang_String_java_lang_String_java_lang_String_int_R_long(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT url, JAVA_OBJECT certificateUrl, JAVA_OBJECT licenseUrl, JAVA_OBJECT contentId, JAVA_OBJECT licenseHeaders, JAVA_INT onCompletionCallbackId) { - if (!useAVKit()) { - return 0; - } +static JAVA_LONG cn1CreateFairPlayVideo(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT url, JAVA_OBJECT certificateUrl, JAVA_OBJECT licenseUrl, JAVA_OBJECT contentId, JAVA_OBJECT licenseHeaders, JAVA_INT onCompletionCallbackId) API_AVAILABLE(ios(10.3), tvos(10.2)) { NSString* sUrl = toNSString(CN1_THREAD_STATE_PASS_ARG url); NSString* sCert = certificateUrl == JAVA_NULL ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG certificateUrl); NSString* sLicense = toNSString(CN1_THREAD_STATE_PASS_ARG licenseUrl); @@ -23952,6 +23953,17 @@ JAVA_LONG com_codename1_impl_ios_IOSNative_createFairPlayVideoComponent___java_l }); return (JAVA_LONG)((BRIDGE_CAST void*)moviePlayerInstance); } + +JAVA_LONG com_codename1_impl_ios_IOSNative_createFairPlayVideoComponent___java_lang_String_java_lang_String_java_lang_String_java_lang_String_java_lang_String_int_R_long(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT url, JAVA_OBJECT certificateUrl, JAVA_OBJECT licenseUrl, JAVA_OBJECT contentId, JAVA_OBJECT licenseHeaders, JAVA_INT onCompletionCallbackId) { + if (!useAVKit()) { + return 0; + } + if (@available(iOS 10.3, tvOS 10.2, *)) { + return cn1CreateFairPlayVideo(CN1_THREAD_STATE_PASS_ARG url, certificateUrl, licenseUrl, contentId, + licenseHeaders, onCompletionCallbackId); + } + return 0; +} #else JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isFairPlaySupported___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { return JAVA_FALSE; diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index dd73e9709e0..9112bc9c437 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -7230,6 +7230,7 @@ class IOSMedia extends AbstractMedia { // Set for a FairPlay stream: getVideoComponent creates the player through // the content key session instead of a plain URL. private com.codename1.media.DrmConfig fairPlay; + private int pendingTime = -1; @@ -7447,6 +7448,10 @@ public void setTime(int time) { } else { nativeInstance.setAudioTime(moviePlayerPeer, time); } + } else if(isVideo) { + // The video peer is created lazily by getVideoComponent; a resume + // position set before that is applied once the peer exists. + pendingTime = time; } } @@ -7518,6 +7523,10 @@ public Component getVideoComponent() { if (prepareToPlay && isVideo && !isPlaying()) { prepare(); } + if (moviePlayerPeer != 0 && pendingTime > 0) { + nativeInstance.setMediaTimeMS(moviePlayerPeer, pendingTime); + pendingTime = -1; + } return component; } From c86e9cfbcf8a8c8b7f0f4d45daf36044ae01c221 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:16:25 +0300 Subject: [PATCH 03/24] Reel sample: streaming backend, shared API and packager Catalog, recommendations, playback with AES-128/ClearKey keys served only to live sessions, Widevine/FairPlay license proxying, plans, rentals and coupons, ads, studio uploads in checked pieces, a packaging worker, moderation with frame blocklists, quizzes, polls, branching and certificates, an admin console API and the demo catalog of Blender open movies. Co-Authored-By: Claude Opus 5.5 --- scripts/reel/LICENSE | 22 + .../reel/backend/application-dev.properties | 40 ++ .../reel/backend/application-test.properties | 24 + scripts/reel/backend/application.properties | 77 +++ scripts/reel/backend/pom.xml | 160 +++++ scripts/reel/backend/server.sh | 112 ++++ .../com/codenameone/examples/reel/Caller.java | 63 ++ .../com/codenameone/examples/reel/Days.java | 123 ++++ .../codenameone/examples/reel/DemoData.java | 341 ++++++++++ .../com/codenameone/examples/reel/Ids.java | 70 ++ .../com/codenameone/examples/reel/Kinds.java | 57 ++ .../codenameone/examples/reel/Requests.java | 80 +++ .../com/codenameone/examples/reel/Roles.java | 37 + .../com/codenameone/examples/reel/Rows.java | 156 +++++ .../examples/reel/SecurityConfig.java | 410 +++++++++++ .../codenameone/examples/reel/Services.java | 201 ++++++ .../examples/reel/SignInCodePage.java | 67 ++ .../com/codenameone/examples/reel/Text.java | 83 +++ .../examples/reel/account/AccountCleanup.java | 100 +++ .../reel/account/AccountEndpoint.java | 139 ++++ .../examples/reel/account/Accounts.java | 406 +++++++++++ .../examples/reel/account/DemoAccounts.java | 116 ++++ .../examples/reel/account/Devices.java | 154 +++++ .../examples/reel/account/Grants.java | 91 +++ .../examples/reel/account/Households.java | 227 +++++++ .../examples/reel/account/Moderation.java | 104 +++ .../examples/reel/admin/AdminEndpoint.java | 242 +++++++ .../examples/reel/admin/Sharing.java | 110 +++ .../examples/reel/admin/Stats.java | 224 +++++++ .../codenameone/examples/reel/ads/Ads.java | 228 +++++++ .../examples/reel/billing/Billing.java | 634 ++++++++++++++++++ .../reel/billing/BillingEndpoint.java | 94 +++ .../examples/reel/billing/Entitlements.java | 211 ++++++ .../examples/reel/catalog/Catalog.java | 446 ++++++++++++ .../reel/catalog/CatalogEndpoint.java | 75 +++ .../examples/reel/catalog/Recommender.java | 240 +++++++ .../examples/reel/domain/AdCampaign.java | 218 ++++++ .../examples/reel/domain/AdEvent.java | 97 +++ .../examples/reel/domain/Answer.java | 130 ++++ .../examples/reel/domain/BlockedItem.java | 97 +++ .../examples/reel/domain/Certificate.java | 108 +++ .../examples/reel/domain/Chapter.java | 75 +++ .../examples/reel/domain/ClosedAccount.java | 54 ++ .../examples/reel/domain/Coupon.java | 119 ++++ .../examples/reel/domain/Device.java | 130 ++++ .../examples/reel/domain/Download.java | 119 ++++ .../examples/reel/domain/FrameHash.java | 75 +++ .../examples/reel/domain/Genre.java | 64 ++ .../examples/reel/domain/Household.java | 130 ++++ .../examples/reel/domain/Interaction.java | 152 +++++ .../examples/reel/domain/Invoice.java | 141 ++++ .../codenameone/examples/reel/domain/Job.java | 163 +++++ .../examples/reel/domain/ListEntry.java | 75 +++ .../examples/reel/domain/ModerationEvent.java | 97 +++ .../examples/reel/domain/ModerationItem.java | 185 +++++ .../examples/reel/domain/PaymentCustomer.java | 53 ++ .../examples/reel/domain/PaymentMethod.java | 131 ++++ .../examples/reel/domain/PaymentSetup.java | 86 +++ .../examples/reel/domain/Plan.java | 174 +++++ .../examples/reel/domain/Playback.java | 207 ++++++ .../examples/reel/domain/Profile.java | 108 +++ .../examples/reel/domain/Progress.java | 130 ++++ .../examples/reel/domain/PushDevice.java | 101 +++ .../examples/reel/domain/Rental.java | 130 ++++ .../examples/reel/domain/Setting.java | 75 +++ .../examples/reel/domain/SharingAlert.java | 141 ++++ .../examples/reel/domain/Subscription.java | 185 +++++ .../examples/reel/domain/Title.java | 295 ++++++++ .../examples/reel/domain/TitleRating.java | 97 +++ .../examples/reel/domain/Upload.java | 141 ++++ .../examples/reel/domain/Video.java | 218 ++++++ .../examples/reel/domain/VideoKey.java | 86 +++ .../reel/learning/CertificatePage.java | 92 +++ .../examples/reel/learning/Learning.java | 325 +++++++++ .../reel/learning/LearningEndpoint.java | 60 ++ .../examples/reel/library/Library.java | 313 +++++++++ .../reel/library/LibraryEndpoint.java | 88 +++ .../reel/moderation/ContentClassifier.java | 34 + .../reel/moderation/ModerationEndpoint.java | 74 ++ .../examples/reel/moderation/Reviews.java | 503 ++++++++++++++ .../reel/pay/MisconfiguredPayments.java | 112 ++++ .../examples/reel/pay/PayReturnPage.java | 53 ++ .../reel/pay/PaymentDeclinedException.java | 31 + .../examples/reel/pay/PaymentEndpoint.java | 80 +++ .../examples/reel/pay/PaymentProvider.java | 137 ++++ .../examples/reel/pay/Payments.java | 540 +++++++++++++++ .../examples/reel/pay/SimulatedPayments.java | 245 +++++++ .../examples/reel/pay/StripePayments.java | 429 ++++++++++++ .../reel/playback/HttpLicenseProxy.java | 77 +++ .../reel/playback/LicenseController.java | 101 +++ .../reel/playback/LicenseProvider.java | 45 ++ .../examples/reel/playback/Licenses.java | 148 ++++ .../examples/reel/playback/MediaOrigin.java | 235 +++++++ .../examples/reel/playback/MediaTokens.java | 122 ++++ .../reel/playback/PlaybackEndpoint.java | 80 +++ .../examples/reel/playback/Playbacks.java | 558 +++++++++++++++ .../examples/reel/push/CloudPushSender.java | 80 +++ .../examples/reel/push/ConsolePushSender.java | 43 ++ .../reel/push/MisconfiguredPushSender.java | 47 ++ .../examples/reel/push/PushDevices.java | 174 +++++ .../examples/reel/push/PushSender.java | 42 ++ .../reel/push/RecordingPushSender.java | 123 ++++ .../reel/repository/AdCampaignRepository.java | 42 ++ .../reel/repository/AdEventRepository.java | 41 ++ .../reel/repository/AnswerRepository.java | 39 ++ .../repository/BlockedItemRepository.java | 31 + .../repository/CertificateRepository.java | 39 ++ .../reel/repository/ChapterRepository.java | 33 + .../repository/ClosedAccountRepository.java | 29 + .../reel/repository/CouponRepository.java | 28 + .../reel/repository/DeviceRepository.java | 37 + .../reel/repository/DownloadRepository.java | 37 + .../reel/repository/FrameHashRepository.java | 33 + .../reel/repository/GenreRepository.java | 31 + .../reel/repository/HouseholdRepository.java | 35 + .../repository/InteractionRepository.java | 33 + .../reel/repository/InvoiceRepository.java | 37 + .../reel/repository/JobRepository.java | 39 ++ .../reel/repository/ListEntryRepository.java | 35 + .../repository/ModerationEventRepository.java | 41 ++ .../repository/ModerationItemRepository.java | 39 ++ .../repository/PaymentCustomerRepository.java | 38 ++ .../repository/PaymentMethodRepository.java | 48 ++ .../repository/PaymentSetupRepository.java | 51 ++ .../reel/repository/PlanRepository.java | 28 + .../reel/repository/PlaybackRepository.java | 45 ++ .../reel/repository/ProfileRepository.java | 72 ++ .../reel/repository/ProgressRepository.java | 43 ++ .../reel/repository/PushDeviceRepository.java | 35 + .../reel/repository/RentalRepository.java | 35 + .../reel/repository/SettingRepository.java | 28 + .../repository/SharingAlertRepository.java | 39 ++ .../repository/SubscriptionRepository.java | 37 + .../repository/TitleRatingRepository.java | 35 + .../reel/repository/TitleRepository.java | 35 + .../reel/repository/UploadRepository.java | 33 + .../reel/repository/VideoKeyRepository.java | 28 + .../reel/repository/VideoRepository.java | 35 + .../examples/reel/settings/Settings.java | 292 ++++++++ .../examples/reel/studio/Jobs.java | 352 ++++++++++ .../examples/reel/studio/Studio.java | 568 ++++++++++++++++ .../examples/reel/studio/StudioEndpoint.java | 153 +++++ .../reel/studio/UploadController.java | 69 ++ .../examples/reel/studio/Uploads.java | 206 ++++++ .../reel/studio/WorkerController.java | 190 ++++++ .../resources/db/migration/V1__accounts.sql | 76 +++ .../resources/db/migration/V2__catalog.sql | 107 +++ .../resources/db/migration/V3__studio.sql | 68 ++ .../resources/db/migration/V4__viewing.sql | 119 ++++ .../resources/db/migration/V5__billing.sql | 108 +++ .../main/resources/db/migration/V6__ads.sql | 36 + .../db/migration/mysql/V9__collation.sql | 55 ++ .../db/migration/postgresql/V9__collation.sql | 4 + .../db/migration/sqlite/V9__collation.sql | 4 + .../codenameone/examples/reel/AccessTest.java | 90 +++ .../codenameone/examples/reel/DaysTest.java | 51 ++ .../examples/reel/LearningTest.java | 123 ++++ .../com/codenameone/examples/reel/People.java | 72 ++ .../examples/reel/PlaybackTest.java | 135 ++++ .../examples/reel/ScriptedPayments.java | 71 ++ .../com/codenameone/examples/reel/SignIn.java | 100 +++ .../codenameone/examples/reel/StudioTest.java | 148 ++++ scripts/reel/pom.xml | 314 +++++++++ scripts/reel/shared/pom.xml | 49 ++ .../examples/reel/api/AccountApi.java | 83 +++ .../examples/reel/api/AccountClosureDto.java | 26 + .../examples/reel/api/AccountDto.java | 41 ++ .../examples/reel/api/AdBreakDto.java | 30 + .../examples/reel/api/AdCampaignDto.java | 44 ++ .../codenameone/examples/reel/api/AdDto.java | 36 + .../examples/reel/api/AdEventDto.java | 30 + .../examples/reel/api/AdminApi.java | 120 ++++ .../examples/reel/api/AdminStatsDto.java | 40 ++ .../examples/reel/api/AnswerDto.java | 32 + .../examples/reel/api/AnswerResultDto.java | 33 + .../examples/reel/api/AppConfigDto.java | 32 + .../examples/reel/api/AppealDto.java | 28 + .../examples/reel/api/BillingApi.java | 68 ++ .../examples/reel/api/BlocklistEntryDto.java | 31 + .../examples/reel/api/BrandingDto.java | 31 + .../examples/reel/api/CardDto.java | 28 + .../examples/reel/api/CatalogApi.java | 56 ++ .../examples/reel/api/CertificateDto.java | 34 + .../examples/reel/api/ChapterDto.java | 29 + .../examples/reel/api/ChaptersDto.java | 29 + .../examples/reel/api/CouponCheckDto.java | 31 + .../examples/reel/api/CouponDto.java | 33 + .../examples/reel/api/CourseProgressDto.java | 33 + .../examples/reel/api/DeviceDto.java | 33 + .../examples/reel/api/DownloadDto.java | 36 + .../examples/reel/api/EpisodeDto.java | 36 + .../examples/reel/api/FeatureFlagDto.java | 31 + .../examples/reel/api/Features.java | 65 ++ .../examples/reel/api/FlagDto.java | 26 + .../examples/reel/api/GenreDto.java | 29 + .../examples/reel/api/HeartbeatDto.java | 31 + .../examples/reel/api/HeartbeatReplyDto.java | 29 + .../examples/reel/api/HistoryEntryDto.java | 33 + .../examples/reel/api/HomeDto.java | 30 + .../examples/reel/api/HomeRowDto.java | 32 + .../reel/api/HouseholdProfileDto.java | 33 + .../examples/reel/api/InteractionDto.java | 36 + .../examples/reel/api/InteractionEditDto.java | 37 + .../examples/reel/api/InteractionsDto.java | 29 + .../examples/reel/api/InvoiceDto.java | 34 + .../codenameone/examples/reel/api/JobDto.java | 36 + .../examples/reel/api/LearningApi.java | 47 ++ .../examples/reel/api/LibraryApi.java | 70 ++ .../examples/reel/api/ModerationApi.java | 55 ++ .../reel/api/ModerationDecisionDto.java | 28 + .../examples/reel/api/ModerationEventDto.java | 30 + .../examples/reel/api/ModerationItemDto.java | 40 ++ .../examples/reel/api/NameCountDto.java | 26 + .../examples/reel/api/PasswordChangeDto.java | 26 + .../examples/reel/api/PaymentApi.java | 54 ++ .../examples/reel/api/PaymentConfigDto.java | 28 + .../examples/reel/api/PaymentMethodDto.java | 37 + .../examples/reel/api/PaymentSetupDto.java | 30 + .../codenameone/examples/reel/api/PinDto.java | 27 + .../examples/reel/api/PlanDto.java | 38 ++ .../examples/reel/api/PlaybackApi.java | 52 ++ .../examples/reel/api/PlaybackDto.java | 43 ++ .../examples/reel/api/PlaybackRequestDto.java | 35 + .../examples/reel/api/PresetDto.java | 30 + .../examples/reel/api/ProfileDto.java | 28 + .../examples/reel/api/ProfileEditDto.java | 33 + .../examples/reel/api/PurchaseReceiptDto.java | 30 + .../examples/reel/api/PushDeviceDto.java | 30 + .../examples/reel/api/RatingDto.java | 27 + .../examples/reel/api/ReasonDto.java | 27 + .../examples/reel/api/RentDto.java | 28 + .../examples/reel/api/RentalDto.java | 33 + .../examples/reel/api/ReportDto.java | 29 + .../examples/reel/api/RoleChangeDto.java | 29 + .../codenameone/examples/reel/api/Roles.java | 35 + .../examples/reel/api/SearchResultDto.java | 31 + .../examples/reel/api/SeriesPointDto.java | 28 + .../examples/reel/api/SessionDto.java | 37 + .../examples/reel/api/SharingAlertDto.java | 34 + .../examples/reel/api/SharingDecisionDto.java | 28 + .../examples/reel/api/SignupDto.java | 29 + .../examples/reel/api/StatsSeriesDto.java | 33 + .../examples/reel/api/StopDto.java | 29 + .../examples/reel/api/StudioApi.java | 93 +++ .../examples/reel/api/StudioTitleDto.java | 48 ++ .../examples/reel/api/StudioTitleEditDto.java | 39 ++ .../examples/reel/api/StudioVideoDto.java | 41 ++ .../examples/reel/api/StudioVideoEditDto.java | 31 + .../examples/reel/api/SubscribeDto.java | 29 + .../examples/reel/api/SubscriptionDto.java | 37 + .../examples/reel/api/SubtitleDto.java | 29 + .../examples/reel/api/SuspendDto.java | 28 + .../examples/reel/api/TitleAnalyticsDto.java | 41 ++ .../examples/reel/api/TitleCardDto.java | 39 ++ .../examples/reel/api/TitleDto.java | 57 ++ .../examples/reel/api/UploadDto.java | 33 + .../examples/reel/api/UploadStartDto.java | 30 + .../examples/reel/api/UserDetailDto.java | 34 + .../examples/reel/api/UserDto.java | 40 ++ .../examples/reel/api/WorkerJobDto.java | 37 + .../examples/reel/api/WorkerProgressDto.java | 29 + .../examples/reel/api/WorkerResultDto.java | 35 + scripts/reel/tools/packager/.gitignore | 1 + scripts/reel/tools/packager/README.adoc | 31 + scripts/reel/tools/packager/pom.xml | 73 ++ .../examples/reel/packager/Ffmpeg.java | 142 ++++ .../examples/reel/packager/Hex.java | 53 ++ .../examples/reel/packager/Json.java | 240 +++++++ .../examples/reel/packager/Mp4.java | 213 ++++++ .../examples/reel/packager/PackageJob.java | 214 ++++++ .../examples/reel/packager/Packager.java | 113 ++++ .../examples/reel/packager/Seed.java | 298 ++++++++ .../examples/reel/packager/Worker.java | 198 ++++++ .../examples/reel/packager/PackagerTest.java | 131 ++++ 274 files changed, 25771 insertions(+) create mode 100644 scripts/reel/LICENSE create mode 100644 scripts/reel/backend/application-dev.properties create mode 100644 scripts/reel/backend/application-test.properties create mode 100644 scripts/reel/backend/application.properties create mode 100644 scripts/reel/backend/pom.xml create mode 100755 scripts/reel/backend/server.sh create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Caller.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Days.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/DemoData.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Ids.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Kinds.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Requests.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Roles.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Rows.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/SecurityConfig.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Services.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/SignInCodePage.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Text.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/AccountCleanup.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/AccountEndpoint.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Accounts.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/DemoAccounts.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Devices.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Grants.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Households.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Moderation.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/admin/AdminEndpoint.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/admin/Sharing.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/admin/Stats.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/ads/Ads.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/billing/Billing.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/billing/BillingEndpoint.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/billing/Entitlements.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/catalog/Catalog.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/catalog/CatalogEndpoint.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/catalog/Recommender.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/AdCampaign.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/AdEvent.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Answer.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/BlockedItem.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Certificate.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Chapter.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/ClosedAccount.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Coupon.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Device.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Download.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/FrameHash.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Genre.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Household.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Interaction.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Invoice.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Job.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/ListEntry.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/ModerationEvent.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/ModerationItem.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/PaymentCustomer.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/PaymentMethod.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/PaymentSetup.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Plan.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Playback.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Profile.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Progress.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/PushDevice.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Rental.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Setting.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/SharingAlert.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Subscription.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Title.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/TitleRating.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Upload.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Video.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/VideoKey.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/learning/CertificatePage.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/learning/Learning.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/learning/LearningEndpoint.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/library/Library.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/library/LibraryEndpoint.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/moderation/ContentClassifier.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/moderation/ModerationEndpoint.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/moderation/Reviews.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/pay/MisconfiguredPayments.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/pay/PayReturnPage.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/pay/PaymentDeclinedException.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/pay/PaymentEndpoint.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/pay/PaymentProvider.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/pay/Payments.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/pay/SimulatedPayments.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/pay/StripePayments.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/playback/HttpLicenseProxy.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/playback/LicenseController.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/playback/LicenseProvider.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/playback/Licenses.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/playback/MediaOrigin.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/playback/MediaTokens.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/playback/PlaybackEndpoint.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/playback/Playbacks.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/push/CloudPushSender.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/push/ConsolePushSender.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/push/MisconfiguredPushSender.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/push/PushDevices.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/push/PushSender.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/push/RecordingPushSender.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/AdCampaignRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/AdEventRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/AnswerRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/BlockedItemRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/CertificateRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/ChapterRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/ClosedAccountRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/CouponRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/DeviceRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/DownloadRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/FrameHashRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/GenreRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/HouseholdRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/InteractionRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/InvoiceRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/JobRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/ListEntryRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/ModerationEventRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/ModerationItemRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/PaymentCustomerRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/PaymentMethodRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/PaymentSetupRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/PlanRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/PlaybackRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/ProfileRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/ProgressRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/PushDeviceRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/RentalRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/SettingRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/SharingAlertRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/SubscriptionRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/TitleRatingRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/TitleRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/UploadRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/VideoKeyRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/repository/VideoRepository.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/settings/Settings.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/studio/Jobs.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/studio/Studio.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/studio/StudioEndpoint.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/studio/UploadController.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/studio/Uploads.java create mode 100644 scripts/reel/backend/src/main/java/com/codenameone/examples/reel/studio/WorkerController.java create mode 100644 scripts/reel/backend/src/main/resources/db/migration/V1__accounts.sql create mode 100644 scripts/reel/backend/src/main/resources/db/migration/V2__catalog.sql create mode 100644 scripts/reel/backend/src/main/resources/db/migration/V3__studio.sql create mode 100644 scripts/reel/backend/src/main/resources/db/migration/V4__viewing.sql create mode 100644 scripts/reel/backend/src/main/resources/db/migration/V5__billing.sql create mode 100644 scripts/reel/backend/src/main/resources/db/migration/V6__ads.sql create mode 100644 scripts/reel/backend/src/main/resources/db/migration/mysql/V9__collation.sql create mode 100644 scripts/reel/backend/src/main/resources/db/migration/postgresql/V9__collation.sql create mode 100644 scripts/reel/backend/src/main/resources/db/migration/sqlite/V9__collation.sql create mode 100644 scripts/reel/backend/src/test/java/com/codenameone/examples/reel/AccessTest.java create mode 100644 scripts/reel/backend/src/test/java/com/codenameone/examples/reel/DaysTest.java create mode 100644 scripts/reel/backend/src/test/java/com/codenameone/examples/reel/LearningTest.java create mode 100644 scripts/reel/backend/src/test/java/com/codenameone/examples/reel/People.java create mode 100644 scripts/reel/backend/src/test/java/com/codenameone/examples/reel/PlaybackTest.java create mode 100644 scripts/reel/backend/src/test/java/com/codenameone/examples/reel/ScriptedPayments.java create mode 100644 scripts/reel/backend/src/test/java/com/codenameone/examples/reel/SignIn.java create mode 100644 scripts/reel/backend/src/test/java/com/codenameone/examples/reel/StudioTest.java create mode 100644 scripts/reel/pom.xml create mode 100644 scripts/reel/shared/pom.xml create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/AccountApi.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/AccountClosureDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/AccountDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/AdBreakDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/AdCampaignDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/AdDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/AdEventDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/AdminApi.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/AdminStatsDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/AnswerDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/AnswerResultDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/AppConfigDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/AppealDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/BillingApi.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/BlocklistEntryDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/BrandingDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/CardDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/CatalogApi.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/CertificateDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/ChapterDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/ChaptersDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/CouponCheckDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/CouponDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/CourseProgressDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/DeviceDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/DownloadDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/EpisodeDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/FeatureFlagDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/Features.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/FlagDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/GenreDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/HeartbeatDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/HeartbeatReplyDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/HistoryEntryDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/HomeDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/HomeRowDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/HouseholdProfileDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/InteractionDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/InteractionEditDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/InteractionsDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/InvoiceDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/JobDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/LearningApi.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/LibraryApi.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/ModerationApi.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/ModerationDecisionDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/ModerationEventDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/ModerationItemDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/NameCountDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/PasswordChangeDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/PaymentApi.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/PaymentConfigDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/PaymentMethodDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/PaymentSetupDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/PinDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/PlanDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/PlaybackApi.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/PlaybackDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/PlaybackRequestDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/PresetDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/ProfileDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/ProfileEditDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/PurchaseReceiptDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/PushDeviceDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/RatingDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/ReasonDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/RentDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/RentalDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/ReportDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/RoleChangeDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/Roles.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/SearchResultDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/SeriesPointDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/SessionDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/SharingAlertDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/SharingDecisionDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/SignupDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/StatsSeriesDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/StopDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/StudioApi.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/StudioTitleDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/StudioTitleEditDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/StudioVideoDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/StudioVideoEditDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/SubscribeDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/SubscriptionDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/SubtitleDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/SuspendDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/TitleAnalyticsDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/TitleCardDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/TitleDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/UploadDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/UploadStartDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/UserDetailDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/UserDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/WorkerJobDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/WorkerProgressDto.java create mode 100644 scripts/reel/shared/src/main/java/com/codenameone/examples/reel/api/WorkerResultDto.java create mode 100644 scripts/reel/tools/packager/.gitignore create mode 100644 scripts/reel/tools/packager/README.adoc create mode 100644 scripts/reel/tools/packager/pom.xml create mode 100644 scripts/reel/tools/packager/src/main/java/com/codenameone/examples/reel/packager/Ffmpeg.java create mode 100644 scripts/reel/tools/packager/src/main/java/com/codenameone/examples/reel/packager/Hex.java create mode 100644 scripts/reel/tools/packager/src/main/java/com/codenameone/examples/reel/packager/Json.java create mode 100644 scripts/reel/tools/packager/src/main/java/com/codenameone/examples/reel/packager/Mp4.java create mode 100644 scripts/reel/tools/packager/src/main/java/com/codenameone/examples/reel/packager/PackageJob.java create mode 100644 scripts/reel/tools/packager/src/main/java/com/codenameone/examples/reel/packager/Packager.java create mode 100644 scripts/reel/tools/packager/src/main/java/com/codenameone/examples/reel/packager/Seed.java create mode 100644 scripts/reel/tools/packager/src/main/java/com/codenameone/examples/reel/packager/Worker.java create mode 100644 scripts/reel/tools/packager/src/test/java/com/codenameone/examples/reel/packager/PackagerTest.java diff --git a/scripts/reel/LICENSE b/scripts/reel/LICENSE new file mode 100644 index 00000000000..e998a652408 --- /dev/null +++ b/scripts/reel/LICENSE @@ -0,0 +1,22 @@ +MIT No Attribution + +Copyright (c) 2026, Codename One and/or its affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +This applies to the Reel sample and to everything Codename One Initializr +writes into a generated project: the application templates under +maven/cn1app-archetype/src/main/resources/archetype-resources, the agent skill +under scripts/initializr/common/src/main/resources/skill, and the Initializr +template archives built from them. diff --git a/scripts/reel/backend/application-dev.properties b/scripts/reel/backend/application-dev.properties new file mode 100644 index 00000000000..981dbb98bc5 --- /dev/null +++ b/scripts/reel/backend/application-dev.properties @@ -0,0 +1,40 @@ +# The development profile, chosen with CN1_PROFILE=dev. +# +# An in-memory SQLite database, so the server starts with nothing installed: the +# migrations create the tables at start-up, and the demo accounts are added to +# them. Point this at a file to keep the data between runs: +# +# cn1.datasource.url=./target/app.db +# +# Production points cn1.datasource.url at the real database instead -- a +# postgres:// or mysql:// URL -- and the same code runs against it. +cn1.datasource.url=:memory: + +# On this profile the server also serves, without a token: +# +# /manage/health, /manage/metrics, /manage/prometheus, /manage/jobs +# /mcp -- the development MCP tools, for an agent working on this backend +# +# Neither is served outside a development profile unless it is turned on and +# given a token (cn1.management.token, cn1.mcp.token). + +# Text messages and push notifications are printed on the console and cards are +# simulated. All three are the defaults of a development profile anyway, and are +# spelled out to show where the choice is made. The console senders are refused +# outside a development profile. See application.properties for the real +# providers. +# tag::reel-properties-dev-providers[] +reel.payments.provider=simulated +reel.push.provider=console +# end::reel-properties-dev-providers[] + +# Packaged media and finished uploads. tools/fetch-demo-media.sh fills the +# first with the Blender open movies. +reel.media.dir=../media +reel.upload.dir=target/uploads +# The packaging worker's shared secret. Unset in production means the worker +# API answers 503, so a real deployment has to choose one. +reel.worker.token=dev-worker-token +# Store purchases are accepted without asking the store, which only a +# development server may do. +reel.billing.trust-store-receipts=true diff --git a/scripts/reel/backend/application-test.properties b/scripts/reel/backend/application-test.properties new file mode 100644 index 00000000000..a652cf4cf88 --- /dev/null +++ b/scripts/reel/backend/application-test.properties @@ -0,0 +1,24 @@ +# The test profile, chosen with CN1_PROFILE=test. It is what run-e2e.sh starts the +# server on for the app's end-to-end tests, and what @BackendTest runs the +# server's own tests on. +# +# Like `dev` -- an in-memory database, the demo accounts, text messages that are +# printed and not sent -- and one thing more: places are looked up in a list of +# ten the server carries, so a test run asks no public geocoder for anything. +cn1.datasource.url=:memory: + +# The tests sign several accounts in from one address, one after another, which +# is exactly what the sign-in rate limit is there to stop. +reel.signin.per-minute=1000 + +# Cards +# simulated: no test reaches a payment processor. +reel.payments.provider=simulated + +# Push notifications are kept for the tests to read, not sent. +reel.push.provider=recording + +reel.media.dir=target/test-media +reel.upload.dir=target/test-uploads +reel.worker.token=test-worker-token +reel.billing.trust-store-receipts=true diff --git a/scripts/reel/backend/application.properties b/scripts/reel/backend/application.properties new file mode 100644 index 00000000000..fadfa711cff --- /dev/null +++ b/scripts/reel/backend/application.properties @@ -0,0 +1,77 @@ +# The settings this server reads at start-up, and the layer with the lowest +# precedence. A system property of the same name wins over anything here, then +# the environment variable the key maps to (cn1.datasource.url is read from +# CN1_DATASOURCE_URL, and also from DATABASE_URL, which is what a platform sets +# for you), then application-.properties, then this file. +# +# It sits in the module root on purpose. This runtime has no classpath to load a +# resource from, so Config opens application*.properties as FILES relative to the +# working directory: the module directory under `cn1:backend`, and wherever the +# binary is started once it is packaged. +# +# Choose a profile with CN1_PROFILE. The one beside this file is `dev`: +# +# CN1_PROFILE=dev mvn -pl backend -Dcodename1.platform=backend cn1:backend +# +# A development profile (dev, test, local) needs nothing else: an in-memory +# database, a signing key made at start-up, simulated cards, the demo accounts +# and the Blender open movies tools/fetch-demo-media.sh packaged. A real +# deployment sets, here or in its environment: +# +# cn1.datasource.url the database +# cn1.security.authorizationserver.issuer the public address of this server +# cn1.security.authorizationserver.jwk.keys the key tokens are signed with +# reel.admin.email, reel.admin.password the first admin, who grants the rest +# reel.media.dir where packaged video is served from +# reel.upload.dir where finished uploads wait for the worker +# reel.worker.token the packaging worker's shared secret +# reel.media.secret the key media and license tokens are signed with +# reel.payments.provider and its settings below +# reel.push.app, reel.push.key push notifications, below +# +# and may change the currency (reel.currency). +# +# DRM. Every packaged video is AES-128 HLS and ClearKey CENC, served with keys +# this server holds; that is enough to stop a link being shared and is what +# the demo plays. Widevine and FairPlay need a license service of your own -- +# this server forwards the challenge to it and passes the answer back: +# +# reel.drm.widevine.url, reel.drm.widevine.header +# reel.drm.fairplay.url, reel.drm.fairplay.header, +# reel.drm.fairplay.certificate +# +# Unset, the scheme is simply not offered, and a device that needs it is told +# so before playback starts. +# +# Payments. reel.payments.provider is simulated or stripe; unset, it is +# simulated on a development profile and stripe on any other. Simulated cards: +# 4242 4242 4242 4242 is accepted, 4000 0000 0000 0002 is declined, and nothing +# is charged. Stripe works over Stripe's REST API: cards are added on Stripe's +# own page, so a card number never reaches the app or this server. Keep the +# key out of this file and out of the repository; set it in the environment of +# the deployment instead, where each setting is read in upper case with _ for +# every dot: +# +# REEL_PAYMENTS_STRIPE_SECRET a restricted or secret key (sk_...) +# +# Store purchases (reel.billing.trust-store-receipts) are accepted unverified +# only when that is true, which only the development profiles set; a real +# deployment verifies receipts with the store before granting anything. +# +# Push notifications. reel.push.provider is cloud, console or recording; +# unset, it is recording on the test profile, console on the other development +# profiles and cloud on any other. cloud sends through the Codename One push +# service and needs reel.push.app and reel.push.key (REEL_PUSH_KEY). + +# ${DATABASE_URL} is resolved when the value is READ, so this line is fine in a +# checkout where that variable is unset: the dev profile overrides the key, and +# nothing reads this one. SQLite (a file path), postgres:// and mysql:// all work. +cn1.datasource.url=${DATABASE_URL} + +# The port. A platform that sets PORT wins over this, which is what lets the same +# build deploy anywhere. +cn1.server.port=8080 + +# Creates the tables the accounts, the registered app and the tokens are kept in. +# Read by the build, so it has to be here and not in a profile's file. +cn1.security.schema.enabled=true diff --git a/scripts/reel/backend/pom.xml b/scripts/reel/backend/pom.xml new file mode 100644 index 00000000000..6362ff0b0a8 --- /dev/null +++ b/scripts/reel/backend/pom.xml @@ -0,0 +1,160 @@ + + + 4.0.0 + + com.codenameone.examples.reel + reel + 1.0-SNAPSHOT + + com.codenameone.examples.reel + reel-backend + 1.0-SNAPSHOT + jar + + reel-backend + + + + + com.codenameone + codenameone-backend + ${cn1.version} + + + + ${project.groupId} + reel-shared + ${project.version} + + + + org.xerial + sqlite-jdbc + 3.46.1.0 + runtime + + + + com.codenameone + codenameone-backend-test + ${cn1.version} + test + + + org.junit.jupiter + junit-jupiter + 5.9.3 + test + + + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + + cn1-generate-annotation-stubs + generate-sources + + + generate-annotation-stubs + + + + cn1-process-annotations + process-classes + + + process-annotations + + + + cn1-process-test-annotations + process-test-classes + + + process-test-annotations + + + + cn1-compiled-tests + test + + + backend-test + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M5 + + + false + + + + + diff --git a/scripts/reel/backend/server.sh b/scripts/reel/backend/server.sh new file mode 100755 index 00000000000..b350be76457 --- /dev/null +++ b/scripts/reel/backend/server.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# +# Builds and runs the Reel server. +# +# server.sh build [--jvm|--native] [--web] +# server.sh run [--jvm|--native] [--web] [--prebuilt] [--port N] +# server.sh web +# +# --jvm (the default) runs the server on this JDK, which is how you work on it. +# --native compiles it to a native binary, which is how it ships. +# +# The profile comes from CN1_PROFILE. `test` and `dev` need nothing else: an +# in-memory database, four demo accounts, a geocoder that knows ten places +# (`test`) and text messages that are printed, not sent. Anything else is a +# deployment and reads its settings from the environment -- see +# application.properties. +# +# `web` builds the app for the browser and stages it in target/webapp, which +# takes about a minute; `--web` does that as part of a build or a run. A server +# that finds the app there serves it at /, so http://localhost:8080/ is Reel +# with nothing installed. It stays staged until the next `mvn clean`: build it +# once, and again when the app changed. +# +# The server prints "listening on http port N" when it accepts connections. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP="$(cd "$HERE/.." && pwd)" +# The project's own Maven wrapper where it has one. +if [ -z "${MVN:-}" ]; then + if [ -x "$APP/mvnw" ]; then MVN="$APP/mvnw"; else MVN="mvn"; fi +fi + +log() { echo "[reel-server] $*" >&2; } + +build_jvm() { + log "building the server for the JVM" + "$MVN" -B -q -f "$APP/pom.xml" -Dcodename1.platform=backend -pl shared,backend \ + -Dmaven.test.skip=true package dependency:copy-dependencies \ + -DincludeScope=runtime -DoutputDirectory="$APP/backend/target/server-lib" >&2 +} + +build_native() { + log "translating the server to a native binary" + "$MVN" -B -q -f "$APP/pom.xml" -Dcodename1.platform=backend -pl shared,backend \ + -Dmaven.test.skip=true package cn1:backend-package \ + -Dcn1.backend.output="$APP/backend/target/reel-server" >&2 +} + +build_web() { + log "building the app for the browser" + "$MVN" -B -q -f "$APP/pom.xml" -Dcodename1.platform=backend -pl backend \ + cn1:backend-webapp >&2 +} + +# The server looks for the app in `webapp` beside its settings; here it is build +# output, so it is under target and the server is told. An address set by the +# caller is left alone. +serve_web() { + if [ -z "${CN1_WEBAPP_ROOT:-}" ] && [ -f "$APP/backend/target/webapp/index.html" ]; then + export CN1_WEBAPP_ROOT="target/webapp" + log "serving the web app in backend/target/webapp" + fi +} + +command="${1:-run}" +shift || true +mode="jvm" +port="${PORT:-8080}" +prebuilt=0 +web=0 +while [ $# -gt 0 ]; do + case "$1" in + --jvm) mode="jvm" ;; + --native) mode="native" ;; + --prebuilt) prebuilt=1 ;; + --web) web=1 ;; + --port) port="$2"; shift ;; + *) log "unknown argument $1"; exit 2 ;; + esac + shift +done + +case "$command" in + build) + if [ "$mode" = "native" ]; then build_native; else build_jvm; fi + [ "$web" = 0 ] || build_web + ;; + web) + build_web + ;; + run) + # application.properties is read from the working directory. + cd "$APP/backend" + if [ "$mode" = "native" ]; then + [ "$prebuilt" = 1 ] || build_native + [ "$web" = 0 ] || [ "$prebuilt" = 1 ] || build_web + serve_web + PORT="$port" exec "$APP/backend/target/reel-server" + fi + [ "$prebuilt" = 1 ] || build_jvm + [ "$web" = 0 ] || [ "$prebuilt" = 1 ] || build_web + serve_web + main="$(tr -d '\r\n' < "$APP/backend/target/classes/META-INF/cn1-backend-main")" + log "running $main on port $port" + PORT="$port" exec java -cp "$APP/backend/target/classes:$APP/backend/target/server-lib/*" "$main" + ;; + *) + log "unknown command $command (build, run, web)" + exit 2 + ;; +esac diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Caller.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Caller.java new file mode 100644 index 00000000000..d3bbc5ac4f2 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Caller.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.security.Authentication; +import com.codename1.backend.security.GrantedAuthority; +import com.codename1.backend.security.SecurityContextHolder; + +import java.util.Iterator; + +/// Who is making the request being served. +/// +/// The endpoints implement interfaces generated from the shared contract, whose +/// methods take what the app sends and nothing else, so the caller is read from +/// the security context of the request and not from a parameter. +public final class Caller { + private Caller() { + } + + /// The signed-in user's name, which is the e-mail address of the account. + /// Answers 401 when nobody is signed in; the filter chain has normally done + /// so already, and this is what stands behind it. + public static String name() { + Authentication who = SecurityContextHolder.getContext().getAuthentication(); + if (who == null || !who.isAuthenticated() || who.getName() == null) { + throw new ResponseStatusException(401, "Sign in first"); + } + return who.getName(); + } + + /// Whether the caller holds `role`, named without its `ROLE_` prefix. + public static boolean hasRole(String role) { + Authentication who = SecurityContextHolder.getContext().getAuthentication(); + if (who == null || who.getAuthorities() == null) { + return false; + } + String wanted = "ROLE_" + role; + Iterator it = who.getAuthorities().iterator(); + while (it.hasNext()) { + if (wanted.equals(it.next().getAuthority())) { + return true; + } + } + return false; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Days.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Days.java new file mode 100644 index 00000000000..61492b211a1 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Days.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel; + +/// Days, counted in UTC. +/// +/// The charts and the earnings are by day, and a day has to mean the same on +/// SQLite, PostgreSQL and MySQL, whose date functions agree on nothing. So the +/// tables hold milliseconds since the epoch, and the arithmetic is done here. +/// UTC throughout: a service in one city would shift by that city's offset. +public final class Days { + public static final long MILLIS = 86400000L; + + private Days() { + } + + /// The number of the day `millis` falls in: whole days since the epoch. + public static long number(long millis) { + return millis / MILLIS; + } + + /// The first millisecond of the day `millis` falls in. + public static long start(long millis) { + return millis - millis % MILLIS; + } + + /// The stretch of time from `from` to `to` cut at each midnight: the + /// number of each day it touches followed by how many of its milliseconds + /// fall in that day, day after day. Empty when `to` is not after `from`. + /// + /// Time on line is credited to the days it was spent in. Credited whole + /// to the day it ended in, a courier on line across midnight had the + /// minutes before it counted as the next day's. + public static long[] split(long from, long to) { + if (to <= from) { + return new long[0]; + } + long[] out = new long[(int) (number(to - 1) - number(from) + 1) * 2]; + long at = from; + for (int iter = 0; iter < out.length; iter += 2) { + long end = Math.min(to, start(at) + MILLIS); + out[iter] = number(at); + out[iter + 1] = end - at; + at = end; + } + return out; + } + + /// The day `millis` falls in, as `yyyy-MM-dd`. + public static String iso(long millis) { + // Days since the epoch to a civil date, by the usual era arithmetic: + // shift the year to start in March so the leap day is its last day. + long z = number(millis) + 719468L; + long era = z / 146097L; + long dayOfEra = z - era * 146097L; + long yearOfEra = (dayOfEra - dayOfEra / 1460L + dayOfEra / 36524L - dayOfEra / 146096L) + / 365L; + long dayOfYear = dayOfEra - (365L * yearOfEra + yearOfEra / 4L - yearOfEra / 100L); + long shifted = (5L * dayOfYear + 2L) / 153L; + long day = dayOfYear - (153L * shifted + 2L) / 5L + 1L; + long month = shifted < 10L ? shifted + 3L : shifted - 9L; + long year = yearOfEra + era * 400L + (month <= 2L ? 1L : 0L); + StringBuilder out = new StringBuilder(10); + out.append(year).append('-'); + if (month < 10L) { + out.append('0'); + } + out.append(month).append('-'); + if (day < 10L) { + out.append('0'); + } + return out.append(day).toString(); + } + + /// Whether `text` is a date written `yyyy-MM-dd`, with a month and a day + /// that could exist. + public static boolean isIso(String text) { + if (text == null || text.length() != 10 || text.charAt(4) != '-' || text.charAt(7) != '-') { + return false; + } + for (int iter = 0; iter < 10; iter++) { + char c = text.charAt(iter); + if (iter != 4 && iter != 7 && (c < '0' || c > '9')) { + return false; + } + } + int year = 0; + for (int iter = 0; iter < 4; iter++) { + year = year * 10 + (text.charAt(iter) - '0'); + } + int month = (text.charAt(5) - '0') * 10 + (text.charAt(6) - '0'); + int day = (text.charAt(8) - '0') * 10 + (text.charAt(9) - '0'); + // The day is held to its own month: these strings are compared as + // text and never parsed, so a 31st of February accepted here would be + // stored, and approved, as somebody's date of birth. + return month >= 1 && month <= 12 && day >= 1 && day <= length(year, month); + } + + // How many days a month has, by the Gregorian rule for leap years. + private static int length(int year, int month) { + if (month == 2) { + boolean leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + return leap ? 29 : 28; + } + return month == 4 || month == 6 || month == 9 || month == 11 ? 30 : 31; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/DemoData.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/DemoData.java new file mode 100644 index 00000000000..979f8a0cbc2 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/DemoData.java @@ -0,0 +1,341 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel; + +import com.codename1.backend.Crypto; +import com.codename1.backend.Json; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.PostConstruct; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.annotations.Value; +import com.codenameone.examples.reel.account.DemoAccounts; +import com.codenameone.examples.reel.ads.Ads; +import com.codenameone.examples.reel.billing.Billing; +import com.codenameone.examples.reel.billing.Entitlements; +import com.codenameone.examples.reel.domain.AdCampaign; +import com.codenameone.examples.reel.domain.Chapter; +import com.codenameone.examples.reel.domain.Genre; +import com.codenameone.examples.reel.domain.Interaction; +import com.codenameone.examples.reel.domain.Plan; +import com.codenameone.examples.reel.domain.Title; +import com.codenameone.examples.reel.domain.Video; +import com.codenameone.examples.reel.domain.VideoKey; +import com.codenameone.examples.reel.learning.Learning; +import com.codenameone.examples.reel.repository.AdCampaignRepository; +import com.codenameone.examples.reel.repository.ChapterRepository; +import com.codenameone.examples.reel.repository.GenreRepository; +import com.codenameone.examples.reel.repository.InteractionRepository; +import com.codenameone.examples.reel.repository.PlanRepository; +import com.codenameone.examples.reel.repository.TitleRepository; +import com.codenameone.examples.reel.repository.VideoKeyRepository; +import com.codenameone.examples.reel.repository.VideoRepository; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// What a development server starts with: the plans, the genres, the demo +/// accounts, two ad campaigns and a catalog of the Blender open movies plus a +/// short course. +/// +/// The video itself is not in the repository. `tools/fetch-demo-media.sh` +/// downloads the films, packages them with fresh random keys and writes +/// `seed.json` beside them; this reads that file for each video's keys, +/// duration and renditions. Without it -- a fresh checkout, the tests -- the +/// catalog is written anyway, with keys made here, so every screen works and +/// only the picture is missing. +@Component +public class DemoData { + /// The catalog: id, kind, name, year, rating, genres, access, synopsis. + private static final String[][] TITLES = { + {"big-buck-bunny", Kinds.MOVIE, "Big Buck Bunny", "2008", "G", "animation,comedy", Entitlements.FREE, + "A gentle giant rabbit takes a stand against three bullying rodents."}, + {"sintel", Kinds.MOVIE, "Sintel", "2010", "PG-13", "animation,fantasy", Entitlements.SUBSCRIPTION, + "A lonely girl searches the world for the baby dragon she raised."}, + {"tears-of-steel", Kinds.MOVIE, "Tears of Steel", "2012", "PG-13", "sci-fi,action", Entitlements.RENTAL, + "In a future Amsterdam, scientists replay one breakup to save the world from robots."}, + {"elephants-dream", Kinds.MOVIE, "Elephants Dream", "2006", "PG", "sci-fi,animation", + Entitlements.SUBSCRIPTION, "Two men explore a vast and strange machine that may only be in their heads."}, + {"streaming-101", Kinds.COURSE, "Streaming 101", "2026", "G", "learning", Entitlements.SUBSCRIPTION, + "How a streaming service protects its video: segments, keys, licences and downloads."}}; + private static final String[][] GENRES = { + {"animation", "Animation"}, {"comedy", "Comedy"}, {"fantasy", "Fantasy"}, {"sci-fi", "Science Fiction"}, + {"action", "Action"}, {"learning", "Learning"}}; + private static final String[] LESSONS = { + "Welcome to Reel Academy", "Keys and licences", "Watching offline"}; + + private final DemoAccounts accounts; + private final PlanRepository plans; + private final GenreRepository genres; + private final TitleRepository titles; + private final VideoRepository videos; + private final VideoKeyRepository keys; + private final ChapterRepository chapters; + private final InteractionRepository interactions; + private final AdCampaignRepository campaigns; + private final String mediaDir; + private final String currency; + + public DemoData(DemoAccounts accounts, PlanRepository plans, GenreRepository genres, TitleRepository titles, + VideoRepository videos, VideoKeyRepository keys, ChapterRepository chapters, + InteractionRepository interactions, AdCampaignRepository campaigns, + @Value("${reel.media.dir:media}") String mediaDir, + @Value("${reel.currency:USD}") String currency) { + this.accounts = accounts; + this.plans = plans; + this.genres = genres; + this.titles = titles; + this.videos = videos; + this.keys = keys; + this.chapters = chapters; + this.interactions = interactions; + this.campaigns = campaigns; + this.mediaDir = mediaDir; + this.currency = currency; + } + + @PostConstruct + public void seed() throws IOException { + if (accounts.seed()) { + catalog(readSeed()); + } + } + + @Transactional(rollbackFor = IOException.class) + void catalog(Map seed) throws IOException { + long now = System.currentTimeMillis(); + plan("basic", "Basic with ads", "Everything, with short ad breaks. One screen, HD.", 699, 1, "720", true, + false, 7, 0); + plan("standard", "Standard", "No ads, two screens in Full HD, downloads.", 1549, 2, "1080", false, true, + 7, 1); + plan("premium", "Premium", "No ads, four screens in the best quality there is, downloads.", 2299, 4, + "2160", false, true, 0, 2); + for (int iter = 0; iter < GENRES.length; iter++) { + Genre g = new Genre(); + g.setId(GENRES[iter][0]); + g.setName(GENRES[iter][1]); + g.setPosition(iter); + genres.insert(g); + } + for (int iter = 0; iter < TITLES.length; iter++) { + String[] t = TITLES[iter]; + Title row = new Title(); + row.setId(t[0]); + row.setKind(t[1]); + row.setName(t[2]); + row.setYear(Integer.parseInt(t[3])); + row.setRating(t[4]); + row.setGenres(t[5]); + row.setAccess(t[6]); + row.setRentalPriceCents(Entitlements.RENTAL.equals(t[6]) ? 399 : 0); + row.setSynopsis(t[7]); + row.setCreator(DemoAccounts.CREATOR); + row.setStatus(Kinds.PUBLISHED); + row.setFeatured(iter == 1); + row.setCreatedAt(now - (TITLES.length - iter) * Days.MILLIS); + row.setPublishedAt(row.getCreatedAt()); + if (Kinds.COURSE.equals(t[1])) { + row.setCertificate(true); + row.setPassPercent(70); + String first = "academy-lesson-1"; + row.setPosterUrl("/art/" + first + "/poster.jpg"); + row.setBackdropUrl(row.getPosterUrl()); + titles.insert(row); + for (int lesson = 0; lesson < LESSONS.length; lesson++) { + video("academy-lesson-" + (lesson + 1), t[0], Kinds.LESSON, lesson + 1, LESSONS[lesson], + seed, now); + } + lessonContent(); + } else { + row.setPosterUrl("/art/" + t[0] + "/poster.jpg"); + row.setBackdropUrl(row.getPosterUrl()); + titles.insert(row); + video(t[0], t[0], Kinds.FEATURE, 1, t[2], seed, now); + } + } + campaign("ad-coffee", "Northside Roasters", "Fresh Coffee", "https://example.com/coffee", "", now, seed); + campaign("ad-bike", "Ride More Cycles", "Ride More", "https://example.com/bikes", "action,sci-fi", now, seed); + } + + private void plan(String code, String name, String description, long cents, int streams, String quality, + boolean ads, boolean downloads, int trialDays, int position) throws IOException { + Plan p = new Plan(); + p.setCode(code); + p.setName(name); + p.setDescription(description); + p.setPriceCents(cents); + p.setCurrency(currency); + p.setBillingInterval(Billing.MONTH); + p.setMaxStreams(streams); + p.setMaxQuality(quality); + p.setAdSupported(ads); + p.setDownloads(downloads); + p.setTrialDays(trialDays); + p.setActive(true); + p.setPosition(position); + plans.insert(p); + } + + private void video(String id, String titleId, String role, int number, String name, Map seed, + long now) throws IOException { + Map entry = seed.get(id); + Video v = new Video(); + v.setId(id); + v.setTitleId(titleId); + v.setRole(role); + v.setNumber(number); + v.setName(name); + v.setStatus(Kinds.READY); + v.setProgressPercent(100); + v.setDurationSeconds(entry == null ? 600 : (int) Rows.number(entry.get("durationSeconds"))); + v.setWidth(entry == null ? 1280 : (int) Rows.number(entry.get("width"))); + v.setHeight(entry == null ? 720 : (int) Rows.number(entry.get("height"))); + v.setRenditions(entry == null ? "360,720" : Rows.join(strings(entry.get("renditions")))); + // The same three as a video the worker packaged: see Jobs.done. + v.setSchemes("AES_128,CLEARKEY,WIDEVINE"); + v.setThumbnailUrl("/art/" + id + "/poster.jpg"); + v.setCreatedAt(now); + videos.insert(v); + VideoKey k = new VideoKey(); + k.setVideoId(id); + k.setKeyId(entry == null ? Ids.hex(Crypto.randomBytes(16)) : Rows.text(entry.get("keyId"))); + k.setKeyHex(entry == null ? Ids.hex(Crypto.randomBytes(16)) : Rows.text(entry.get("keyHex"))); + k.setIvHex(entry == null ? Ids.hex(Crypto.randomBytes(16)) : Rows.text(entry.get("ivHex"))); + k.setCreatedAt(now); + keys.insert(k); + } + + /// Chapters, a quiz, a poll and a branch, at the points the generated + /// lessons show them. + private void lessonContent() throws IOException { + chapter("academy-lesson-1", 0, "Why segments"); + chapter("academy-lesson-1", 15, "Encryption"); + chapter("academy-lesson-2", 0, "Asking for a key"); + chapter("academy-lesson-3", 0, "Downloads"); + interaction("academy-lesson-1", Learning.QUIZ, 30, "What does a player download first?", + Arrays.asList("The master playlist", "The last segment", "The key"), "0", + "The master playlist lists the renditions, and each rendition lists its segments.", true, "", 0); + interaction("academy-lesson-2", Learning.POLL, 20, "Which device do you watch on most?", + Arrays.asList("Phone", "Tablet", "TV", "Computer"), "", "", false, "", 0); + interaction("academy-lesson-2", Learning.QUIZ, 35, "Who gets a decryption key?", + Arrays.asList("Anyone with the link", "Entitled viewers", "Only admins"), "1", + "The license server checks the session before it hands over a key.", true, "", 1); + interaction("academy-lesson-3", Learning.BRANCH, 40, "Where next?", + Arrays.asList("Review keys and licences", "Finish the course"), "", "", false, + "academy-lesson-2,", 0); + } + + private void chapter(String video, int at, String name) throws IOException { + Chapter c = new Chapter(); + c.setId(Ids.next()); + c.setVideoId(video); + c.setStartSeconds(at); + c.setTitle(name); + chapters.insert(c); + } + + private void interaction(String video, String kind, int at, String prompt, List options, + String answers, String explanation, boolean required, String targets, int position) throws IOException { + Interaction i = new Interaction(); + i.setId(Ids.next()); + i.setVideoId(video); + i.setKind(kind); + i.setAtSeconds(at); + i.setPrompt(prompt); + i.setOptions(Rows.joinLines(options)); + i.setAnswers(answers); + i.setExplanation(explanation); + i.setRequired(required); + i.setBranchTargets(targets); + i.setPosition(position); + interactions.insert(i); + } + + private void campaign(String video, String advertiser, String name, String click, String genreList, long now, + Map seed) throws IOException { + video(video, "", Kinds.AD, 1, name, seed, now); + AdCampaign c = new AdCampaign(); + c.setId(Ids.next()); + c.setAdvertiser(advertiser); + c.setName(name); + c.setCreativeUrl(Ads.VIDEO + video); + c.setDurationSeconds(15); + c.setSkippableAfterSeconds(5); + c.setClickUrl(click); + c.setCpmCents(1200); + c.setBudgetCents(50000); + c.setGenres(genreList); + c.setActive(true); + c.setStartsAt(now - Days.MILLIS); + c.setEndsAt(now + 90 * Days.MILLIS); + c.setCreatedAt(now); + campaigns.insert(c); + } + + /// `seed.json` keyed by slug, or empty when there is none. + Map readSeed() throws IOException { + Map out = new HashMap(); + File f = new File(mediaDir, "seed.json"); + if (f.isFile()) { + Object list = Json.parseObject(new String(read(f), "UTF-8")).get("videos"); + if (list instanceof List) { + List rows = (List) list; + for (int iter = 0; iter < rows.size(); iter++) { + Object row = rows.get(iter); + if (row instanceof Map) { + out.put(Rows.text(((Map) row).get("slug")), (Map) row); + } + } + } + } + return out; + } + + private static List strings(Object value) { + List out = new ArrayList(); + if (value instanceof List) { + List list = (List) value; + for (int iter = 0; iter < list.size(); iter++) { + out.add(String.valueOf(Rows.number(list.get(iter)))); + } + } + return out; + } + + private static byte[] read(File f) throws IOException { + InputStream in = new FileInputStream(f); + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + for (int n = in.read(buf); n > 0; n = in.read(buf)) { + out.write(buf, 0, n); + } + return out.toByteArray(); + } finally { + in.close(); + } + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Ids.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Ids.java new file mode 100644 index 00000000000..e4920d1388a --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Ids.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel; + +import com.codename1.backend.Crypto; + +import java.io.IOException; + +/// Identifiers and secrets made of random bytes. +public final class Ids { + private static final char[] HEX = "0123456789abcdef".toCharArray(); + + private Ids() { + } + + /// A new identifier: 32 hex digits from 16 random bytes. Random and not a + /// counter, so the id of one delivery says nothing about the id of another, and + /// so no table needs a generated key, which every database spells its own way. + public static String next() throws IOException { + return hex(Crypto.randomBytes(16)); + } + + public static String hex(byte[] bytes) { + char[] out = new char[bytes.length * 2]; + for (int iter = 0; iter < bytes.length; iter++) { + out[iter * 2] = HEX[(bytes[iter] >> 4) & 0xf]; + out[iter * 2 + 1] = HEX[bytes[iter] & 0xf]; + } + return new String(out); + } + + /// The SHA-256 of `text`, in hex. What is stored in place of a secret that + /// only ever needs to be compared. + public static String sha256(String text) throws IOException { + return hex(Crypto.sha256(text.getBytes("UTF-8"))); + } + + /// Hex text as bytes; null for text that is not an even run of hex digits. + public static byte[] unhex(String text) { + if (text == null || (text.length() & 1) != 0) { + return null; + } + byte[] out = new byte[text.length() / 2]; + for (int iter = 0; iter < out.length; iter++) { + int hi = Character.digit(text.charAt(iter * 2), 16); + int lo = Character.digit(text.charAt(iter * 2 + 1), 16); + if (hi < 0 || lo < 0) { + return null; + } + out[iter] = (byte) ((hi << 4) | lo); + } + return out; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Kinds.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Kinds.java new file mode 100644 index 00000000000..9b8894e8b4a --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Kinds.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel; + +/// The values of the catalog's status and kind columns, in one place so the +/// studio, the worker, moderation and playback agree on them. +public final class Kinds { + public static final String MOVIE = "MOVIE"; + public static final String SERIES = "SERIES"; + public static final String COURSE = "COURSE"; + + /// A title the creator is still editing; nobody else sees it. + public static final String DRAFT = "DRAFT"; + /// Submitted, waiting for a moderator. + public static final String IN_REVIEW = "IN_REVIEW"; + public static final String PUBLISHED = "PUBLISHED"; + public static final String REJECTED = "REJECTED"; + /// Taken down after publication (a report upheld, or by an admin). + public static final String REMOVED = "REMOVED"; + + public static final String FEATURE = "FEATURE"; + public static final String EPISODE = "EPISODE"; + public static final String LESSON = "LESSON"; + public static final String TRAILER = "TRAILER"; + public static final String AD = "AD"; + + public static final String UPLOADING = "UPLOADING"; + public static final String QUEUED = "QUEUED"; + public static final String PROCESSING = "PROCESSING"; + public static final String READY = "READY"; + public static final String FAILED = "FAILED"; + /// The worker's frame hashes matched the blocklist. + public static final String BLOCKED = "BLOCKED"; + + public static final String OPEN = "OPEN"; + public static final String UPHELD = "UPHELD"; + public static final String DISMISSED = "DISMISSED"; + + private Kinds() { + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Requests.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Requests.java new file mode 100644 index 00000000000..34a3b7668c0 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Requests.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel; + +import com.codename1.backend.security.SecurityExchange; + +/// What the request being served says about where it came from. +/// +/// The app names its device in two headers it sends with every call: +/// `X-Reel-Device`, an id it made once and keeps, and `X-Reel-Device-Name`. +/// The country is the one a CDN in front of the server puts in `CF-IPCountry`; +/// without such a CDN it is empty and sharing detection works on addresses +/// alone. +public final class Requests { + public static final String DEVICE = "X-Reel-Device"; + public static final String DEVICE_NAME = "X-Reel-Device-Name"; + public static final String PLATFORM = "X-Reel-Platform"; + + private Requests() { + } + + public static String header(String name) { + SecurityExchange exchange = SecurityExchange.current(); + String value = exchange == null ? null : exchange.getRequest().getHeader(name); + return value == null ? "" : value.trim(); + } + + /// The device id, cut to what the column holds; empty when not sent. + public static String deviceId() { + return clean(header(DEVICE), 64); + } + + public static String deviceName() { + String name = clean(header(DEVICE_NAME), 80); + return name.length() == 0 ? "Unknown device" : name; + } + + public static String platform() { + return clean(header(PLATFORM), 32); + } + + public static String ip() { + SecurityExchange exchange = SecurityExchange.current(); + String address = exchange == null ? null : exchange.getRequest().getRemoteAddress(); + return address == null ? "" : clean(address, 64); + } + + public static String country() { + return clean(header("CF-IPCountry"), 8); + } + + /// Printable ASCII only, at most `max` characters: these go into columns, + /// logs and admin screens. + static String clean(String value, int max) { + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter < value.length() && out.length() < max; iter++) { + char c = value.charAt(iter); + if (c >= ' ' && c <= '~') { + out.append(c); + } + } + return out.toString().trim(); + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Roles.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Roles.java new file mode 100644 index 00000000000..153e6eda148 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Roles.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel; + +/// The roles, as the backend's user store names them (without `ROLE_`). The +/// shared [com.codenameone.examples.reel.api.Roles] holds the same names for the app. +public final class Roles { + public static final String VIEWER = com.codenameone.examples.reel.api.Roles.VIEWER; + public static final String CREATOR = com.codenameone.examples.reel.api.Roles.CREATOR; + public static final String MODERATOR = com.codenameone.examples.reel.api.Roles.MODERATOR; + public static final String ADMIN = com.codenameone.examples.reel.api.Roles.ADMIN; + + /// Whether the caller moderates or administers: staff can play any title, + /// published or not, without a plan, to review it. + public static boolean isStaff() { + return Caller.hasRole(ADMIN) || Caller.hasRole(MODERATOR); + } + + private Roles() { + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Rows.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Rows.java new file mode 100644 index 00000000000..8b9112d0e75 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Rows.java @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Reading the rows of an aggregate query, whose columns come back as whatever +/// number type the database driver chose, and the comma lists some columns hold. +public final class Rows { + private Rows() { + } + + public static long number(Object value) { + return value instanceof Number ? ((Number) value).longValue() : 0L; + } + + public static long number(Long value) { + return value == null ? 0L : value.longValue(); + } + + public static String text(Object value) { + return value == null ? "" : String.valueOf(value); + } + + /// The first column of each row as a key and the second as its count. + public static Map counts(List rows) { + Map out = new HashMap(); + for (int iter = 0; iter < rows.size(); iter++) { + Object[] row = rows.get(iter); + out.put(text(row[0]), Long.valueOf(number(row[1]))); + } + return out; + } + + /// A comma-separated column as a list, without empty entries. + public static List split(String value) { + List out = new ArrayList(); + if (value == null || value.length() == 0) { + return out; + } + int start = 0; + while (start <= value.length()) { + int comma = value.indexOf(',', start); + int end = comma < 0 ? value.length() : comma; + String part = value.substring(start, end).trim(); + if (part.length() > 0) { + out.add(part); + } + if (comma < 0) { + break; + } + start = comma + 1; + } + return out; + } + + /// A list as a comma-separated column. Commas inside an entry are dropped, + /// since they would split it on the way back. + public static String join(List values) { + StringBuilder out = new StringBuilder(); + if (values == null) { + return ""; + } + for (int iter = 0; iter < values.size(); iter++) { + String v = values.get(iter); + if (v == null) { + continue; + } + v = v.replace(",", "").trim(); + if (v.length() == 0) { + continue; + } + if (out.length() > 0) { + out.append(','); + } + out.append(v); + } + return out.toString(); + } + + /// Comma-separated integers, skipping anything that is not one. + public static List ints(String value) { + List out = new ArrayList(); + List parts = split(value); + for (int iter = 0; iter < parts.size(); iter++) { + try { + out.add(Integer.valueOf(Integer.parseInt(parts.get(iter)))); + } catch (NumberFormatException skipped) { + // not a number: not an answer + } + } + return out; + } + + public static String joinInts(List values) { + List parts = new ArrayList(); + if (values != null) { + for (int iter = 0; iter < values.size(); iter++) { + if (values.get(iter) != null) { + parts.add(String.valueOf(values.get(iter).intValue())); + } + } + } + return join(parts); + } + + /// A list as one entry per line, for entries that may hold commas (the + /// options of a quiz question). Line breaks inside an entry become spaces. + public static String joinLines(List values) { + StringBuilder out = new StringBuilder(); + for (int iter = 0; values != null && iter < values.size(); iter++) { + String v = values.get(iter) == null ? "" : values.get(iter).replace('\r', ' ').replace('\n', ' ').trim(); + if (v.length() == 0) { + continue; + } + if (out.length() > 0) { + out.append('\n'); + } + out.append(v); + } + return out.toString(); + } + + public static List splitLines(String value) { + List out = new ArrayList(); + if (value == null || value.length() == 0) { + return out; + } + String[] parts = value.split("\n"); + for (int iter = 0; iter < parts.length; iter++) { + if (parts[iter].trim().length() > 0) { + out.add(parts[iter].trim()); + } + } + return out; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/SecurityConfig.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/SecurityConfig.java new file mode 100644 index 00000000000..5fa69addc72 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/SecurityConfig.java @@ -0,0 +1,410 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel; + +import com.codename1.backend.Config; +import com.codename1.backend.DataSource; +import com.codename1.backend.HttpServer; +import com.codename1.backend.annotations.Bean; +import com.codename1.backend.annotations.Configuration; +import com.codename1.backend.annotations.Order; +import com.codename1.backend.annotations.Value; +import com.codename1.backend.security.AntPathRequestMatcher; +import com.codename1.backend.security.DisabledException; +import com.codename1.backend.security.GrantedAuthority; +import com.codename1.backend.security.HttpSecurity; +import com.codename1.backend.security.HttpStatusReturningLogoutSuccessHandler; +import com.codename1.backend.security.LogoutFilter; +import com.codename1.backend.security.SecurityExchange; +import com.codename1.backend.security.SecurityFilter; +import com.codename1.backend.security.SecurityFilterChain; +import com.codename1.backend.security.SessionCreationPolicy; +import com.codename1.backend.security.core.userdetails.JdbcUserDetailsManager; +import com.codename1.backend.security.core.userdetails.UserDetails; +import com.codename1.backend.security.crypto.JwkSource; +import com.codename1.backend.security.crypto.PasswordEncoder; +import com.codename1.backend.security.crypto.PasswordEncoderFactories; +import com.codename1.backend.security.oauth2.core.AuthorizationGrantType; +import com.codename1.backend.security.oauth2.core.ClientAuthenticationMethod; +import com.codename1.backend.security.oauth2.jwt.DefaultJwtDecoder; +import com.codename1.backend.security.oauth2.jwt.JwtAudienceValidator; +import com.codename1.backend.security.oauth2.jwt.JwtClaimValidator; +import com.codename1.backend.security.oauth2.jwt.JwtIssuerValidator; +import com.codename1.backend.security.oauth2.jwt.JwtValidators; +import com.codename1.backend.security.oauth2.server.authorization.AuthorizationServerKeys; +import com.codename1.backend.security.oauth2.server.authorization.AuthorizationServerSettings; +import com.codename1.backend.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService; +import com.codename1.backend.security.oauth2.server.authorization.JdbcRegisteredClientRepository; +import com.codename1.backend.security.oauth2.server.authorization.OAuth2AuthorizationService; +import com.codename1.backend.security.oauth2.server.authorization.RegisteredClient; +import com.codename1.backend.security.oauth2.server.authorization.RegisteredClientRepository; +import com.codename1.backend.security.oauth2.server.resource.JwtAuthenticationConverter; +import com.codename1.backend.security.ratelimit.InMemoryRateLimiter; +import com.codename1.backend.security.ratelimit.RateLimitKeys; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; + +/// Who may call what. +/// +/// This server is two things at once, and has a filter chain for each: +/// +/// - **An authorization server.** The app signs a user in here -- e-mail and +/// password, then the authorization-code flow with PKCE -- and gets a +/// short-lived access token and a refresh token. Nothing but this server ever +/// sees the password. +/// - **An API.** Everything under `/api` takes that access token as a bearer +/// token, keeps no session, and decides from the account's roles what the +/// caller may do. +/// +/// The roles are VIEWER, CREATOR, MODERATOR and ADMIN ([Roles]). +/// `/api/admin` is for admins and `/api/moderation` for moderators and admins; +/// the studio checks the creator role itself, because a feature flag can open +/// uploads to everyone. The rest is for anyone signed in and acts on the +/// caller's own account, profiles, list and subscription. +@Configuration +public class SecurityConfig { + /// The id the app signs in as. A public client: it holds no secret, since + /// anything shipped inside an app can be read out of it, and proves itself + /// with PKCE instead. + public static final String CLIENT_ID = "reel-app"; + /// Where the authorization code is sent back to. The app makes the requests + /// of the flow itself and reads the code out of the redirect, so this is + /// never opened; it is the loopback address because that is the one address + /// nobody else can register a listener on. An app that signs in through the + /// system browser adds its own address scheme here with `redirectUri`. + public static final String LOOPBACK_REDIRECT = "http://127.0.0.1/callback"; + /// Where the code is sent back to when the app runs in a browser: a path on + /// this server, which answers with the code it was given. See + /// [#withWebRedirect]. + public static final String WEB_REDIRECT_PATH = "/signin/code"; + /// Whom the access tokens are for. The API accepts no token issued for + /// anything else. + public static final String AUDIENCE = "urn:reel:api"; + public static final String ISSUER_KEY = "cn1.security.authorizationserver.issuer"; + + @Bean + public PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } + + /// The accounts, in the tables `cn1.security.schema.enabled` creates. + @Bean + public JdbcUserDetailsManager users(DataSource db) { + return new JdbcUserDetailsManager(db); + } + + @Bean + public RegisteredClientRepository clients(DataSource db, + @Value("${" + ISSUER_KEY + ":}") final String issuer) throws IOException { + final JdbcRegisteredClientRepository clients = new JdbcRegisteredClientRepository(db); + if (clients.findByClientId(CLIENT_ID) == null) { + clients.save(RegisteredClient.withId("reel").clientId(CLIENT_ID) + .clientName("Reel") + .clientAuthenticationMethod(ClientAuthenticationMethod.NONE) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) + .redirectUri(LOOPBACK_REDIRECT) + .scope("openid").scope("profile") + .build()); + } + // The app in a browser is the same client with one more address: this + // server's own. It is worked out and not stored, because on a + // development profile the server has no one address to store -- and a + // row written by an earlier version would not have it either. + return new RegisteredClientRepository() { + @Override + public void save(RegisteredClient client) { + clients.save(client); + } + + @Override + public RegisteredClient findByClientId(String clientId) { + return withWebRedirect(clients.findByClientId(clientId), issuer); + } + + @Override + public RegisteredClient findById(String id) { + return withWebRedirect(clients.findById(id), issuer); + } + }; + } + + /// The app's registration with [#WEB_REDIRECT_PATH] on this server added + /// to the addresses a code may be sent back to. + /// + /// A browser will not show a page the answer to a request that was + /// redirected to another origin, so the app running in one cannot read its + /// code off a redirect to the loopback address the way the installed app + /// does. It asks for the code to be sent to this server instead, which + /// hands it straight back ([SignInCodePage]). + /// + /// "This server" is the configured issuer where there is one, and is then + /// the only web address accepted. With none -- a development profile -- it + /// is the address the request arrived at, the same rule + /// [#issuedAtThisAddress] holds a token to: a code is only ever sent back + /// to the server the browser was already talking to. + static RegisteredClient withWebRedirect(RegisteredClient client, String issuer) { + if (client == null || !CLIENT_ID.equals(client.getClientId())) { + return client; + } + String here = here(issuer); + if (here == null) { + return client; + } + RegisteredClient.Builder copy = RegisteredClient.withId(client.getId()) + .clientId(client.getClientId()) + .clientName(client.getClientName()) + .clientSettings(client.getClientSettings()) + .tokenSettings(client.getTokenSettings()) + .redirectUri(here + WEB_REDIRECT_PATH); + for (ClientAuthenticationMethod method : client.getClientAuthenticationMethods()) { + copy.clientAuthenticationMethod(method); + } + for (AuthorizationGrantType grant : client.getAuthorizationGrantTypes()) { + copy.authorizationGrantType(grant); + } + for (String uri : client.getRedirectUris()) { + copy.redirectUri(uri); + } + for (String scope : client.getScopes()) { + copy.scope(scope); + } + for (String resource : client.getResources()) { + copy.resource(resource); + } + return copy.build(); + } + + /// Refuses a request that changes something and was sent by a page of + /// another site. + /// + /// A browser names the site a request came from in `Origin` on every + /// request that is not a plain read, and a page cannot change or drop it. + /// The installed app is not a browser and sends none, so a request without + /// one is let through: there is no cookie jar of somebody else's for it to + /// be riding on. + static SecurityFilter sameOrigin(final String issuer) { + return (request, chain) -> { + String method = request.getMethod(); + if ("GET".equals(method) || "HEAD".equals(method) || "OPTIONS".equals(method)) { + return chain.doFilter(request); + } + String origin = request.getHeader("Origin"); + if (origin == null || origin.length() == 0) { + return chain.doFilter(request); + } + String here = here(issuer); + if (here != null && sameOrigin(origin, here)) { + return chain.doFilter(request); + } + return new HttpServer.Response(403, "application/json", + "{\"error\":\"cross_origin\"}".getBytes("UTF-8")) + .header("Cache-Control", "no-store"); + }; + } + + /// This server's address: the configured issuer, and with none -- a + /// development profile -- the address the request in hand arrived at. Null + /// outside a request that says. + static String here(String issuer) { + if (issuer != null && issuer.length() > 0) { + return issuer; + } + SecurityExchange exchange = SecurityExchange.current(); + String host = exchange == null ? null : exchange.getRequest().getHeader("Host"); + if (host == null || host.length() == 0) { + return null; + } + return (exchange.isSecure() ? "https://" : "http://") + host; + } + + /// Whether `origin` is the origin of `address`: the same scheme, host and + /// port, whatever path the address goes on to name. Scheme and host are + /// compared without regard to case, and without folding it -- that would + /// depend on the language the server runs in. + static boolean sameOrigin(String origin, String address) { + int path = address.indexOf('/', address.indexOf("://") + 3); + String root = path < 0 ? address : address.substring(0, path); + return origin.equalsIgnoreCase(root); + } + + /// Kept in the database, so a refresh token outlives a restart and is good + /// on every instance of the server. + @Bean + public OAuth2AuthorizationService authorizations(DataSource db) { + return new JdbcOAuth2AuthorizationService(db); + } + + /// The keys the tokens are signed with: the files named by + /// `cn1.security.authorizationserver.jwk.keys`. On a development profile + /// with none configured, a key made at start-up -- which is why signing in + /// again is needed after restarting a development server. + @Bean + public JwkSource signingKeys(Config config) throws IOException { + return AuthorizationServerKeys.load(config); + } + + /// Signing in. A session exists here and only here, for the few requests + /// between the password being accepted and the code being handed over; + /// the app ends it with `POST /logout` once it has its tokens, and one it + /// never ends runs out after `cn1.session.timeout` seconds without a + /// request, half an hour unless set. + @Bean + @Order(1) + public SecurityFilterChain signIn(HttpSecurity http, + @Value("${" + ISSUER_KEY + ":}") final String issuer, + @Value("${reel.signin.per-minute:10}") int signInsPerMinute) throws IOException { + http.securityMatcher("/oauth2/**", "/.well-known/**", "/userinfo", "/login", "/login/**", + "/logout") + .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) + // No CSRF token: the app posts the sign-in form itself, from its own + // screen, and would have to fetch a token first to send one. What a + // token is for still has to be done, because the app also runs in a + // browser, where the session is a cookie the browser keeps and would + // send along with a form another site submitted -- signing its + // visitor in as somebody else. Two things stand in for it. The + // session cookie is `SameSite=Lax`, the server's default, so it does + // not travel with another site's request; and [#sameOrigin] below + // refuses any request that changes something and says it came from + // another site, which is the one a forged sign-in is. A server that + // signs people in through a page it serves keeps CSRF on. + .csrf(csrf -> csrf.disable()) + .addFilterBefore(sameOrigin(issuer), LogoutFilter.class) + // Signing out is a POST and nothing else -- with CSRF off the + // default takes a GET too, which an image tag on any page can send + // -- and is answered with a status, for the reason signing in is. + .logout(logout -> logout + .logoutRequestMatcher(AntPathRequestMatcher.antMatcher("POST", "/logout")) + .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler(204))) + // And it is answered with a status, not a redirect to a page: + // the app in a browser cannot see a redirect or the cookie, only + // how the request ended, so that has to be the answer. The session + // cookie is set either way; the installed app reads it, a browser + // keeps it. + .formLogin(form -> form + .successHandler((request, authentication) -> new HttpServer.Response(200, + "application/json", "{\"signedIn\":true}".getBytes("UTF-8")) + .header("Cache-Control", "no-store")) + .failureHandler((request, refused) -> new HttpServer.Response(401, + "application/json", "{\"signedIn\":false}".getBytes("UTF-8")) + .header("Cache-Control", "no-store"))) + // Guessing passwords is the attack on this chain. Ten tries a minute + // from one address, unless `reel.signin.per-minute` says otherwise: + // the end-to-end tests are many people signing in from one address, + // and their profile raises it. + .rateLimit("/login", RateLimitKeys.clientAddress(), + new InMemoryRateLimiter(signInsPerMinute, 60)) + .authorizationServer(server -> server + .settings(AuthorizationServerSettings.builder() + .defaultAudience(AUDIENCE).build())); + return http.build(); + } + + /// The API. Stateless: every request stands on its own bearer token. + @Bean + @Order(2) + public SecurityFilterChain api(HttpSecurity http, JwkSource signingKeys, + @Value("${" + ISSUER_KEY + ":}") final String issuer, + JdbcUserDetailsManager users) throws IOException { + DefaultJwtDecoder decoder = DefaultJwtDecoder.withJwkSource(signingKeys).build(); + decoder.setJwtValidator(JwtValidators.createDefaultWithValidators( + new JwtAudienceValidator(AUDIENCE), + issuer.length() > 0 ? new JwtIssuerValidator(issuer) + : new JwtClaimValidator("iss", SecurityConfig::issuedAtThisAddress))); + + // The token says who the caller is and nothing about what they may do. + // The roles are read from the account on every request instead of being + // copied into the token, for one reason: an admin who withdraws a role + // or suspends an account means now, not when the token runs out. It + // costs a lookup by primary key per request. + JwtAuthenticationConverter roles = new JwtAuthenticationConverter(); + roles.setJwtGrantedAuthoritiesConverter(jwt -> { + String name = jwt.getSubject(); + if (name == null || !users.userExists(name)) { + throw new DisabledException("No such account"); + } + UserDetails account = users.loadUserByUsername(name); + if (!account.isEnabled() || !account.isAccountNonLocked()) { + throw new DisabledException("This account is suspended"); + } + Collection held = new ArrayList(); + held.addAll(account.getAuthorities()); + return held; + }); + + http.securityMatcher("/api/**") + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + // Opening accounts and sending text messages are the two things + // here that cost something each time and that a stranger can ask + // for. One limiter per rule: a shared one would share its count. + // Opening accounts is the one thing a stranger can ask for that + // writes a row each time. One limiter per rule: a shared one would + // share its count. + .rateLimit("/api/signup", RateLimitKeys.clientAddress(), + new InMemoryRateLimiter(20, 3600)) + // Guessing the current password with a token in hand. + .rateLimit("/api/me/password", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(10, 3600)) + // Certificate codes are short enough to type; guessing them in a + // loop is what this slows down. + .rateLimit("/api/verify/**", RateLimitKeys.clientAddress(), + new InMemoryRateLimiter(60, 60)) + // Every playback start mints tokens and may raise a sharing alert; + // a person starts a few a minute at most. + .rateLimit("/api/playback/start", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(30, 60)) + // tag::reel-api-rules[] + // The media origin, the license server, the worker API and the + // certificate page are outside this chain: each checks its own + // signed token (MediaTokens, the worker's bearer secret) and none + // of them takes the user's access token. + .authorizeHttpRequests(auth -> auth + .requestMatchers(AntPathRequestMatcher.antMatcher("POST", "/api/signup")).permitAll() + .requestMatchers(AntPathRequestMatcher.antMatcher("GET", "/api/config")).permitAll() + .requestMatchers(AntPathRequestMatcher.antMatcher("GET", "/api/plans")).permitAll() + .requestMatchers(AntPathRequestMatcher.antMatcher("GET", "/api/verify/**")).permitAll() + .requestMatchers("/api/admin/**").hasRole(Roles.ADMIN) + .requestMatchers("/api/moderation/**").hasAnyRole(Roles.MODERATOR, Roles.ADMIN) + .anyRequest().authenticated()) + // end::reel-api-rules[] + .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.decoder(decoder) + .jwtAuthenticationConverter(roles))); + return http.build(); + } + + /// Whether a token's issuer is the address this request came to. + /// + /// Used only when no issuer is configured, which the server allows only on + /// a development profile: it then issues tokens in the name of whatever + /// address each request arrived at, so that is the name to hold a token to. + static boolean issuedAtThisAddress(Object issuer) { + SecurityExchange exchange = SecurityExchange.current(); + if (exchange == null || !(issuer instanceof String)) { + return false; + } + String host = exchange.getRequest().getHeader("Host"); + return host != null && ((exchange.isSecure() ? "https://" : "http://") + host) + .equals(issuer); + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Services.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Services.java new file mode 100644 index 00000000000..00c8c0d0ebc --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Services.java @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel; + +import com.codename1.backend.Config; +import com.codename1.backend.annotations.Bean; +import com.codename1.backend.annotations.Configuration; +import com.codename1.backend.annotations.Value; +import com.codename1.backend.push.CloudPush; +import com.codenameone.examples.reel.moderation.ContentClassifier; +import com.codenameone.examples.reel.pay.MisconfiguredPayments; +import com.codenameone.examples.reel.playback.HttpLicenseProxy; +import com.codenameone.examples.reel.playback.LicenseProvider; +import com.codenameone.examples.reel.playback.Licenses; +import com.codenameone.examples.reel.repository.VideoKeyRepository; +import com.codenameone.examples.reel.settings.Settings; +import com.codenameone.examples.reel.pay.PaymentProvider; +import com.codenameone.examples.reel.pay.SimulatedPayments; +import com.codenameone.examples.reel.pay.StripePayments; +import com.codenameone.examples.reel.push.CloudPushSender; +import com.codenameone.examples.reel.push.ConsolePushSender; +import com.codenameone.examples.reel.push.MisconfiguredPushSender; +import com.codenameone.examples.reel.push.PushSender; +import com.codenameone.examples.reel.push.RecordingPushSender; + +import java.util.ArrayList; +import java.util.List; + +/// The services this server reaches outside itself for, each behind an +/// interface and each chosen here from the settings. +/// +/// Each is named by one setting -- `reel.payments.provider` and +/// `reel.push.provider` -- and, when that is not set, a development profile +/// (dev, test, local) gets the one that needs no account: notifications +/// printed on the console, cards simulated. Any other profile gets the real provider, +/// and when its settings are missing the server still starts, logs a +/// configuration error naming each missing setting, and fails every call to +/// that provider with the same message. It never quietly falls back to a +/// development stand-in. +/// +/// Every setting is also read from the environment, in upper case with `_` +/// for each `.` (`REEL_PAYMENTS_STRIPE_SECRET`), which is where secrets belong. +@Configuration +public class Services { + // tag::reel-payment-choice[] + /// `reel.payments.provider` is one of: + /// + /// - `stripe`: [StripePayments], an untested stub over Stripe's REST API. + /// It needs `reel.payments.stripe.secret`. + /// - `simulated`: test card numbers, and no money moves. + /// + /// Unset, it is `simulated` on a development profile and `stripe` otherwise. + /// + /// With Stripe a card is typed into Stripe's own page, which then sends the + /// browser back to `/pay/return` on this server. That address has to be the + /// one a phone can reach, so it is `reel.public.url` when that is set + /// and the issuer -- the public address of this server, which a deployment + /// sets anyway -- when it is not. + @Bean + public PaymentProvider paymentProvider(Config config, + @Value("${reel.payments.provider:}") String chosen, + @Value("${reel.payments.stripe.secret:}") String secret, + @Value("${reel.public.url:}") String publicUrl, + @Value("${" + SecurityConfig.ISSUER_KEY + ":}") String issuer) { + String kind = chosen.trim().length() > 0 ? chosen.trim() + : config.isDevelopmentProfile() ? "simulated" : "stripe"; + if ("simulated".equalsIgnoreCase(kind)) { + return new SimulatedPayments(); + } + if (!"stripe".equalsIgnoreCase(kind)) { + return new MisconfiguredPayments(kind, "reel.payments.provider is '" + kind + + "', which is neither stripe nor simulated"); + } + String address = publicUrl.length() > 0 ? publicUrl : issuer; + String missing = missing("", new String[] {"reel.payments.stripe.secret", secret, + "reel.public.url (or " + SecurityConfig.ISSUER_KEY + ")", address}); + if (missing.length() > 0) { + return new MisconfiguredPayments("stripe", unset("payments", "stripe", missing)); + } + while (address.endsWith("/")) { + address = address.substring(0, address.length() - 1); + } + return new StripePayments(secret, address + "/pay/return"); + } + // end::reel-payment-choice[] + + // tag::reel-push-choice[] + /// `reel.push.provider` is one of: + /// + /// - `cloud`: the Codename One push service. It needs `reel.push.app`, + /// the app's push id, and `reel.push.key`, a server key with the push + /// scope; both are in the Push section of the Codename One console. + /// - `console`: prints each notification, on a development profile only. + /// - `recording`: keeps each notification for the tests to read, on a + /// development profile only. + /// + /// Unset, it is `recording` on the `test` profile, `console` on the other + /// development profiles and `cloud` otherwise. A server whose push settings + /// are missing still starts and still serves video: it logs a configuration + /// error at start-up and again for every notification it could not send. + @Bean + public PushSender pushSender(Config config, + @Value("${reel.push.provider:}") String chosen, + @Value("${reel.push.app:}") String app, + @Value("${reel.push.key:}") String key, + @Value("${reel.push.url:}") String url) { + String kind = chosen.trim().length() > 0 ? chosen.trim() + : "test".equals(config.getProfile()) ? "recording" + : config.isDevelopmentProfile() ? "console" : "cloud"; + if ("console".equalsIgnoreCase(kind) || "recording".equalsIgnoreCase(kind)) { + if (!config.isDevelopmentProfile()) { + return new MisconfiguredPushSender("reel.push.provider=" + kind + + " sends nothing and is refused on the '" + config.getProfile() + + "' profile; set it to cloud"); + } + return "console".equalsIgnoreCase(kind) ? new ConsolePushSender() + : new RecordingPushSender(); + } + if (!"cloud".equalsIgnoreCase(kind)) { + return new MisconfiguredPushSender("reel.push.provider is '" + kind + + "', which is none of cloud, console or recording"); + } + String missing = missing("reel.push.", new String[] {"app", app, "key", key}); + if (missing.length() > 0) { + return new MisconfiguredPushSender(unset("push", "cloud", missing)); + } + try { + CloudPush push = new CloudPush(app.trim(), key.trim()); + return new CloudPushSender(url.trim().length() > 0 ? push.withEndpoint(url.trim()) + : push); + } catch (IllegalArgumentException err) { + return new MisconfiguredPushSender("push provider 'cloud' is misconfigured: " + + err.getMessage()); + } + } + // end::reel-push-choice[] + + /// The names, after `prefix`, of the settings in `namesAndValues` whose + /// value is empty, separated by commas. + private static String missing(String prefix, String[] namesAndValues) { + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter + 1 < namesAndValues.length; iter += 2) { + if (namesAndValues[iter + 1] == null || namesAndValues[iter + 1].trim().length() == 0) { + if (out.length() > 0) { + out.append(", "); + } + out.append(prefix).append(namesAndValues[iter]); + } + } + return out.toString(); + } + + /// The license servers. ClearKey and AES-128 are answered by Reel itself + /// from the keys in `video_key`; Widevine and FairPlay need a vendor + /// license service (a DRM provider's proxy endpoint), so each is offered + /// only when its URL is configured. + @Bean + public Licenses licenses(VideoKeyRepository keys, Settings settings, + @Value("${reel.drm.widevine.url:}") String widevineUrl, + @Value("${reel.drm.widevine.header:}") String widevineHeader, + @Value("${reel.drm.fairplay.url:}") String fairplayUrl, + @Value("${reel.drm.fairplay.header:}") String fairplayHeader, + @Value("${reel.drm.fairplay.certificate:}") String fairplayCertificate) { + LicenseProvider widevine = widevineUrl.trim().length() == 0 ? null + : new HttpLicenseProxy("WIDEVINE", widevineUrl.trim(), widevineHeader, null); + LicenseProvider fairplay = fairplayUrl.trim().length() == 0 ? null + : new HttpLicenseProxy("FAIRPLAY", fairplayUrl.trim(), fairplayHeader, fairplayCertificate.trim()); + return new Licenses(keys, settings, widevine, fairplay); + } + + /// No classifier: see [ContentClassifier] for plugging one in. + @Bean + public ContentClassifier contentClassifier() { + return new ContentClassifier() { + @Override + public List classify(String name, String synopsis, List tags) { + return new ArrayList(); + } + }; + } + + private static String unset(String service, String provider, String missing) { + return service + " provider '" + provider + "' is not configured; set " + missing; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/SignInCodePage.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/SignInCodePage.java new file mode 100644 index 00000000000..c2cddf61505 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/SignInCodePage.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel; + +import com.codename1.backend.HttpServer; +import com.codename1.backend.Json; +import com.codename1.backend.annotations.GetMapping; +import com.codename1.backend.annotations.RequestParam; +import com.codename1.backend.annotations.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +/// Where a sign-in from a browser ends: the authorization code, handed back. +/// +/// The installed app asks for its code to be sent to a loopback address and +/// reads it off the redirect without following it. A page in a browser cannot +/// do that -- the browser follows the redirect itself and shows the page only +/// what it found at the end -- so the app running in one asks for the code to +/// be sent here, and what is at the end is the code. +/// +/// Nothing is decided here and nothing is trusted: the code arrived in the +/// address, so repeating it tells the caller nothing it did not have, and it +/// is worth nothing without the PKCE verifier only the app that started the +/// sign-in holds. That is why this needs no sign-in of its own. +@RestController +public class SignInCodePage { + @GetMapping(SecurityConfig.WEB_REDIRECT_PATH) + public HttpServer.Response code( + @RequestParam(value = "code", required = false) String code, + @RequestParam(value = "state", required = false) String state, + @RequestParam(value = "error", required = false) String error) throws Exception { + Map answer = new LinkedHashMap(); + if (code != null) { + answer.put("code", code); + } + if (state != null) { + answer.put("state", state); + } + if (error != null) { + answer.put("error", error); + } + return new HttpServer.Response(code == null ? 400 : 200, "application/json", + Json.write(answer).getBytes("UTF-8")) + // A code is good once, for a few minutes; nothing should keep it. + .header("Cache-Control", "no-store") + // And nothing should read it as anything but data: the values + // come from the address, which anyone can write a link to. + .header("X-Content-Type-Options", "nosniff"); + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Text.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Text.java new file mode 100644 index 00000000000..dde18414dc8 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Text.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel; + +import com.codename1.backend.ResponseStatusException; + +/// What the app sends, made safe to store: trimmed, bounded, and one of the +/// words a field allows. +public final class Text { + private Text() { + } + + /// `value` trimmed, or empty for null; answers 400 when longer than `max`. + public static String optional(String value, int max, String what) { + String text = value == null ? "" : value.trim(); + if (text.length() > max) { + throw new ResponseStatusException(400, "That is too long for " + what); + } + return text; + } + + /// `value` trimmed; answers 400 when it is empty or longer than `max`. + public static String required(String value, int max, String what) { + String text = optional(value, max, what); + if (text.length() == 0) { + throw new ResponseStatusException(400, "Tell us " + what); + } + return text; + } + + /// `value` when it is one of `allowed`, and `fallback` when it is empty; + /// answers 400 for anything else. The words are compared as written: they + /// are the app's own constants, not something a person types. + public static String oneOf(String value, String[] allowed, String fallback, String what) { + String text = value == null ? "" : value.trim(); + if (text.length() == 0) { + return fallback; + } + for (int iter = 0; iter < allowed.length; iter++) { + if (allowed[iter].equals(text)) { + return text; + } + } + throw new ResponseStatusException(400, "That is not a value for " + what); + } + + /// The first letters of the first and last words of a name, in upper case + /// when they are ASCII letters: "Dana Courier" is "DD". + public static String initials(String name) { + String text = name == null ? "" : name.trim(); + if (text.length() == 0) { + return ""; + } + int last = text.lastIndexOf(' '); + StringBuilder out = new StringBuilder(2); + out.append(upper(text.charAt(0))); + if (last > 0 && last + 1 < text.length()) { + out.append(upper(text.charAt(last + 1))); + } + return out.toString(); + } + + private static char upper(char c) { + // By hand: toUpperCase() follows the server's locale. + return c >= 'a' && c <= 'z' ? (char) (c - ('a' - 'A')) : c; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/AccountCleanup.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/AccountCleanup.java new file mode 100644 index 00000000000..942c02391b7 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/AccountCleanup.java @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.account; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.reel.domain.Household; +import com.codenameone.examples.reel.repository.AnswerRepository; +import com.codenameone.examples.reel.repository.CertificateRepository; +import com.codenameone.examples.reel.repository.DeviceRepository; +import com.codenameone.examples.reel.repository.DownloadRepository; +import com.codenameone.examples.reel.repository.HouseholdRepository; +import com.codenameone.examples.reel.repository.ListEntryRepository; +import com.codenameone.examples.reel.repository.PlaybackRepository; +import com.codenameone.examples.reel.repository.ProgressRepository; +import com.codenameone.examples.reel.repository.PushDeviceRepository; +import com.codenameone.examples.reel.repository.RentalRepository; +import com.codenameone.examples.reel.repository.SharingAlertRepository; +import com.codenameone.examples.reel.repository.SubscriptionRepository; +import com.codenameone.examples.reel.repository.TitleRatingRepository; + +import java.util.List; + +/// Deletes what a closed account leaves in the viewing tables, inside the +/// closing transaction ([Accounts#close]). Invoices are not touched: they are the +/// service's accounts, and stay under the retired name. Titles the account +/// published stay too, unpublished by the studio when their creator is gone. +@Component +public class AccountCleanup { + private final HouseholdRepository households; + private final ListEntryRepository lists; + private final DeviceRepository devices; + private final ProgressRepository progress; + private final TitleRatingRepository ratings; + private final DownloadRepository downloads; + private final AnswerRepository answers; + private final PlaybackRepository playbacks; + private final CertificateRepository certificates; + private final SharingAlertRepository alerts; + private final SubscriptionRepository subscriptions; + private final RentalRepository rentals; + private final PushDeviceRepository pushes; + + public AccountCleanup(HouseholdRepository households, ListEntryRepository lists, + DeviceRepository devices, ProgressRepository progress, TitleRatingRepository ratings, + DownloadRepository downloads, AnswerRepository answers, PlaybackRepository playbacks, + CertificateRepository certificates, SharingAlertRepository alerts, + SubscriptionRepository subscriptions, RentalRepository rentals, + PushDeviceRepository pushes) { + this.households = households; + this.lists = lists; + this.devices = devices; + this.progress = progress; + this.ratings = ratings; + this.downloads = downloads; + this.answers = answers; + this.playbacks = playbacks; + this.certificates = certificates; + this.alerts = alerts; + this.subscriptions = subscriptions; + this.rentals = rentals; + this.pushes = pushes; + } + + public void removeEverythingOf(String username) { + List mine = households.findByUsernameOrderByCreatedAtAsc(username); + for (int iter = 0; iter < mine.size(); iter++) { + lists.deleteByProfileId(mine.get(iter).getId()); + } + households.deleteByUsername(username); + devices.deleteByUsername(username); + progress.deleteByUsername(username); + ratings.deleteByUsername(username); + downloads.deleteByUsername(username); + answers.deleteByUsername(username); + playbacks.deleteByUsername(username); + certificates.deleteByUsername(username); + alerts.deleteByUsername(username); + rentals.deleteByUsername(username); + pushes.deleteByUsername(username); + if (subscriptions.findById(username) != null) { + subscriptions.deleteById(username); + } + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/AccountEndpoint.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/AccountEndpoint.java new file mode 100644 index 00000000000..aadba9830a6 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/AccountEndpoint.java @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.account; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.reel.Caller; +import com.codenameone.examples.reel.api.AccountApiServer; +import com.codenameone.examples.reel.api.AccountClosureDto; +import com.codenameone.examples.reel.api.AccountDto; +import com.codenameone.examples.reel.api.DeviceDto; +import com.codenameone.examples.reel.api.HouseholdProfileDto; +import com.codenameone.examples.reel.api.PasswordChangeDto; +import com.codenameone.examples.reel.api.PinDto; +import com.codenameone.examples.reel.api.ProfileDto; +import com.codenameone.examples.reel.api.ProfileEditDto; +import com.codenameone.examples.reel.api.PushDeviceDto; +import com.codenameone.examples.reel.api.SessionDto; +import com.codenameone.examples.reel.api.SignupDto; +import com.codenameone.examples.reel.playback.Playbacks; +import com.codenameone.examples.reel.push.PushDevices; + +import java.util.List; + +@Component +public class AccountEndpoint implements AccountApiServer { + private final Accounts accounts; + private final Households households; + private final Devices devices; + private final PushDevices pushes; + private final Playbacks playbacks; + + public AccountEndpoint(Accounts accounts, Households households, Devices devices, + PushDevices pushes, Playbacks playbacks) { + this.accounts = accounts; + this.households = households; + this.devices = devices; + this.pushes = pushes; + this.playbacks = playbacks; + } + + @Override + public AccountDto signup(SignupDto signup) throws Exception { + return accounts.register(signup); + } + + /// Also where a device learns it was signed out: [Devices#touch] answers 401. + @Override + public AccountDto me() throws Exception { + String who = Caller.name(); + devices.touch(who); + accounts.seen(who); + return accounts.describe(who); + } + + @Override + public AccountDto updateProfile(ProfileDto profile) throws Exception { + return accounts.saveProfile(Caller.name(), profile); + } + + @Override + public String changePassword(PasswordChangeDto change) throws Exception { + accounts.changePassword(Caller.name(), change == null ? null : change.current(), + change == null ? null : change.replacement()); + return "ok"; + } + + @Override + public String close(AccountClosureDto closure) throws Exception { + accounts.close(Caller.name(), closure == null ? null : closure.password()); + return "closed"; + } + + @Override + public List devices() throws Exception { + return devices.list(Caller.name()); + } + + @Override + public String signOutDevice(String id) throws Exception { + String who = Caller.name(); + devices.signOut(who, id); + playbacks.endForDevice(who, id); + return "ok"; + } + + @Override + public List sessions() throws Exception { + return playbacks.liveFor(Caller.name()); + } + + @Override + public String registerPush(PushDeviceDto device) throws Exception { + pushes.register(Caller.name(), device); + return "ok"; + } + + @Override + public List profiles() throws Exception { + String who = Caller.name(); + return households.list(who, accounts.displayName(who)); + } + + @Override + public HouseholdProfileDto createProfile(ProfileEditDto profile) throws Exception { + return households.create(Caller.name(), profile); + } + + @Override + public HouseholdProfileDto updateHouseholdProfile(String id, ProfileEditDto profile) throws Exception { + return households.update(Caller.name(), id, profile); + } + + @Override + public String deleteProfile(String id) throws Exception { + households.delete(Caller.name(), id); + return "ok"; + } + + @Override + public HouseholdProfileDto unlockProfile(String id, PinDto pin) throws Exception { + return households.unlock(Caller.name(), id, pin == null ? null : pin.pin()); + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Accounts.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Accounts.java new file mode 100644 index 00000000000..2b8b59b2086 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Accounts.java @@ -0,0 +1,406 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.account; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.security.GrantedAuthority; +import com.codename1.backend.security.core.userdetails.JdbcUserDetailsManager; +import com.codename1.backend.security.core.userdetails.User; +import com.codename1.backend.security.core.userdetails.UserDetails; +import com.codename1.backend.security.crypto.PasswordEncoder; +import com.codenameone.examples.reel.Ids; +import com.codenameone.examples.reel.Roles; +import com.codenameone.examples.reel.api.AccountDto; +import com.codenameone.examples.reel.api.ProfileDto; +import com.codenameone.examples.reel.api.SignupDto; +import com.codenameone.examples.reel.api.UserDto; +import com.codenameone.examples.reel.billing.Entitlements; +import com.codenameone.examples.reel.domain.ClosedAccount; +import com.codenameone.examples.reel.domain.Profile; +import com.codenameone.examples.reel.pay.Payments; +import com.codenameone.examples.reel.repository.ClosedAccountRepository; +import com.codenameone.examples.reel.repository.ProfileRepository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +/// Accounts: opening one, describing one, and what an admin may change. +/// +/// An account is two things kept under one name. Its sign-in -- password hash, +/// roles, whether it is enabled -- is the backend's own user store. Its profile +/// is this application's table. The name is the e-mail address, in lower case. +@Component +public class Accounts { + private final ProfileRepository profiles; + private final AccountCleanup cleanup; + private final JdbcUserDetailsManager users; + private final PasswordEncoder encoder; + private final Moderation moderation; + private final Payments payments; + private final ClosedAccountRepository closed; + private final Grants grants; + private final Entitlements entitlements; + + public Accounts(ProfileRepository profiles, AccountCleanup cleanup, JdbcUserDetailsManager users, + PasswordEncoder encoder, Moderation moderation, Payments payments, + ClosedAccountRepository closed, Grants grants, Entitlements entitlements) { + this.profiles = profiles; + this.cleanup = cleanup; + this.users = users; + this.encoder = encoder; + this.moderation = moderation; + this.payments = payments; + this.closed = closed; + this.grants = grants; + this.entitlements = entitlements; + } + + /// Opens an account. Answers 400 for a request that cannot be one and 409 + /// when the e-mail already has one. Not a transaction itself, so that after + /// a failure it can look again: two sign-ups racing for one e-mail both + /// pass the check, the unique key refuses the second, and the 409 is the + /// honest answer whatever the database called the error. + public AccountDto register(SignupDto request) throws IOException { + try { + return open(request); + } catch (ResponseStatusException answered) { + throw answered; + } catch (IOException | RuntimeException failed) { + if (request != null && request.email() != null && users.userExists(email(request.email()))) { + throw new ResponseStatusException(409, "That e-mail already has an account"); + } + throw failed; + } + } + + @Transactional(rollbackFor = IOException.class) + private AccountDto open(SignupDto request) throws IOException { + if (request == null) { + throw new ResponseStatusException(400, "Nothing to register"); + } + String username = email(request.email()); + password(request.password()); + String name = required(request.name(), 120, "your name"); + if (users.userExists(username)) { + throw new ResponseStatusException(409, "That e-mail already has an account"); + } + // A closed account's e-mail is never handed out again: its invoices and + // certificates are still filed under it. + if (closed.existsById(Ids.sha256(username))) { + throw new ResponseStatusException(409, + "That e-mail belonged to an account that was closed. Use another"); + } + create(username, request.password(), name, new String[] {Roles.VIEWER}, + System.currentTimeMillis()); + return describe(username); + } + + /// Creates the sign-in and the profile. The caller has checked the name is free. + @Transactional(rollbackFor = IOException.class) + public void create(String username, String password, String name, String[] roles, long openedAt) + throws IOException { + users.createUser(User.withUsername(username).password(encoder.encode(password)) + .roles(roles).build()); + Profile profile = new Profile(); + profile.setUsername(username); + profile.setDisplayName(name); + profile.setCreatedAt(openedAt); + profile.setLanguage("en"); + profiles.insert(profile); + } + + public boolean exists(String username) { + return username != null && users.userExists(username); + } + + /// The account as its owner sees it. Answers 404 for a name with no account. + @Transactional(readOnly = true) + public AccountDto describe(String username) throws IOException { + Profile profile = profiles.findById(username); + if (profile == null || !users.userExists(username)) { + throw new ResponseStatusException(404, "No such account"); + } + UserDetails signIn = users.loadUserByUsername(username); + Entitlements.Allowance plan = entitlements.allowance(username); + return new AccountDto(username, profile.getDisplayName(), username, roles(signIn), + plan.planCode, plan.planName, plan.status, plan.periodEndsAt, plan.ads, plan.maxStreams, + plan.maxQuality, plan.downloads, !signIn.isEnabled()); + } + + /// The account as the admin console lists it, without the counts the + /// console adds. + @Transactional(readOnly = true) + public UserDto summary(String username, int devices, int streamsNow) throws IOException { + Profile profile = profiles.findById(username); + if (profile == null || !users.userExists(username)) { + throw new ResponseStatusException(404, "No such account"); + } + UserDetails signIn = users.loadUserByUsername(username); + Entitlements.Allowance plan = entitlements.allowance(username); + return new UserDto(username, profile.getDisplayName(), username, roles(signIn), plan.planCode, + plan.status, !signIn.isEnabled(), profile.isFlagged(), devices, streamsNow, + profile.getCreatedAt(), profile.getLastSeenAt()); + } + + @Transactional(readOnly = true) + public List newestFirst() { + List rows = profiles.newestFirst(); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + out.add(rows.get(iter).getUsername()); + } + return out; + } + + @Transactional(readOnly = true) + public String displayName(String username) { + Profile profile = username == null ? null : profiles.findById(username); + return profile == null ? "" : profile.getDisplayName(); + } + + /// Whether the account holds `role`, read from the user store now. + public boolean hasRole(String username, String role) { + if (!exists(username)) { + return false; + } + return roles(users.loadUserByUsername(username)).contains(role); + } + + /// Replaces an account's roles. VIEWER is always kept. An admin cannot take + /// the admin role from themselves, so the service is never left with none. + public UserDto setRoles(String by, String username, List wanted) throws IOException { + List roles = new ArrayList(); + roles.add(Roles.VIEWER); + String[] known = {Roles.CREATOR, Roles.MODERATOR, Roles.ADMIN}; + for (int iter = 0; iter < known.length; iter++) { + if (wanted != null && wanted.contains(known[iter])) { + roles.add(known[iter]); + } + } + if (by.equals(username) && !roles.contains(Roles.ADMIN)) { + throw new ResponseStatusException(409, "You cannot take the admin role from yourself"); + } + changeRoles(by, username, roles); + return summary(username, 0, 0); + } + + @Transactional(rollbackFor = IOException.class) + private void changeRoles(String by, String username, List roles) throws IOException { + actingAdmin(by, username); + UserDetails signIn = load(username); + users.updateUser(User.withUsername(username).password(signIn.getPassword()) + .disabled(!signIn.isEnabled()).roles(roles.toArray(new String[roles.size()])).build()); + moderation.record(username, by, Moderation.ROLES, String.join(", ", roles)); + } + + /// Blocks an account or lifts the block. A blocked account's tokens stop + /// working at its next request: the roles are read on every one. + public UserDto suspend(String by, String username, boolean suspended, String reason) + throws IOException { + if (by.equals(username)) { + throw new ResponseStatusException(409, "You cannot block yourself"); + } + changeSuspension(by, username, suspended, reason); + if (suspended) { + grants.endAll(username); + } + return summary(username, 0, 0); + } + + @Transactional(rollbackFor = IOException.class) + private void changeSuspension(String by, String username, boolean suspended, String reason) + throws IOException { + actingAdmin(by, username); + UserDetails signIn = load(username); + List roles = roles(signIn); + users.updateUser(User.withUsername(username).password(signIn.getPassword()).disabled(suspended) + .roles(roles.toArray(new String[roles.size()])).build()); + moderation.record(username, by, suspended ? Moderation.BLOCK : Moderation.UNBLOCK, reason); + } + + @Transactional(rollbackFor = IOException.class) + public UserDto flag(String by, String username, boolean flagged, String reason) throws IOException { + Profile profile = profiles.findById(username); + if (profile == null) { + throw new ResponseStatusException(404, "No such account"); + } + String why = reason == null ? "" : reason.trim(); + profile.setFlagged(flagged); + profile.setFlagReason(flagged ? (why.length() > 255 ? why.substring(0, 255) : why) : ""); + profiles.save(profile); + moderation.record(username, by, flagged ? Moderation.FLAG : Moderation.UNFLAG, why); + return summary(username, 0, 0); + } + + @Transactional(rollbackFor = IOException.class) + public AccountDto saveProfile(String username, ProfileDto wanted) throws IOException { + Profile profile = profiles.findById(username); + if (profile == null) { + throw new ResponseStatusException(404, "No such account"); + } + profile.setDisplayName(required(wanted == null ? null : wanted.name(), 120, "your name")); + profiles.save(profile); + return describe(username); + } + + /// Notes that the account was used, at most once a minute. + @Transactional + public void seen(String username) { + Profile profile = profiles.findById(username); + long now = System.currentTimeMillis(); + if (profile != null && now - profile.getLastSeenAt() > 60000L) { + profile.setLastSeenAt(now); + profiles.save(profile); + } + } + + /// Changes the password, and ends every other sign-in: a password is changed + /// as often as not because someone else may have the old one. + @Transactional(rollbackFor = IOException.class) + public AccountDto changePassword(String username, String current, String replacement) + throws IOException { + // Held first so two changes at once queue; the second then checks + // against the password the first one set. + profiles.hold(username); + UserDetails signIn = load(username); + if (current == null || !encoder.matches(current, signIn.getPassword())) { + throw new ResponseStatusException(403, "That is not your current password"); + } + password(replacement); + users.updatePassword(signIn, encoder.encode(replacement)); + grants.endOthers(username); + return describe(username); + } + + /// Closes an account for good: the sign-in, the profile, the household, + /// the devices, the saved cards and the viewing history are deleted. The + /// invoices stay, as the service's accounts, under a retired name. + /// `password` is asked for again: a token alone must not be enough for + /// something that cannot be undone. + @Transactional(rollbackFor = IOException.class) + public void close(String username, String password) throws IOException { + profiles.hold(username); + UserDetails signIn = load(username); + if (password == null || !encoder.matches(password, signIn.getPassword())) { + throw new ResponseStatusException(403, "That is not your current password"); + } + if (roles(signIn).contains(Roles.ADMIN)) { + throw new ResponseStatusException(409, "Hand the admin role on before closing"); + } + payments.forget(username); + moderation.forget(username); + cleanup.removeEverythingOf(username); + if (profiles.findById(username) != null) { + profiles.deleteById(username); + } + String hash = Ids.sha256(username); + if (!closed.existsById(hash)) { + ClosedAccount row = new ClosedAccount(); + row.setUsernameHash(hash); + row.setClosedAt(System.currentTimeMillis()); + closed.insert(row); + } + users.deleteUser(username); + grants.endAll(username); + } + + /// Holds both accounts, in name order so two admins cannot deadlock, and + /// checks `by` still is an admin: otherwise two admins could each strip the + /// other at the same moment and leave the service with none. + private void actingAdmin(String by, String username) { + String first = by.compareTo(username) <= 0 ? by : username; + String second = first.equals(by) ? username : by; + profiles.hold(first); + if (!second.equals(first)) { + profiles.hold(second); + } + if (!users.userExists(by)) { + throw new ResponseStatusException(403, "You are no longer an admin"); + } + UserDetails acting = users.loadUserByUsername(by); + if (!acting.isEnabled() || !roles(acting).contains(Roles.ADMIN)) { + throw new ResponseStatusException(403, "You are no longer an admin"); + } + } + + private UserDetails load(String username) { + if (username == null || !users.userExists(username)) { + throw new ResponseStatusException(404, "No such account"); + } + return users.loadUserByUsername(username); + } + + /// The roles, without their `ROLE_` prefix. + private static List roles(UserDetails signIn) { + List held = new ArrayList(); + Iterator it = signIn.getAuthorities().iterator(); + while (it.hasNext()) { + String name = it.next().getAuthority(); + held.add(name.startsWith("ROLE_") ? name.substring(5) : name); + } + return held; + } + + private static void password(String value) { + if (value == null || value.length() < 8) { + throw new ResponseStatusException(400, "Choose a password of at least 8 characters"); + } + if (value.length() > 200) { + throw new ResponseStatusException(400, "That password is too long"); + } + } + + /// An e-mail address as an account name: trimmed, and its ASCII letters + /// folded by hand, because `toLowerCase()` follows the server's locale and + /// a key must not. + public static String email(String value) { + String text = value == null ? "" : value.trim(); + int at = text.indexOf('@'); + if (at < 1 || at != text.lastIndexOf('@') || at > text.length() - 4 + || text.indexOf('.', at) < 0 || text.length() > 190) { + throw new ResponseStatusException(400, "That is not an e-mail address"); + } + char[] chars = text.toCharArray(); + for (int iter = 0; iter < chars.length; iter++) { + char c = chars[iter]; + if (c <= ' ' || c > '~') { + throw new ResponseStatusException(400, "That is not an e-mail address"); + } + if (c >= 'A' && c <= 'Z') { + chars[iter] = (char) (c + ('a' - 'A')); + } + } + return new String(chars); + } + + private static String required(String value, int max, String what) { + String text = value == null ? "" : value.trim(); + if (text.length() == 0) { + throw new ResponseStatusException(400, "Tell us " + what); + } + if (text.length() > max) { + throw new ResponseStatusException(400, "That is too long for " + what); + } + return text; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/DemoAccounts.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/DemoAccounts.java new file mode 100644 index 00000000000..52cf15f8c55 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/DemoAccounts.java @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.account; + +import com.codename1.backend.Config; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.annotations.Value; +import com.codenameone.examples.reel.Days; +import com.codenameone.examples.reel.Roles; +import com.codenameone.examples.reel.domain.Profile; +import com.codenameone.examples.reel.repository.ProfileRepository; + +import java.io.IOException; + +/// The accounts a development server starts with, one per role, all with the +/// password [#PASSWORD]. A server on any other profile makes only the first +/// admin, from `reel.admin.email` and `reel.admin.password`, and nobody else. +/// +/// [com.codenameone.examples.reel.DemoData] calls [#seed()] before it writes +/// the catalog, because the uploaded titles belong to [#CREATOR]. +@Component +public class DemoAccounts { + public static final String VIEWER = "viewer@reel.example"; + public static final String SECOND_VIEWER = "viewer2@reel.example"; + /// A viewer with an open sharing alert and a flag, for the admin console. + public static final String FLAGGED_VIEWER = "viewer3@reel.example"; + public static final String CREATOR = "creator@reel.example"; + public static final String MODERATOR = "moderator@reel.example"; + public static final String ADMIN = "admin@reel.example"; + public static final String PASSWORD = "reel-demo"; + + private final Accounts accounts; + private final Moderation moderation; + private final ProfileRepository profiles; + private final Config config; + private final String adminEmail; + private final String adminPassword; + + public DemoAccounts(Accounts accounts, Moderation moderation, ProfileRepository profiles, Config config, + @Value("${reel.admin.email:}") String adminEmail, + @Value("${reel.admin.password:}") String adminPassword) { + this.accounts = accounts; + this.moderation = moderation; + this.profiles = profiles; + this.config = config; + this.adminEmail = adminEmail; + this.adminPassword = adminPassword; + } + + /// Makes the accounts that do not exist yet. Returns whether this start + /// made the development set, so the caller seeds what goes with it once. + public boolean seed() throws IOException { + if (!config.isDevelopmentProfile()) { + if (adminEmail.length() > 0 && adminPassword.length() >= 8) { + add(Accounts.email(adminEmail), "Administrator", 0, Roles.VIEWER, Roles.ADMIN); + } + return false; + } + boolean first = !accounts.exists(ADMIN); + add(VIEWER, "Riley Viewer", 0, Roles.VIEWER); + add(SECOND_VIEWER, "Robin Viewer", 40, Roles.VIEWER); + add(CREATOR, "Casey Creator", 120, Roles.VIEWER, Roles.CREATOR); + add(MODERATOR, "Morgan Moderator", 200, Roles.VIEWER, Roles.MODERATOR); + add(ADMIN, "Alex Admin", 365, Roles.VIEWER, Roles.ADMIN); + boolean flag = !accounts.exists(FLAGGED_VIEWER); + add(FLAGGED_VIEWER, "Rae Viewer", 9, Roles.VIEWER); + if (flag) { + flag(FLAGGED_VIEWER, "Streams from five cities in one evening", 1); + } + return first; + } + + @Transactional(rollbackFor = IOException.class) + void flag(String username, String why, int daysAgo) throws IOException { + Profile profile = profiles.findById(username); + profile.setFlagged(true); + profile.setFlagReason(why); + profiles.save(profile); + moderation.record(username, ADMIN, Moderation.FLAG, why, + System.currentTimeMillis() - daysAgo * Days.MILLIS); + } + + private void add(String username, String name, int daysAgo, String... roles) throws IOException { + if (accounts.exists(username)) { + return; + } + String password = config.isDevelopmentProfile() ? PASSWORD : adminPassword; + try { + accounts.create(username, password, name, roles, System.currentTimeMillis() - daysAgo * Days.MILLIS); + } catch (IOException | RuntimeException failed) { + // Two servers started together against a new database both find + // no admin and both make one; the second insert fails on the name. + // create() is one transaction, so the loser left nothing behind. + if (!accounts.exists(username)) { + throw failed; + } + } + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Devices.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Devices.java new file mode 100644 index 00000000000..fe8982b5bd3 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Devices.java @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.account; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.reel.Requests; +import com.codenameone.examples.reel.api.DeviceDto; +import com.codenameone.examples.reel.domain.Device; +import com.codenameone.examples.reel.repository.DeviceRepository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// The devices an account has signed in on. +/// +/// A device is known by the id the app made for itself and sends with every +/// call. Its owner can sign one out from another: the row is marked revoked, +/// and a revoked device is refused playback and told to sign in again on its +/// next call to `/api/me`. Signing out every device at once is changing the +/// password. +@Component +public class Devices { + /// More than this and the oldest is forgotten, so a script making up ids + /// cannot grow the table without end. + private static final int MAX_DEVICES = 20; + + private final DeviceRepository devices; + + public Devices(DeviceRepository devices) { + this.devices = devices; + } + + /// Records that the calling device was used. Answers 401 for a device its + /// owner signed out. + @Transactional + public void touch(String username) { + String id = Requests.deviceId(); + if (id.length() == 0) { + return; + } + long now = System.currentTimeMillis(); + Device row = devices.findFirstByIdAndUsername(id, username); + if (row == null) { + Device elsewhere = devices.findById(id); + if (elsewhere != null) { + // One device, one account: the account that signed in on it last. + devices.deleteById(id); + } + makeRoom(username); + row = new Device(); + row.setId(id); + row.setUsername(username); + row.setCreatedAt(now); + fill(row, now); + devices.insert(row); + return; + } + if (row.getRevokedAt() > 0L) { + throw new ResponseStatusException(401, "This device was signed out"); + } + if (now - row.getLastSeenAt() > 60000L || !Requests.ip().equals(row.getLastIp())) { + fill(row, now); + devices.save(row); + } + } + + /// Whether a device may play: known, not signed out. + @Transactional(readOnly = true) + public boolean usable(String username, String id) { + if (id == null || id.length() == 0) { + return true; + } + Device row = devices.findFirstByIdAndUsername(id, username); + return row == null || row.getRevokedAt() == 0L; + } + + @Transactional(readOnly = true) + public List list(String username) { + List rows = devices.findByUsernameOrderByLastSeenAtDesc(username); + String current = Requests.deviceId(); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + Device d = rows.get(iter); + if (d.getRevokedAt() > 0L) { + continue; + } + out.add(new DeviceDto(d.getId(), d.getName(), d.getPlatform(), d.getLastIp(), + d.getLastCountry(), d.getLastSeenAt(), d.getId().equals(current))); + } + return out; + } + + @Transactional(readOnly = true) + public int count(String username) { + return (int) devices.countByUsernameAndRevokedAt(username, 0L); + } + + @Transactional(rollbackFor = IOException.class) + /// Revokes every device the account is signed in on. + public void signOutAll(String username) throws IOException { + List rows = devices.findByUsernameOrderByLastSeenAtDesc(username); + long now = System.currentTimeMillis(); + for (int iter = 0; iter < rows.size(); iter++) { + Device row = rows.get(iter); + if (row.getRevokedAt() == 0L) { + row.setRevokedAt(now); + devices.save(row); + } + } + } + + public void signOut(String username, String id) throws IOException { + Device row = id == null ? null : devices.findFirstByIdAndUsername(id, username); + if (row == null) { + throw new ResponseStatusException(404, "No such device"); + } + row.setRevokedAt(System.currentTimeMillis()); + devices.save(row); + } + + private void makeRoom(String username) { + List rows = devices.findByUsernameOrderByLastSeenAtDesc(username); + for (int iter = MAX_DEVICES - 1; iter < rows.size(); iter++) { + devices.deleteById(rows.get(iter).getId()); + } + } + + private static void fill(Device row, long now) { + row.setName(Requests.deviceName()); + row.setPlatform(Requests.platform()); + row.setLastIp(Requests.ip()); + row.setLastCountry(Requests.country()); + row.setLastSeenAt(now); + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Grants.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Grants.java new file mode 100644 index 00000000000..a96bd98ab67 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Grants.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.account; + +import com.codename1.backend.DataSource; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.security.Authentication; +import com.codename1.backend.security.SecurityContextHolder; +import com.codename1.backend.security.oauth2.server.authorization.OAuth2AuthorizationService; +import com.codename1.backend.security.oauth2.server.resource.JwtAuthenticationToken; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/// The sign-ins an account has open, and ending them. +/// +/// Signing in leaves a grant in the authorization server's own table, and a +/// refresh token is good for as long as its grant is there: refreshing asks +/// for no password. So a password changed because a phone was lost changes +/// nothing for whoever holds that phone, unless the grants go too. +/// +/// An access token already issued is not recalled by this. It is a signed +/// token that is checked without the table, and it runs out on its own within +/// its few minutes; what ends here is the means of getting another. +@Component +public class Grants { + private final DataSource db; + private final OAuth2AuthorizationService authorizations; + + public Grants(DataSource db, OAuth2AuthorizationService authorizations) { + this.db = db; + this.authorizations = authorizations; + } + + /// Ends every sign-in of an account but the one this request was made + /// with, so the device a password was changed on stays signed in and every + /// other has to give the new password. + public void endOthers(String username) throws IOException { + end(username, current()); + } + + /// Ends every sign-in of an account. + public void endAll(String username) throws IOException { + end(username, ""); + } + + private void end(String username, String kept) throws IOException { + // The table is the authorization server's, which offers a grant by its + // id and no list of an account's: this reads the ids and leaves the + // removing, which takes the grant's tokens with it, to the service. + List rows = db.query("SELECT id FROM cn1_oauth2_authorization WHERE principal_name = ?", + new Object[] {username}); + for (int iter = 0; rows != null && iter < rows.size(); iter++) { + Object row = rows.get(iter); + Object id = row instanceof Map ? ((Map) row).get("id") : null; + if (id != null && !kept.equals(String.valueOf(id))) { + authorizations.remove(String.valueOf(id)); + } + } + } + + /// The id of the grant the request's token was issued under, or empty for + /// a request that carried no such token. The server writes the grant's id + /// before the first dot of the token's own. + private static String current() { + Authentication who = SecurityContextHolder.getContext().getAuthentication(); + if (!(who instanceof JwtAuthenticationToken)) { + return ""; + } + String id = ((JwtAuthenticationToken) who).getToken().getId(); + int dot = id == null ? -1 : id.indexOf('.'); + return dot > 0 ? id.substring(0, dot) : ""; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Households.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Households.java new file mode 100644 index 00000000000..653a40e1f5c --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Households.java @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.account; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.security.crypto.PasswordEncoder; +import com.codenameone.examples.reel.Ids; +import com.codenameone.examples.reel.api.Features; +import com.codenameone.examples.reel.api.HouseholdProfileDto; +import com.codenameone.examples.reel.api.ProfileEditDto; +import com.codenameone.examples.reel.domain.Household; +import com.codenameone.examples.reel.repository.HouseholdRepository; +import com.codenameone.examples.reel.settings.Settings; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// The viewers inside an account: each with its own list, history, +/// recommendations and rating limit, a kids profile among them. +/// +/// Every account has at least one, made the first time it is asked for. With +/// the profiles switch off the account has that one and no more. A PIN is +/// stored as a password hash and checked by [#unlock]; the PIN gates choosing +/// the profile in the app, which is a household lock, not a security boundary +/// against the account's own owner. +@Component +public class Households { + public static final int MAX_PROFILES = 5; + /// Ratings from mildest to strongest; a profile sees up to its limit. + public static final String[] RATINGS = {"G", "PG", "PG-13", "R", "NC-17"}; + + private final HouseholdRepository rows; + private final PasswordEncoder encoder; + private final Settings settings; + + public Households(HouseholdRepository rows, PasswordEncoder encoder, Settings settings) { + this.rows = rows; + this.encoder = encoder; + this.settings = settings; + } + + @Transactional(rollbackFor = IOException.class) + public List list(String username, String ownerName) throws IOException { + List all = ensure(username, ownerName); + List out = new ArrayList(); + for (int iter = 0; iter < all.size(); iter++) { + out.add(dto(all.get(iter))); + } + return out; + } + + /// The profile `id` of the account, or its first when `id` is empty. Answers + /// 404 for a profile that is not the account's. + @Transactional(rollbackFor = IOException.class) + public Household resolve(String username, String id) throws IOException { + List all = ensure(username, ""); + if (id == null || id.length() == 0) { + return all.get(0); + } + for (int iter = 0; iter < all.size(); iter++) { + if (all.get(iter).getId().equals(id)) { + return all.get(iter); + } + } + throw new ResponseStatusException(404, "No such profile"); + } + + @Transactional(rollbackFor = IOException.class) + public HouseholdProfileDto create(String username, ProfileEditDto wanted) throws IOException { + settings.require(Features.PROFILES); + List all = ensure(username, ""); + if (all.size() >= MAX_PROFILES) { + throw new ResponseStatusException(409, "An account has at most " + MAX_PROFILES + " profiles"); + } + Household row = new Household(); + row.setId(Ids.next()); + row.setUsername(username); + row.setCreatedAt(System.currentTimeMillis()); + apply(row, wanted); + rows.insert(row); + return dto(row); + } + + @Transactional(rollbackFor = IOException.class) + public HouseholdProfileDto update(String username, String id, ProfileEditDto wanted) throws IOException { + Household row = resolve(username, id); + apply(row, wanted); + rows.save(row); + return dto(row); + } + + @Transactional(rollbackFor = IOException.class) + public void delete(String username, String id) throws IOException { + List all = ensure(username, ""); + Household row = resolve(username, id); + if (all.size() == 1) { + throw new ResponseStatusException(409, "An account keeps at least one profile"); + } + rows.deleteById(row.getId()); + } + + /// Checks a profile's PIN. A profile without one unlocks with anything. + @Transactional(rollbackFor = IOException.class) + public HouseholdProfileDto unlock(String username, String id, String pin) throws IOException { + Household row = resolve(username, id); + if (row.getPinHash().length() > 0 && (pin == null || !encoder.matches(pin, row.getPinHash()))) { + throw new ResponseStatusException(403, "That PIN is not right"); + } + return dto(row); + } + + /// Whether a profile may see a title of this rating. + /// A profile by id, whoever owns it; for the admin views. + @Transactional(readOnly = true) + public Household find(String id) { + return rows.findById(id); + } + + public static boolean allows(Household profile, String rating) { + return rank(rating) <= rank(profile.getMaxRating()); + } + + static int rank(String rating) { + for (int iter = 0; iter < RATINGS.length; iter++) { + if (RATINGS[iter].equals(rating)) { + return iter; + } + } + // Unrated counts as the strongest: a kids profile does not see it. + return RATINGS.length - 1; + } + + private List ensure(String username, String ownerName) throws IOException { + List all = rows.findByUsernameOrderByCreatedAtAsc(username); + if (!all.isEmpty()) { + return all; + } + Household first = new Household(); + first.setId(Ids.next()); + first.setUsername(username); + first.setName(ownerName == null || ownerName.trim().length() == 0 ? "Me" : first(ownerName)); + first.setAvatar("red"); + first.setMaxRating("NC-17"); + first.setLanguage("en"); + first.setCreatedAt(System.currentTimeMillis()); + rows.insert(first); + all = new ArrayList(); + all.add(first); + return all; + } + + private void apply(Household row, ProfileEditDto wanted) { + if (wanted == null) { + throw new ResponseStatusException(400, "Nothing to save"); + } + String name = wanted.name() == null ? "" : wanted.name().trim(); + if (name.length() == 0 || name.length() > 60) { + throw new ResponseStatusException(400, "A profile name is 1 to 60 characters"); + } + row.setName(name); + row.setAvatar(safe(wanted.avatar(), 32, "red")); + row.setKids(wanted.kids()); + String rating = wanted.kids() ? "PG" : safe(wanted.maxRating(), 8, "NC-17"); + if (rank(rating) == RATINGS.length - 1 && !"NC-17".equals(rating)) { + rating = "NC-17"; + } + row.setMaxRating(rating); + row.setLanguage(safe(wanted.language(), 8, "en")); + if (wanted.clearPin()) { + row.setPinHash(""); + } else if (wanted.pin() != null && wanted.pin().length() > 0) { + String pin = wanted.pin(); + if (pin.length() != 4 || !digits(pin)) { + throw new ResponseStatusException(400, "A PIN is four digits"); + } + row.setPinHash(encoder.encode(pin)); + } + } + + private static boolean digits(String text) { + for (int iter = 0; iter < text.length(); iter++) { + if (text.charAt(iter) < '0' || text.charAt(iter) > '9') { + return false; + } + } + return true; + } + + private static String safe(String value, int max, String fallback) { + String text = value == null ? "" : value.trim(); + if (text.length() == 0) { + return fallback; + } + return text.length() > max ? text.substring(0, max) : text; + } + + private static String first(String name) { + String text = name.trim(); + int space = text.indexOf(' '); + String out = space > 0 ? text.substring(0, space) : text; + return out.length() > 60 ? out.substring(0, 60) : out; + } + + public static HouseholdProfileDto dto(Household row) { + return new HouseholdProfileDto(row.getId(), row.getName(), row.getAvatar(), row.isKids(), + row.getMaxRating(), row.getPinHash().length() > 0, row.getLanguage()); + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Moderation.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Moderation.java new file mode 100644 index 00000000000..90b7869966f --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Moderation.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.account; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.reel.Ids; +import com.codenameone.examples.reel.api.ModerationEventDto; +import com.codenameone.examples.reel.domain.ModerationEvent; +import com.codenameone.examples.reel.repository.ModerationEventRepository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// The record of what the admins did to an account, and why. +/// +/// Flagging, blocking, approving and rejecting all change an account's state, +/// and the state alone does not say who decided it or on what grounds. This +/// does. A row is written with the change and never altered afterwards. +@Component +public class Moderation { + public static final String FLAG = "flag"; + public static final String UNFLAG = "unflag"; + public static final String BLOCK = "block"; + public static final String UNBLOCK = "unblock"; + public static final String APPROVE = "approve"; + public static final String REJECT = "reject"; + public static final String ROLES = "roles"; + /// Account suspension; the column holds 16 characters. + public static final String SUSPEND = "suspend"; + public static final String UNSUSPEND = "unsuspend"; + /// A title of the account's taken down or restored. + public static final String TAKEDOWN = "takedown"; + public static final String RESTORE = "restore"; + + private final ModerationEventRepository events; + + public Moderation(ModerationEventRepository events) { + this.events = events; + } + + @Transactional + public void record(String username, String by, String action, String reason) + throws IOException { + // Two changes made in the same millisecond -- a block and the flag that + // came with it -- would otherwise be listed in either order. The later + // one is stamped after the one before it, so the list is the order in + // which things were done. + Long newest = events.newest(username); + long after = newest == null ? 0L : newest.longValue() + 1L; + record(username, by, action, reason, Math.max(System.currentTimeMillis(), after)); + } + + /// As [#record(String, String, String, String)], at a time of the caller's + /// choosing; the demo data is the one caller that has a past to write. + @Transactional + public void record(String username, String by, String action, String reason, long at) + throws IOException { + String why = reason == null ? "" : reason.trim(); + ModerationEvent event = new ModerationEvent(); + event.setId(Ids.next()); + event.setUsername(username); + event.setCreatedAt(at); + event.setActor(by); + event.setAction(action); + event.setReason(why.length() > 255 ? why.substring(0, 255) : why); + events.insert(event); + } + + /// An account's events, newest first. + @Transactional(readOnly = true) + public List events(String username) throws IOException { + List rows = events.findTop100ByUsernameOrderByCreatedAtDescIdAsc(username); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + ModerationEvent row = rows.get(iter); + out.add(new ModerationEventDto(row.getCreatedAt(), row.getActor(), row.getAction(), + row.getReason())); + } + return out; + } + + @Transactional + public void forget(String username) throws IOException { + events.deleteByUsername(username); + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/admin/AdminEndpoint.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/admin/AdminEndpoint.java new file mode 100644 index 00000000000..4d9bb31980d --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/admin/AdminEndpoint.java @@ -0,0 +1,242 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.admin; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.reel.Caller; +import com.codenameone.examples.reel.account.Accounts; +import com.codenameone.examples.reel.account.Devices; +import com.codenameone.examples.reel.account.Moderation; +import com.codenameone.examples.reel.ads.Ads; +import com.codenameone.examples.reel.api.AdCampaignDto; +import com.codenameone.examples.reel.api.AdminApiServer; +import com.codenameone.examples.reel.api.AdminStatsDto; +import com.codenameone.examples.reel.api.AppConfigDto; +import com.codenameone.examples.reel.api.BrandingDto; +import com.codenameone.examples.reel.api.CouponDto; +import com.codenameone.examples.reel.api.FeatureFlagDto; +import com.codenameone.examples.reel.api.FlagDto; +import com.codenameone.examples.reel.api.JobDto; +import com.codenameone.examples.reel.api.PlanDto; +import com.codenameone.examples.reel.api.PresetDto; +import com.codenameone.examples.reel.api.RoleChangeDto; +import com.codenameone.examples.reel.api.SessionDto; +import com.codenameone.examples.reel.api.SharingAlertDto; +import com.codenameone.examples.reel.api.SharingDecisionDto; +import com.codenameone.examples.reel.api.StatsSeriesDto; +import com.codenameone.examples.reel.api.SuspendDto; +import com.codenameone.examples.reel.api.UserDetailDto; +import com.codenameone.examples.reel.api.UserDto; +import com.codenameone.examples.reel.billing.Billing; +import com.codenameone.examples.reel.catalog.Catalog; +import com.codenameone.examples.reel.pay.Payments; +import com.codenameone.examples.reel.playback.Playbacks; +import com.codenameone.examples.reel.settings.Settings; +import com.codenameone.examples.reel.studio.Jobs; + +import java.util.ArrayList; +import java.util.List; + +/// The admin console. Everything under `/api/admin/` is limited to admins in +/// [com.codenameone.examples.reel.SecurityConfig]; `/api/config` is the one +/// public call, what every app reads at start to theme itself and to know +/// which features to show. +@Component +public class AdminEndpoint implements AdminApiServer { + private final Accounts accounts; + private final Devices devices; + private final Playbacks playbacks; + private final Settings settings; + private final Billing billing; + private final Ads ads; + private final Jobs jobs; + private final Stats stats; + private final Sharing sharing; + private final Moderation history; + private final Payments payments; + + public AdminEndpoint(Accounts accounts, Devices devices, Playbacks playbacks, Settings settings, + Billing billing, Ads ads, Jobs jobs, Stats stats, Sharing sharing, Moderation history, + Payments payments) { + this.accounts = accounts; + this.devices = devices; + this.playbacks = playbacks; + this.settings = settings; + this.billing = billing; + this.ads = ads; + this.jobs = jobs; + this.stats = stats; + this.sharing = sharing; + this.history = history; + this.payments = payments; + } + + @Override + public AppConfigDto config() throws Exception { + return new AppConfigDto(settings.branding(), settings.preset(), settings.flags(), + payments.config(settings.currency())); + } + + @Override + public AdminStatsDto stats() throws Exception { + return stats.now(); + } + + @Override + public StatsSeriesDto statsSeries(int days) throws Exception { + return stats.series(days == 0 ? 30 : days); + } + + /// Accounts newest first, filtered by a fragment of the address or name. + @Override + public List users(String q) throws Exception { + String wanted = q == null ? "" : Catalog.fold(q.trim()); + List all = accounts.newestFirst(); + List out = new ArrayList(); + for (int iter = 0; iter < all.size() && out.size() < 100; iter++) { + String username = all.get(iter); + if (wanted.length() > 0 && Catalog.fold(username).indexOf(wanted) < 0 + && Catalog.fold(accounts.displayName(username)).indexOf(wanted) < 0) { + continue; + } + out.add(summary(username)); + } + return out; + } + + @Override + public UserDetailDto user(String username) throws Exception { + UserDto user = summary(username); + return new UserDetailDto(user, devices.list(username), playbacks.liveFor(username), + billing.invoices(username), history.events(username), sharing.forUser(username)); + } + + @Override + public UserDto setRoles(String username, RoleChangeDto roles) throws Exception { + accounts.setRoles(Caller.name(), username, roles == null ? null : roles.roles()); + return summary(username); + } + + /// Suspending also ends whatever the account is streaming now. + @Override + public UserDto suspend(String username, SuspendDto suspend) throws Exception { + boolean on = suspend != null && suspend.suspended(); + accounts.suspend(Caller.name(), username, on, suspend == null ? "" : suspend.reason()); + if (on) { + playbacks.endAll(username, "SUSPENDED"); + } + return summary(username); + } + + @Override + public UserDto flag(String username, FlagDto flag) throws Exception { + accounts.flag(Caller.name(), username, flag != null && flag.flagged(), flag == null ? "" : flag.reason()); + return summary(username); + } + + @Override + public List sessions() throws Exception { + return playbacks.allLive(); + } + + @Override + public String endSession(String id) throws Exception { + playbacks.endSession(id); + return "ok"; + } + + @Override + public List flags() throws Exception { + return settings.flags(); + } + + @Override + public FeatureFlagDto setFlag(String key, FeatureFlagDto flag) throws Exception { + return settings.setFlag(Caller.name(), key, flag != null && flag.enabled()); + } + + @Override + public List presets() throws Exception { + return settings.presets(); + } + + @Override + public List applyPreset(String id) throws Exception { + return settings.applyPreset(Caller.name(), id); + } + + @Override + public BrandingDto setBranding(BrandingDto branding) throws Exception { + return settings.setBranding(Caller.name(), branding); + } + + @Override + public List plans() throws Exception { + return billing.plans(true); + } + + @Override + public PlanDto savePlan(String code, PlanDto plan) throws Exception { + return billing.savePlan(code, plan); + } + + @Override + public List coupons() throws Exception { + return billing.coupons(); + } + + @Override + public CouponDto saveCoupon(String code, CouponDto coupon) throws Exception { + return billing.saveCoupon(code, coupon); + } + + @Override + public List campaigns() throws Exception { + return ads.list(); + } + + @Override + public AdCampaignDto saveCampaign(AdCampaignDto campaign) throws Exception { + return ads.save(campaign); + } + + @Override + public List sharingAlerts(String status) throws Exception { + return sharing.list(status); + } + + @Override + public SharingAlertDto decideSharing(String id, SharingDecisionDto decision) throws Exception { + return sharing.decide(Caller.name(), id, decision); + } + + @Override + public List jobs(String status) throws Exception { + return jobs.list(status); + } + + @Override + public JobDto retryJob(String id) throws Exception { + return jobs.retry(id); + } + + private UserDto summary(String username) throws Exception { + return accounts.summary(username, devices.count(username), playbacks.liveFor(username).size()); + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/admin/Sharing.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/admin/Sharing.java new file mode 100644 index 00000000000..9e82f11a92f --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/admin/Sharing.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.admin; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.reel.Kinds; +import com.codenameone.examples.reel.account.Devices; +import com.codenameone.examples.reel.account.Grants; +import com.codenameone.examples.reel.api.SharingAlertDto; +import com.codenameone.examples.reel.api.SharingDecisionDto; +import com.codenameone.examples.reel.domain.SharingAlert; +import com.codenameone.examples.reel.playback.Playbacks; +import com.codenameone.examples.reel.repository.SharingAlertRepository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// Password-sharing alerts ([Playbacks] raises them) and what an admin does +/// about one: dismiss it (a family on holiday), or sign the account out +/// everywhere, which ends its streams and its sign-ins so the owner has to +/// sign in again -- and whoever they shared the password with does too. +@Component +public class Sharing { + public static final String DISMISS = "DISMISS"; + public static final String SIGN_OUT_ALL = "SIGN_OUT_ALL"; + + private final SharingAlertRepository alerts; + private final Playbacks playbacks; + private final Grants grants; + private final Devices devices; + + public Sharing(SharingAlertRepository alerts, Playbacks playbacks, Grants grants, Devices devices) { + this.alerts = alerts; + this.playbacks = playbacks; + this.grants = grants; + this.devices = devices; + } + + @Transactional(readOnly = true) + public List list(String status) { + List rows = status == null || status.length() == 0 + ? alerts.findByCreatedAtGreaterThanOrderByCreatedAtDesc(0L) + : alerts.findByStatusOrderByCreatedAtDesc(status); + return dtos(rows); + } + + @Transactional(readOnly = true) + public List forUser(String username) { + return dtos(alerts.findByUsernameOrderByCreatedAtDesc(username)); + } + + @Transactional(rollbackFor = IOException.class) + public SharingAlertDto decide(String by, String id, SharingDecisionDto decision) throws IOException { + SharingAlert a = alerts.findById(id); + if (a == null) { + throw new ResponseStatusException(404, "No such alert"); + } + if (!Kinds.OPEN.equals(a.getStatus())) { + throw new ResponseStatusException(409, "Already decided"); + } + String action = decision == null ? "" : decision.action(); + if (SIGN_OUT_ALL.equals(action)) { + playbacks.endAll(a.getUsername(), "SIGNED_OUT"); + grants.endAll(a.getUsername()); + devices.signOutAll(a.getUsername()); + a.setStatus(Kinds.UPHELD); + } else if (DISMISS.equals(action)) { + a.setStatus(Kinds.DISMISSED); + } else { + throw new ResponseStatusException(400, "Dismiss, or sign the account out everywhere"); + } + a.setDecidedBy(by); + String note = decision.note() == null ? "" : decision.note().trim(); + a.setNote(note.length() > 255 ? note.substring(0, 255) : note); + alerts.save(a); + return dto(a); + } + + private static List dtos(List rows) { + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + out.add(dto(rows.get(iter))); + } + return out; + } + + static SharingAlertDto dto(SharingAlert a) { + return new SharingAlertDto(a.getId(), a.getUsername(), a.getKind(), a.getDetail(), a.getDistinctIps(), + a.getDistinctCountries(), a.getStatus(), a.getCreatedAt()); + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/admin/Stats.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/admin/Stats.java new file mode 100644 index 00000000000..4fcf5bc6407 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/admin/Stats.java @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.admin; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.reel.Kinds; +import com.codenameone.examples.reel.Rows; +import com.codenameone.examples.reel.ads.Ads; +import com.codenameone.examples.reel.api.AdminStatsDto; +import com.codenameone.examples.reel.api.NameCountDto; +import com.codenameone.examples.reel.api.SeriesPointDto; +import com.codenameone.examples.reel.api.StatsSeriesDto; +import com.codenameone.examples.reel.billing.Billing; +import com.codenameone.examples.reel.billing.Entitlements; +import com.codenameone.examples.reel.domain.AdCampaign; +import com.codenameone.examples.reel.domain.AdEvent; +import com.codenameone.examples.reel.domain.Invoice; +import com.codenameone.examples.reel.domain.Plan; +import com.codenameone.examples.reel.domain.Playback; +import com.codenameone.examples.reel.domain.Profile; +import com.codenameone.examples.reel.domain.Subscription; +import com.codenameone.examples.reel.domain.Title; +import com.codenameone.examples.reel.moderation.Reviews; +import com.codenameone.examples.reel.repository.AdCampaignRepository; +import com.codenameone.examples.reel.repository.AdEventRepository; +import com.codenameone.examples.reel.repository.InvoiceRepository; +import com.codenameone.examples.reel.repository.PlanRepository; +import com.codenameone.examples.reel.repository.PlaybackRepository; +import com.codenameone.examples.reel.repository.ProfileRepository; +import com.codenameone.examples.reel.repository.SharingAlertRepository; +import com.codenameone.examples.reel.repository.SubscriptionRepository; +import com.codenameone.examples.reel.repository.TitleRepository; +import com.codenameone.examples.reel.settings.Settings; +import com.codenameone.examples.reel.studio.Jobs; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TimeZone; + +/// The numbers on the admin dashboard, computed from the tables on request. +/// Days are UTC days. +@Component +public class Stats { + private static final long DAY = 86400000L; + + private final ProfileRepository profiles; + private final SubscriptionRepository subscriptions; + private final PlanRepository plans; + private final PlaybackRepository playbacks; + private final TitleRepository titles; + private final InvoiceRepository invoices; + private final AdEventRepository adEvents; + private final AdCampaignRepository campaigns; + private final SharingAlertRepository alerts; + private final Reviews reviews; + private final Jobs jobs; + private final Settings settings; + + public Stats(ProfileRepository profiles, SubscriptionRepository subscriptions, PlanRepository plans, + PlaybackRepository playbacks, TitleRepository titles, InvoiceRepository invoices, + AdEventRepository adEvents, AdCampaignRepository campaigns, SharingAlertRepository alerts, + Reviews reviews, Jobs jobs, Settings settings) { + this.profiles = profiles; + this.subscriptions = subscriptions; + this.plans = plans; + this.playbacks = playbacks; + this.titles = titles; + this.invoices = invoices; + this.adEvents = adEvents; + this.campaigns = campaigns; + this.alerts = alerts; + this.reviews = reviews; + this.jobs = jobs; + this.settings = settings; + } + + @Transactional(readOnly = true) + public AdminStatsDto now() { + long now = System.currentTimeMillis(); + long today = now - now % DAY; + List active = subscriptions.findByStatus(Entitlements.ACTIVE); + long mrr = 0; + for (int iter = 0; iter < active.size(); iter++) { + Plan p = plans.findById(active.get(iter).getPlanCode()); + if (p != null) { + mrr += Billing.YEAR.equals(p.getBillingInterval()) ? p.getPriceCents() / 12 : p.getPriceCents(); + } + } + List sinceMidnight = playbacks.findByStartedAtGreaterThan(today); + long seconds = 0; + int streams = 0; + for (int iter = 0; iter < sinceMidnight.size(); iter++) { + Playback p = sinceMidnight.get(iter); + seconds += Math.max(0L, ((p.getEndedAt() > 0 ? p.getEndedAt() : p.getLastHeartbeatAt()) + - p.getStartedAt()) / 1000L); + } + streams = (int) playbacks.countByEndedAt(0L); + long impressions = Rows.number(adEvents.countSince(Ads.IMPRESSION, today)); + return new AdminStatsDto((int) profiles.count(), active.size(), + (int) subscriptions.countByStatus(Entitlements.TRIALING), mrr, settings.currency(), streams, + seconds / 60L, (int) titles.countByStatus(Kinds.PUBLISHED), reviews.pending(), + jobs.count(Kinds.QUEUED), jobs.count(Kinds.FAILED), alerts.findByStatusOrderByCreatedAtDesc(Kinds.OPEN).size(), + impressions, adRevenueSince(today)); + } + + @Transactional(readOnly = true) + public StatsSeriesDto series(int days) { + int n = Math.max(1, Math.min(90, days)); + long now = System.currentTimeMillis(); + long first = now - now % DAY - (n - 1) * DAY; + Map signups = buckets(first, n); + Map minutes = buckets(first, n); + Map revenue = buckets(first, n); + Map churn = buckets(first, n); + List people = profiles.findAll(); + for (int iter = 0; iter < people.size(); iter++) { + add(signups, people.get(iter).getCreatedAt(), 1); + } + Map plays = new HashMap(); + List sessions = playbacks.findByStartedAtGreaterThan(first); + for (int iter = 0; iter < sessions.size(); iter++) { + Playback p = sessions.get(iter); + long end = p.getEndedAt() > 0 ? p.getEndedAt() : p.getLastHeartbeatAt(); + add(minutes, p.getStartedAt(), Math.max(0L, (end - p.getStartedAt()) / 60000L)); + Integer c = plays.get(p.getTitleId()); + plays.put(p.getTitleId(), Integer.valueOf(c == null ? 1 : c.intValue() + 1)); + } + List paid = invoices.findByCreatedAtGreaterThan(first); + for (int iter = 0; iter < paid.size(); iter++) { + if (Billing.PAID.equals(paid.get(iter).getStatus())) { + add(revenue, paid.get(iter).getCreatedAt(), paid.get(iter).getAmountCents()); + } + } + List ended = subscriptions.findByStatusAndUpdatedAtGreaterThan(Entitlements.CANCELED, first); + for (int iter = 0; iter < ended.size(); iter++) { + add(churn, ended.get(iter).getUpdatedAt(), 1); + } + List top = new ArrayList(); + for (Map.Entry e : plays.entrySet()) { + Title t = titles.findById(e.getKey()); + top.add(new NameCountDto(t == null ? e.getKey() : t.getName(), e.getValue().intValue())); + } + for (int i = 1; i < top.size(); i++) { + NameCountDto d = top.get(i); + int j = i - 1; + while (j >= 0 && top.get(j).count() < d.count()) { + top.set(j + 1, top.get(j)); + j--; + } + top.set(j + 1, d); + } + return new StatsSeriesDto(points(signups), points(minutes), points(revenue), points(churn), + top.size() > 10 ? new ArrayList(top.subList(0, 10)) : top); + } + + /// What the day's counted impressions earned: each campaign's CPM over a + /// thousand, summed. + private long adRevenueSince(long since) { + Map cpm = new HashMap(); + List all = campaigns.findAll(); + for (int iter = 0; iter < all.size(); iter++) { + cpm.put(all.get(iter).getId(), Long.valueOf(all.get(iter).getCpmCents())); + } + long millicents = 0; + List events = adEvents.findByEventAndCreatedAtGreaterThan(Ads.IMPRESSION, since); + for (int iter = 0; iter < events.size(); iter++) { + Long c = cpm.get(events.get(iter).getCampaignId()); + millicents += c == null ? 0 : c.longValue(); + } + return millicents / 1000L; + } + + private static Map buckets(long first, int n) { + Map out = new LinkedHashMap(); + for (int iter = 0; iter < n; iter++) { + out.put(day(first + iter * DAY), Long.valueOf(0)); + } + return out; + } + + private static void add(Map buckets, long at, long by) { + String key = day(at); + Long v = buckets.get(key); + if (v != null) { + buckets.put(key, Long.valueOf(v.longValue() + by)); + } + } + + private static List points(Map buckets) { + List out = new ArrayList(); + for (Map.Entry e : buckets.entrySet()) { + out.add(new SeriesPointDto(e.getKey(), e.getValue().longValue())); + } + return out; + } + + static String day(long at) { + SimpleDateFormat f = new SimpleDateFormat("yyyy-MM-dd"); + f.setTimeZone(TimeZone.getTimeZone("UTC")); + return f.format(new Date(at)); + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/ads/Ads.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/ads/Ads.java new file mode 100644 index 00000000000..b16389e9d12 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/ads/Ads.java @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.ads; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.reel.Ids; +import com.codenameone.examples.reel.Rows; +import com.codenameone.examples.reel.api.AdBreakDto; +import com.codenameone.examples.reel.api.AdCampaignDto; +import com.codenameone.examples.reel.api.AdDto; +import com.codenameone.examples.reel.api.AdEventDto; +import com.codenameone.examples.reel.domain.AdCampaign; +import com.codenameone.examples.reel.domain.AdEvent; +import com.codenameone.examples.reel.repository.AdCampaignRepository; +import com.codenameone.examples.reel.repository.AdEventRepository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// Ads: where the breaks go in a stream, which campaign fills each, and what +/// the player reports back. +/// +/// A campaign pays a CPM, so every counted impression adds a thousandth of it +/// to what the campaign has spent (kept in millicents, so one impression of a +/// $4 CPM is a whole number). A campaign stops being chosen when its budget is +/// spent, before its start or after its end, and prefers titles in its genres. +/// +/// The app plays the ads from the JSON this class answers and reports +/// impressions, clicks, skips and completions back through `/api/ads/event`, +/// signed in, so an event is tied to the session that showed the ad. +@Component +public class Ads { + public static final String IMPRESSION = "impression"; + public static final String CLICK = "click"; + public static final String SKIP = "skip"; + public static final String COMPLETE = "complete"; + /// A mid-roll every this many seconds of a long title. + public static final int MIDROLL_EVERY = 600; + /// Prefix of a creative URL that names a packaged Reel video instead of an + /// external file: `video:ad-coffee`. + public static final String VIDEO = "video:"; + + private final AdCampaignRepository campaigns; + private final AdEventRepository events; + + public Ads(AdCampaignRepository campaigns, AdEventRepository events) { + this.campaigns = campaigns; + this.events = events; + } + + /// The breaks of a stream: a pre-roll, and a mid-roll every ten minutes of + /// a title long enough to have one, never in its last minute. + public List breaks(String sessionId, int durationSeconds, int resumeSeconds) { + List out = new ArrayList(); + out.add(new AdBreakDto(sessionId + "~0", 0, null, 5)); + int index = 1; + for (int at = MIDROLL_EVERY; at < durationSeconds - 60; at += MIDROLL_EVERY) { + if (at > resumeSeconds) { + out.add(new AdBreakDto(sessionId + "~" + index, at, null, 5)); + } + index++; + } + return out; + } + + /// The campaign that fills a break: the eligible one furthest from its + /// budget, preferring those that target one of the title's genres. One + /// creative per break keeps breaks short; null when nothing is eligible. + @Transactional(readOnly = true) + public AdCampaign choose(List genres, String breakId) { + long now = System.currentTimeMillis(); + List all = campaigns.findByActiveTrue(); + AdCampaign best = null; + double bestScore = -1; + for (int iter = 0; iter < all.size(); iter++) { + AdCampaign c = all.get(iter); + if ((c.getStartsAt() > 0 && c.getStartsAt() > now) || (c.getEndsAt() > 0 && c.getEndsAt() < now)) { + continue; + } + if (c.getBudgetCents() > 0 && c.getSpentMillicents() >= c.getBudgetCents() * 1000L) { + continue; + } + double left = c.getBudgetCents() <= 0 ? 1.0 + : 1.0 - (double) c.getSpentMillicents() / (c.getBudgetCents() * 1000.0); + List targets = Rows.split(c.getGenres()); + boolean matches = targets.isEmpty(); + for (int g = 0; !matches && genres != null && g < genres.size(); g++) { + matches = targets.contains(genres.get(g)); + } + // Spread breaks across equally good campaigns rather than always + // the same one: the break id varies the tie break. + double score = (matches ? 2.0 : 1.0) + left + ((breakId.hashCode() ^ c.getId().hashCode()) & 7) / 100.0; + if (score > bestScore) { + bestScore = score; + best = c; + } + } + return best; + } + + /// Counts an ad event. An impression or a click is counted once per + /// campaign per session, whatever the player sends: a replayed request + /// must not spend an advertiser's budget twice. + @Transactional(rollbackFor = IOException.class) + public void record(String username, AdEventDto event) throws IOException { + if (event == null || event.campaignId() == null || event.sessionId() == null) { + throw new ResponseStatusException(400, "Not an ad event"); + } + String kind = event.event(); + if (!IMPRESSION.equals(kind) && !CLICK.equals(kind) && !SKIP.equals(kind) && !COMPLETE.equals(kind)) { + throw new ResponseStatusException(400, "Not an ad event"); + } + if (campaigns.findById(event.campaignId()) == null) { + throw new ResponseStatusException(404, "No such campaign"); + } + if (events.countBySessionIdAndCampaignIdAndEvent(event.sessionId(), event.campaignId(), kind) > 0) { + return; + } + AdEvent row = new AdEvent(); + row.setId(Ids.next()); + row.setCampaignId(event.campaignId()); + row.setSessionId(event.sessionId()); + row.setUsername(username); + row.setEvent(kind); + row.setCreatedAt(System.currentTimeMillis()); + events.insert(row); + if (IMPRESSION.equals(kind)) { + campaigns.impression(event.campaignId()); + } else if (CLICK.equals(kind)) { + campaigns.click(event.campaignId()); + } + } + + public static AdDto dto(AdCampaign c, String mediaUrl, String breakId) { + return new AdDto(breakId + ":" + c.getId(), c.getId(), mediaUrl, c.getDurationSeconds(), + c.getSkippableAfterSeconds(), c.getClickUrl(), c.getAdvertiser(), new ArrayList()); + } + + // -------------------------------------------------------------- admin + + @Transactional(readOnly = true) + public List list() { + List all = campaigns.findAll(); + List out = new ArrayList(); + for (int iter = 0; iter < all.size(); iter++) { + out.add(campaign(all.get(iter))); + } + return out; + } + + @Transactional(rollbackFor = IOException.class) + public AdCampaignDto save(AdCampaignDto wanted) throws IOException { + if (wanted == null) { + throw new ResponseStatusException(400, "Nothing to save"); + } + AdCampaign row = wanted.id() == null || wanted.id().length() == 0 ? null : campaigns.findById(wanted.id()); + boolean fresh = row == null; + if (fresh) { + row = new AdCampaign(); + row.setId(Ids.next()); + row.setCreatedAt(System.currentTimeMillis()); + } + row.setAdvertiser(text(wanted.advertiser(), 120, "the advertiser")); + row.setName(text(wanted.name(), 120, "the campaign name")); + String creative = wanted.creativeUrl() == null ? "" : wanted.creativeUrl().trim(); + if (!creative.startsWith(VIDEO) && !creative.startsWith("https://")) { + throw new ResponseStatusException(400, "The creative is video: or an https address"); + } + row.setCreativeUrl(creative.length() > 255 ? creative.substring(0, 255) : creative); + row.setDurationSeconds(clamp(wanted.durationSeconds(), 1, 120)); + row.setSkippableAfterSeconds(clamp(wanted.skippableAfterSeconds(), 0, 120)); + String click = wanted.clickUrl() == null ? "" : wanted.clickUrl().trim(); + if (click.length() > 0 && !click.startsWith("https://")) { + throw new ResponseStatusException(400, "The click address must be https"); + } + row.setClickUrl(click.length() > 255 ? click.substring(0, 255) : click); + row.setCpmCents(Math.max(0L, wanted.cpmCents())); + row.setBudgetCents(Math.max(0L, wanted.budgetCents())); + row.setGenres(Rows.join(wanted.genres())); + row.setActive(wanted.active()); + row.setStartsAt(Math.max(0L, wanted.startsAt())); + row.setEndsAt(Math.max(0L, wanted.endsAt())); + if (fresh) { + campaigns.insert(row); + } else { + campaigns.save(row); + } + return campaign(row); + } + + public static AdCampaignDto campaign(AdCampaign c) { + return new AdCampaignDto(c.getId(), c.getAdvertiser(), c.getName(), c.getCreativeUrl(), + c.getDurationSeconds(), c.getSkippableAfterSeconds(), c.getClickUrl(), c.getCpmCents(), + c.getBudgetCents(), c.getSpentMillicents() / 1000L, (int) c.getImpressions(), (int) c.getClicks(), + Rows.split(c.getGenres()), c.isActive(), c.getStartsAt(), c.getEndsAt()); + } + + private static String text(String value, int max, String what) { + String t = value == null ? "" : value.trim(); + if (t.length() == 0 || t.length() > max) { + throw new ResponseStatusException(400, "Fill in " + what + " (at most " + max + " characters)"); + } + return t; + } + + private static int clamp(int value, int min, int max) { + return Math.max(min, Math.min(max, value)); + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/billing/Billing.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/billing/Billing.java new file mode 100644 index 00000000000..e65814a75a4 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/billing/Billing.java @@ -0,0 +1,634 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.billing; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Scheduled; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.annotations.Value; +import com.codenameone.examples.reel.Ids; +import com.codenameone.examples.reel.api.CouponCheckDto; +import com.codenameone.examples.reel.api.CouponDto; +import com.codenameone.examples.reel.api.Features; +import com.codenameone.examples.reel.api.InvoiceDto; +import com.codenameone.examples.reel.api.PlanDto; +import com.codenameone.examples.reel.api.PurchaseReceiptDto; +import com.codenameone.examples.reel.api.RentDto; +import com.codenameone.examples.reel.api.RentalDto; +import com.codenameone.examples.reel.api.SubscribeDto; +import com.codenameone.examples.reel.api.SubscriptionDto; +import com.codenameone.examples.reel.domain.Coupon; +import com.codenameone.examples.reel.domain.Invoice; +import com.codenameone.examples.reel.domain.Plan; +import com.codenameone.examples.reel.domain.Rental; +import com.codenameone.examples.reel.domain.Subscription; +import com.codenameone.examples.reel.domain.Title; +import com.codenameone.examples.reel.pay.PaymentDeclinedException; +import com.codenameone.examples.reel.pay.Payments; +import com.codenameone.examples.reel.repository.CouponRepository; +import com.codenameone.examples.reel.repository.InvoiceRepository; +import com.codenameone.examples.reel.repository.PlanRepository; +import com.codenameone.examples.reel.repository.RentalRepository; +import com.codenameone.examples.reel.repository.SubscriptionRepository; +import com.codenameone.examples.reel.repository.TitleRepository; +import com.codenameone.examples.reel.settings.Settings; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// Subscriptions, coupons, rentals and the invoices they leave. +/// +/// A subscription is one row per account. Subscribing charges the first +/// period at once -- or starts the plan's trial, once per account -- and the +/// renewal sweep charges each period after that. A charge that fails puts the +/// subscription PAST_DUE; [Entitlements] keeps it playing for a grace period +/// while the sweep retries, and it is canceled after [#MAX_ATTEMPTS] failures. +/// Changing plan takes effect at the end of the period already paid for, +/// so nobody is charged twice in one period and there is no proration to get +/// wrong. Canceling also runs to the end of the period. +/// +/// Every charge carries an idempotency key derived from what it pays for +/// (the account and the period, or the rental), so a retried request or two +/// servers running the sweep at once charge a card once. +@Component +public class Billing { + public static final String MONTH = "MONTH"; + public static final String YEAR = "YEAR"; + public static final int MAX_ATTEMPTS = 3; + public static final long DAY = 86400000L; + /// How long a rental plays once paid for. + public static final long RENTAL_MILLIS = 2L * DAY; + public static final String PAID = "PAID"; + public static final String FAILED = "FAILED"; + public static final String KIND_SUBSCRIPTION = "SUBSCRIPTION"; + public static final String KIND_RENTAL = "RENTAL"; + public static final String KIND_STORE = "STORE"; + + private final SubscriptionRepository subscriptions; + private final PlanRepository plans; + private final CouponRepository coupons; + private final InvoiceRepository invoices; + private final RentalRepository rentals; + private final TitleRepository titles; + private final Payments payments; + private final Settings settings; + private final boolean trustReceipts; + + public Billing(SubscriptionRepository subscriptions, PlanRepository plans, CouponRepository coupons, + InvoiceRepository invoices, RentalRepository rentals, TitleRepository titles, Payments payments, + Settings settings, @Value("${reel.billing.trust-store-receipts:false}") String trustReceipts) { + this.subscriptions = subscriptions; + this.plans = plans; + this.coupons = coupons; + this.invoices = invoices; + this.rentals = rentals; + this.titles = titles; + this.payments = payments; + this.settings = settings; + this.trustReceipts = "true".equalsIgnoreCase(trustReceipts.trim()); + } + + // ------------------------------------------------------------ plans + + @Transactional(readOnly = true) + public List plans(boolean includeRetired) { + List all = sortedPlans(); + List out = new ArrayList(); + for (int iter = 0; iter < all.size(); iter++) { + if (includeRetired || all.get(iter).isActive()) { + out.add(plan(all.get(iter))); + } + } + return out; + } + + @Transactional(rollbackFor = IOException.class) + public PlanDto savePlan(String code, PlanDto wanted) throws IOException { + if (wanted == null || !code(code)) { + throw new ResponseStatusException(400, "A plan code is 2-32 letters, digits or dashes"); + } + Plan row = plans.findById(code); + boolean fresh = row == null; + if (fresh) { + row = new Plan(); + row.setCode(code); + row.setPosition(sortedPlans().size()); + } + String name = wanted.name() == null ? "" : wanted.name().trim(); + if (name.length() == 0 || name.length() > 60) { + throw new ResponseStatusException(400, "Name the plan (at most 60 characters)"); + } + row.setName(name); + row.setDescription(cut(wanted.description(), 255)); + row.setPriceCents(Math.max(0L, wanted.priceCents())); + row.setCurrency(settings.currency()); + row.setBillingInterval(YEAR.equals(wanted.interval()) ? YEAR : MONTH); + row.setMaxStreams(Math.max(1, Math.min(10, wanted.maxStreams()))); + row.setMaxQuality(String.valueOf(Entitlements.maxHeight(wanted.maxQuality()))); + row.setAdSupported(wanted.adSupported()); + row.setDownloads(wanted.downloads()); + row.setTrialDays(Math.max(0, Math.min(90, wanted.trialDays()))); + row.setActive(wanted.active()); + if (fresh) { + plans.insert(row); + } else { + plans.save(row); + } + return plan(row); + } + + // ------------------------------------------------------ subscriptions + + @Transactional(readOnly = true) + public SubscriptionDto subscription(String username) { + return dto(subscriptions.findById(username)); + } + + @Transactional(rollbackFor = IOException.class) + public SubscriptionDto subscribe(String username, SubscribeDto wanted) throws IOException { + settings.require(Features.SUBSCRIPTIONS); + if (wanted == null || wanted.planCode() == null) { + throw new ResponseStatusException(400, "Choose a plan"); + } + Plan plan = plans.findById(wanted.planCode()); + if (plan == null || !plan.isActive()) { + throw new ResponseStatusException(404, "No such plan"); + } + long now = System.currentTimeMillis(); + Subscription sub = subscriptions.findById(username); + if (sub != null && Entitlements.isLive(sub, now) && sub.getStore().length() == 0) { + // Already paying: a change of plan waits for the period's end. + if (sub.getPlanCode().equals(plan.getCode())) { + sub.setPendingPlanCode(""); + } else { + sub.setPendingPlanCode(plan.getCode()); + } + sub.setCancelAtPeriodEnd(false); + sub.setUpdatedAt(now); + subscriptions.save(sub); + return dto(sub); + } + Coupon coupon = null; + if (wanted.coupon() != null && wanted.coupon().trim().length() > 0) { + coupon = coupons.findById(normalize(wanted.coupon())); + String why = couponProblem(coupon, now); + if (why != null) { + throw new ResponseStatusException(400, why); + } + } + String method = payments.choose(username, wanted.paymentMethodId())[0]; + if (Payments.CASH.equals(method) && plan.getPriceCents() > 0) { + throw new ResponseStatusException(400, "Add a card to subscribe"); + } + boolean fresh = sub == null; + if (fresh) { + sub = new Subscription(); + sub.setUsername(username); + } + boolean trial = plan.getTrialDays() > 0 && (fresh || sub.getStartedAt() == 0); + sub.setPlanCode(plan.getCode()); + sub.setPendingPlanCode(""); + sub.setCancelAtPeriodEnd(false); + sub.setPaymentMethodId(method); + sub.setFailedAttempts(0); + sub.setStore(""); + sub.setStoreRef(""); + sub.setStartedAt(now); + sub.setUpdatedAt(now); + sub.setCouponCode(coupon == null ? "" : coupon.getCode()); + sub.setCouponPeriodsLeft(coupon == null ? 0 : Math.max(1, coupon.getPeriods())); + if (coupon != null) { + coupon.setRedemptions(coupon.getRedemptions() + 1); + coupons.save(coupon); + } + if (trial) { + sub.setStatus(Entitlements.TRIALING); + sub.setPeriodEndsAt(now + plan.getTrialDays() * DAY); + } else { + long amount = price(plan, sub); + charge(username, method, amount, plan, "start:" + now, now); + sub.setStatus(Entitlements.ACTIVE); + sub.setPeriodEndsAt(periodEnd(plan, now)); + usedCouponPeriod(sub); + } + if (fresh) { + subscriptions.insert(sub); + } else { + subscriptions.save(sub); + } + return dto(sub); + } + + @Transactional(rollbackFor = IOException.class) + public SubscriptionDto cancel(String username) throws IOException { + Subscription sub = subscriptions.findById(username); + if (sub == null || !Entitlements.isLive(sub, System.currentTimeMillis())) { + throw new ResponseStatusException(404, "No subscription to cancel"); + } + sub.setCancelAtPeriodEnd(true); + sub.setPendingPlanCode(""); + sub.setUpdatedAt(System.currentTimeMillis()); + subscriptions.save(sub); + return dto(sub); + } + + @Transactional(rollbackFor = IOException.class) + public SubscriptionDto resume(String username) throws IOException { + Subscription sub = subscriptions.findById(username); + if (sub == null || !Entitlements.isLive(sub, System.currentTimeMillis())) { + throw new ResponseStatusException(404, "No subscription to resume; subscribe again"); + } + sub.setCancelAtPeriodEnd(false); + if (Entitlements.CANCELED.equals(sub.getStatus())) { + sub.setStatus(Entitlements.ACTIVE); + } + sub.setUpdatedAt(System.currentTimeMillis()); + subscriptions.save(sub); + return dto(sub); + } + + @Transactional(readOnly = true) + public CouponCheckDto checkCoupon(String code, String planCode) { + Plan plan = planCode == null ? null : plans.findById(planCode); + Coupon coupon = code == null ? null : coupons.findById(normalize(code)); + String why = couponProblem(coupon, System.currentTimeMillis()); + long base = plan == null ? 0L : plan.getPriceCents(); + if (why != null) { + return new CouponCheckDto(code, false, why, base, 0); + } + return new CouponCheckDto(coupon.getCode(), true, "", base * (100 - coupon.getPercentOff()) / 100, + coupon.getPercentOff()); + } + + /// An App Store or Google Play purchase. A production server must verify + /// the receipt with the store (the App Store Server API, the Play + /// Developer API) before trusting it; that needs store credentials this + /// sample cannot ship, so receipts are refused unless + /// `reel.billing.trust-store-receipts` is on, which only the development + /// and test profiles set. + @Transactional(rollbackFor = IOException.class) + public SubscriptionDto storePurchase(String username, PurchaseReceiptDto receipt) throws IOException { + settings.require(Features.SUBSCRIPTIONS); + if (!trustReceipts) { + throw new ResponseStatusException(501, "Store purchases are not verified on this server"); + } + if (receipt == null || receipt.productId() == null || receipt.transactionId() == null) { + throw new ResponseStatusException(400, "Not a receipt"); + } + Plan plan = plans.findById(receipt.productId()); + if (plan == null) { + throw new ResponseStatusException(404, "No plan matches " + receipt.productId()); + } + long now = System.currentTimeMillis(); + Subscription sub = subscriptions.findById(username); + boolean fresh = sub == null; + if (fresh) { + sub = new Subscription(); + sub.setUsername(username); + } + sub.setPlanCode(plan.getCode()); + sub.setStatus(Entitlements.ACTIVE); + sub.setStartedAt(now); + sub.setPeriodEndsAt(periodEnd(plan, now)); + sub.setCancelAtPeriodEnd(false); + sub.setPendingPlanCode(""); + sub.setStore(cut(receipt.store(), 16)); + sub.setStoreRef(cut(receipt.transactionId(), 128)); + sub.setUpdatedAt(now); + if (fresh) { + subscriptions.insert(sub); + } else { + subscriptions.save(sub); + } + invoice(username, KIND_STORE, plan.getName() + " (" + sub.getStore() + ")", plan.getPriceCents(), + PAID, sub.getStoreRef(), "", now); + return dto(sub); + } + + /// Charges the subscriptions whose period has ended, and ends those that + /// were canceled or ran out of retries. + @Scheduled(fixedDelay = 300000, initialDelay = 30000, lock = "reel-renewals") + @Transactional + public void renew() { + long now = System.currentTimeMillis(); + List due = subscriptions.findByPeriodEndsAtLessThan(now); + for (int iter = 0; iter < due.size(); iter++) { + renewOne(due.get(iter), now); + } + } + + void renewOne(Subscription sub, long now) { + String status = sub.getStatus(); + if (Entitlements.CANCELED.equals(status) || Entitlements.NONE.equals(status)) { + return; + } + if (sub.isCancelAtPeriodEnd() || sub.getStore().length() > 0) { + // A store subscription renews in the store, which tells us with a + // new receipt; without one the period simply ends. + end(sub, now); + return; + } + if (sub.getPendingPlanCode().length() > 0) { + sub.setPlanCode(sub.getPendingPlanCode()); + sub.setPendingPlanCode(""); + } + Plan plan = plans.findById(sub.getPlanCode()); + if (plan == null || !plan.isActive()) { + end(sub, now); + return; + } + // The period being paid for names the charge, so a retry of the same + // period reuses the key and the card is charged once. + String period = "renew:" + sub.getPeriodEndsAt(); + try { + charge(sub.getUsername(), sub.getPaymentMethodId(), price(plan, sub), plan, period, now); + sub.setStatus(Entitlements.ACTIVE); + sub.setFailedAttempts(0); + sub.setPeriodEndsAt(periodEnd(plan, sub.getPeriodEndsAt())); + usedCouponPeriod(sub); + } catch (IOException declined) { + sub.setFailedAttempts(sub.getFailedAttempts() + 1); + if (sub.getFailedAttempts() >= MAX_ATTEMPTS + || now > sub.getPeriodEndsAt() + Entitlements.GRACE_MILLIS) { + end(sub, now); + return; + } + sub.setStatus(Entitlements.PAST_DUE); + } + sub.setUpdatedAt(now); + subscriptions.save(sub); + } + + private void end(Subscription sub, long now) { + sub.setStatus(Entitlements.CANCELED); + sub.setCancelAtPeriodEnd(false); + sub.setUpdatedAt(now); + subscriptions.save(sub); + } + + // ------------------------------------------------------------ rentals + + @Transactional(rollbackFor = IOException.class) + public RentalDto rent(String username, RentDto wanted) throws IOException { + settings.require(Features.RENTALS); + Title title = wanted == null || wanted.titleId() == null ? null : titles.findById(wanted.titleId()); + if (title == null || !Entitlements.RENTAL.equals(title.getAccess())) { + throw new ResponseStatusException(404, "This title is not for rent"); + } + long now = System.currentTimeMillis(); + List mine = rentals.findByUsernameAndTitleId(username, title.getId()); + for (int iter = 0; iter < mine.size(); iter++) { + if (mine.get(iter).getExpiresAt() > now) { + return rental(mine.get(iter), title); + } + } + String method = payments.choose(username, wanted.paymentMethodId())[0]; + if (Payments.CASH.equals(method)) { + throw new ResponseStatusException(400, "Add a card to rent"); + } + Rental row = new Rental(); + row.setId(Ids.next()); + row.setUsername(username); + row.setTitleId(title.getId()); + row.setPriceCents(title.getRentalPriceCents()); + row.setCurrency(settings.currency()); + row.setCreatedAt(now); + row.setExpiresAt(now + RENTAL_MILLIS); + String ref = payments.chargeCard("rent:" + row.getId(), username, method, row.getPriceCents(), + row.getCurrency(), "Rental: " + title.getName()); + row.setInvoiceId(invoice(username, KIND_RENTAL, "Rental: " + title.getName(), row.getPriceCents(), PAID, + ref, title.getId(), now)); + rentals.insert(row); + return rental(row, title); + } + + @Transactional(readOnly = true) + public List rentals(String username) { + List all = rentals.findByUsernameOrderByCreatedAtDesc(username); + List out = new ArrayList(); + for (int iter = 0; iter < all.size(); iter++) { + out.add(rental(all.get(iter), titles.findById(all.get(iter).getTitleId()))); + } + return out; + } + + @Transactional(readOnly = true) + public List invoices(String username) { + List all = invoices.findByUsernameOrderByCreatedAtDesc(username); + List out = new ArrayList(); + for (int iter = 0; iter < all.size(); iter++) { + Invoice i = all.get(iter); + out.add(new InvoiceDto(i.getId(), i.getKind(), i.getDescription(), i.getAmountCents(), i.getCurrency(), + i.getStatus(), i.getCreatedAt(), i.getTitleId())); + } + return out; + } + + // ------------------------------------------------------------ coupons + + @Transactional(readOnly = true) + public List coupons() { + List all = coupons.findAll(); + List out = new ArrayList(); + for (int iter = 0; iter < all.size(); iter++) { + out.add(coupon(all.get(iter))); + } + return out; + } + + @Transactional(rollbackFor = IOException.class) + public CouponDto saveCoupon(String code, CouponDto wanted) throws IOException { + String key = normalize(code); + if (wanted == null || !code(key)) { + throw new ResponseStatusException(400, "A coupon code is 2-32 letters, digits or dashes"); + } + Coupon row = coupons.findById(key); + boolean fresh = row == null; + if (fresh) { + row = new Coupon(); + row.setCode(key); + row.setCreatedAt(System.currentTimeMillis()); + } + row.setPercentOff(Math.max(1, Math.min(100, wanted.percentOff()))); + row.setPeriods(Math.max(1, Math.min(36, wanted.periods()))); + row.setMaxRedemptions(Math.max(0, wanted.maxRedemptions())); + row.setExpiresAt(Math.max(0L, wanted.expiresAt())); + row.setActive(wanted.active()); + if (fresh) { + coupons.insert(row); + } else { + coupons.save(row); + } + return coupon(row); + } + + // ------------------------------------------------------------ helpers + + private void charge(String username, String method, long amount, Plan plan, String period, long now) + throws IOException { + String description = plan.getName() + " plan"; + if (amount <= 0) { + invoice(username, KIND_SUBSCRIPTION, description, 0L, PAID, "", "", now); + return; + } + try { + String ref = payments.chargeCard("sub:" + username + ":" + period, username, method, amount, + plan.getCurrency().length() == 0 ? settings.currency() : plan.getCurrency(), description); + invoice(username, KIND_SUBSCRIPTION, description, amount, PAID, ref, "", now); + } catch (PaymentDeclinedException declined) { + invoice(username, KIND_SUBSCRIPTION, description + " (declined)", amount, FAILED, "", "", now); + throw declined; + } + } + + private String invoice(String username, String kind, String description, long amount, String status, + String ref, String titleId, long now) throws IOException { + Invoice row = new Invoice(); + row.setId(Ids.next()); + row.setUsername(username); + row.setKind(kind); + row.setDescription(cut(description, 255)); + row.setAmountCents(amount); + row.setCurrency(settings.currency()); + row.setStatus(status); + row.setProviderRef(ref == null ? "" : ref); + row.setTitleId(titleId == null ? "" : titleId); + row.setCreatedAt(now); + invoices.insert(row); + return row.getId(); + } + + private long price(Plan plan, Subscription sub) { + if (sub.getCouponPeriodsLeft() > 0 && sub.getCouponCode().length() > 0) { + Coupon c = coupons.findById(sub.getCouponCode()); + if (c != null) { + return plan.getPriceCents() * (100 - c.getPercentOff()) / 100; + } + } + return plan.getPriceCents(); + } + + private static void usedCouponPeriod(Subscription sub) { + if (sub.getCouponPeriodsLeft() > 0) { + sub.setCouponPeriodsLeft(sub.getCouponPeriodsLeft() - 1); + if (sub.getCouponPeriodsLeft() == 0) { + sub.setCouponCode(""); + } + } + } + + private static long periodEnd(Plan plan, long from) { + return from + (YEAR.equals(plan.getBillingInterval()) ? 365L : 30L) * DAY; + } + + private static String couponProblem(Coupon c, long now) { + if (c == null || !c.isActive()) { + return "That code is not valid"; + } + if (c.getExpiresAt() > 0 && c.getExpiresAt() < now) { + return "That code has expired"; + } + if (c.getMaxRedemptions() > 0 && c.getRedemptions() >= c.getMaxRedemptions()) { + return "That code has been used up"; + } + return null; + } + + private List sortedPlans() { + List all = new ArrayList(plans.findAll()); + // Insertion sort by position: a handful of plans. + for (int i = 1; i < all.size(); i++) { + Plan p = all.get(i); + int j = i - 1; + while (j >= 0 && all.get(j).getPosition() > p.getPosition()) { + all.set(j + 1, all.get(j)); + j--; + } + all.set(j + 1, p); + } + return all; + } + + private SubscriptionDto dto(Subscription sub) { + if (sub == null) { + return new SubscriptionDto("", "", Entitlements.NONE, 0L, 0L, false, 0L, settings.currency(), "", 0, ""); + } + Plan plan = plans.findById(sub.getPlanCode()); + int percent = 0; + if (sub.getCouponCode().length() > 0) { + Coupon c = coupons.findById(sub.getCouponCode()); + percent = c == null ? 0 : c.getPercentOff(); + } + return new SubscriptionDto(sub.getPlanCode(), plan == null ? "" : plan.getName(), sub.getStatus(), + sub.getStartedAt(), sub.getPeriodEndsAt(), sub.isCancelAtPeriodEnd(), + plan == null ? 0L : plan.getPriceCents(), settings.currency(), sub.getCouponCode(), percent, + sub.getPendingPlanCode()); + } + + public static PlanDto plan(Plan p) { + return new PlanDto(p.getCode(), p.getName(), p.getDescription(), p.getPriceCents(), p.getCurrency(), + p.getBillingInterval(), p.getMaxStreams(), p.getMaxQuality(), p.isAdSupported(), p.isDownloads(), + p.getTrialDays(), p.isActive()); + } + + static CouponDto coupon(Coupon c) { + return new CouponDto(c.getCode(), c.getPercentOff(), c.getPeriods(), c.getMaxRedemptions(), + c.getRedemptions(), c.getExpiresAt(), c.isActive()); + } + + private RentalDto rental(Rental r, Title t) { + return new RentalDto(r.getId(), r.getTitleId(), t == null ? "" : t.getName(), r.getPriceCents(), + r.getCurrency(), r.getExpiresAt(), r.getStartedAt() > 0); + } + + /// Coupon and plan codes are compared in upper case, folded by hand + /// because they are ASCII by construction (see [#code]). + static String normalize(String code) { + if (code == null) { + return ""; + } + String t = code.trim(); + StringBuilder out = new StringBuilder(t.length()); + for (int iter = 0; iter < t.length(); iter++) { + char c = t.charAt(iter); + out.append(c >= 'a' && c <= 'z' ? (char) (c - 32) : c); + } + return out.toString(); + } + + static boolean code(String code) { + if (code == null || code.length() < 2 || code.length() > 32) { + return false; + } + for (int iter = 0; iter < code.length(); iter++) { + char c = code.charAt(iter); + if (!(c >= 'a' && c <= 'z') && !(c >= 'A' && c <= 'Z') && !(c >= '0' && c <= '9') && c != '-') { + return false; + } + } + return true; + } + + private static String cut(String value, int max) { + String t = value == null ? "" : value.trim(); + return t.length() > max ? t.substring(0, max) : t; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/billing/BillingEndpoint.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/billing/BillingEndpoint.java new file mode 100644 index 00000000000..160e3a52277 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/billing/BillingEndpoint.java @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.billing; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.reel.Caller; +import com.codenameone.examples.reel.api.BillingApiServer; +import com.codenameone.examples.reel.api.CouponCheckDto; +import com.codenameone.examples.reel.api.InvoiceDto; +import com.codenameone.examples.reel.api.PlanDto; +import com.codenameone.examples.reel.api.PurchaseReceiptDto; +import com.codenameone.examples.reel.api.RentDto; +import com.codenameone.examples.reel.api.RentalDto; +import com.codenameone.examples.reel.api.SubscribeDto; +import com.codenameone.examples.reel.api.SubscriptionDto; + +import java.util.List; + +@Component +public class BillingEndpoint implements BillingApiServer { + private final Billing billing; + + public BillingEndpoint(Billing billing) { + this.billing = billing; + } + + /// Public: the plan picker is shown before signing up. + @Override + public List plans() throws Exception { + return billing.plans(false); + } + + @Override + public SubscriptionDto subscription() throws Exception { + return billing.subscription(Caller.name()); + } + + @Override + public SubscriptionDto subscribe(SubscribeDto subscribe) throws Exception { + return billing.subscribe(Caller.name(), subscribe); + } + + @Override + public SubscriptionDto cancel() throws Exception { + return billing.cancel(Caller.name()); + } + + @Override + public SubscriptionDto resume() throws Exception { + return billing.resume(Caller.name()); + } + + @Override + public CouponCheckDto checkCoupon(String code, String plan) throws Exception { + Caller.name(); + return billing.checkCoupon(code, plan); + } + + @Override + public List invoices() throws Exception { + return billing.invoices(Caller.name()); + } + + @Override + public List rentals() throws Exception { + return billing.rentals(Caller.name()); + } + + @Override + public RentalDto rent(RentDto rent) throws Exception { + return billing.rent(Caller.name(), rent); + } + + @Override + public SubscriptionDto verifyReceipt(PurchaseReceiptDto receipt) throws Exception { + return billing.storePurchase(Caller.name(), receipt); + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/billing/Entitlements.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/billing/Entitlements.java new file mode 100644 index 00000000000..814bdc08613 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/billing/Entitlements.java @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.billing; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.reel.api.Features; +import com.codenameone.examples.reel.domain.Plan; +import com.codenameone.examples.reel.domain.Rental; +import com.codenameone.examples.reel.domain.Subscription; +import com.codenameone.examples.reel.domain.Title; +import com.codenameone.examples.reel.repository.PlanRepository; +import com.codenameone.examples.reel.repository.RentalRepository; +import com.codenameone.examples.reel.repository.SubscriptionRepository; +import com.codenameone.examples.reel.settings.Settings; + +import java.util.List; + +/// What an account may watch, and how: the one place that turns a plan, a +/// rental and the feature switches into a yes or a no. +/// +/// The rules, in order: +/// +/// - With paid plans switched off every title is free, and ads play where ads +/// are switched on. +/// - A FREE title plays for anyone, with ads unless the plan is ad free. +/// - A SUBSCRIPTION title needs a live subscription: trialing or active, its +/// period not yet over. A cancelled subscription stays live to the end of +/// the period that was paid for. +/// - A RENTAL title needs a rental that has not expired, whatever the plan. +/// +/// The creator of a title, moderators and admins play it whatever it costs: +/// they have to see what they publish and what they judge. +@Component +public class Entitlements { + public static final String TRIALING = "TRIALING"; + public static final String ACTIVE = "ACTIVE"; + public static final String PAST_DUE = "PAST_DUE"; + public static final String CANCELED = "CANCELED"; + public static final String NONE = "NONE"; + + public static final String FREE = "FREE"; + public static final String SUBSCRIPTION = "SUBSCRIPTION"; + public static final String RENTAL = "RENTAL"; + + /// How long a renewal that failed keeps the subscription playing while + /// the card is retried. + public static final long GRACE_MILLIS = 3L * 86400000L; + + private final SubscriptionRepository subscriptions; + private final PlanRepository plans; + private final RentalRepository rentals; + private final Settings settings; + + public Entitlements(SubscriptionRepository subscriptions, PlanRepository plans, + RentalRepository rentals, Settings settings) { + this.subscriptions = subscriptions; + this.plans = plans; + this.rentals = rentals; + this.settings = settings; + } + + /// What an account's plan gives it, right now. + public static final class Allowance { + public final String planCode; + public final String planName; + public final String status; + public final long periodEndsAt; + public final boolean live; + public final boolean ads; + public final int maxStreams; + public final String maxQuality; + public final boolean downloads; + + Allowance(String planCode, String planName, String status, long periodEndsAt, boolean live, + boolean ads, int maxStreams, String maxQuality, boolean downloads) { + this.planCode = planCode; + this.planName = planName; + this.status = status; + this.periodEndsAt = periodEndsAt; + this.live = live; + this.ads = ads; + this.maxStreams = maxStreams; + this.maxQuality = maxQuality; + this.downloads = downloads; + } + } + + /// The decision for one title. + public static final class Access { + public final boolean allowed; + public final boolean ads; + /// Why not, in words the app shows; empty when allowed. + public final String reason; + /// 402 for something money fixes, 403 for something it does not. + public final int status; + + Access(boolean allowed, boolean ads, String reason, int status) { + this.allowed = allowed; + this.ads = ads; + this.reason = reason; + this.status = status; + } + } + + @Transactional(readOnly = true) + public Allowance allowance(String username) { + boolean adsOn = settings.enabled(Features.ADS); + boolean downloadsOn = settings.enabled(Features.DOWNLOADS); + if (!settings.enabled(Features.SUBSCRIPTIONS)) { + // No plans: one generous allowance for everybody, paid for by ads + // when ads are on. + return new Allowance("", "Free", NONE, 0L, true, adsOn, 4, "1080", downloadsOn); + } + Subscription sub = username == null ? null : subscriptions.findById(username); + long now = System.currentTimeMillis(); + if (sub != null && isLive(sub, now)) { + Plan plan = plans.findById(sub.getPlanCode()); + if (plan != null) { + return new Allowance(plan.getCode(), plan.getName(), sub.getStatus(), + sub.getPeriodEndsAt(), true, adsOn && plan.isAdSupported(), + Math.max(1, plan.getMaxStreams()), plan.getMaxQuality(), + downloadsOn && plan.isDownloads()); + } + } + String status = sub == null ? NONE : sub.getStatus(); + return new Allowance("", "No plan", status, sub == null ? 0L : sub.getPeriodEndsAt(), false, + adsOn, 1, "480", false); + } + + /// Whether a subscription row is playing now. + public static boolean isLive(Subscription sub, long now) { + String status = sub.getStatus(); + if (TRIALING.equals(status) || ACTIVE.equals(status) || CANCELED.equals(status)) { + return sub.getPeriodEndsAt() > now; + } + if (PAST_DUE.equals(status)) { + return sub.getPeriodEndsAt() + GRACE_MILLIS > now; + } + return false; + } + + /// May `username` play `title`, and with ads? `staff` is true for the + /// title's creator and for moderators and admins. + @Transactional(readOnly = true) + public Access access(String username, Title title, boolean staff) { + if (staff) { + return new Access(true, false, "", 200); + } + Allowance allowance = allowance(username); + String kind = title.getAccess(); + if (RENTAL.equals(kind) && settings.enabled(Features.RENTALS)) { + if (hasRental(username, title.getId())) { + return new Access(true, false, "", 200); + } + return new Access(false, false, "Rent this title to watch it", 402); + } + if (FREE.equals(kind) || !settings.enabled(Features.SUBSCRIPTIONS)) { + return new Access(true, allowance.ads, "", 200); + } + if (allowance.live) { + return new Access(true, allowance.ads, "", 200); + } + return new Access(false, false, "Choose a plan to watch this", 402); + } + + @Transactional(readOnly = true) + public boolean hasRental(String username, String titleId) { + if (username == null) { + return false; + } + long now = System.currentTimeMillis(); + List held = rentals.findByUsernameAndTitleId(username, titleId); + for (int iter = 0; iter < held.size(); iter++) { + if (held.get(iter).getExpiresAt() > now) { + return true; + } + } + return false; + } + + /// The tallest rendition a quality cap allows. + public static int maxHeight(String quality) { + if ("2160".equals(quality)) { + return 2160; + } + if ("1080".equals(quality)) { + return 1080; + } + if ("720".equals(quality)) { + return 720; + } + return 480; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/catalog/Catalog.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/catalog/Catalog.java new file mode 100644 index 00000000000..67f445ea079 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/catalog/Catalog.java @@ -0,0 +1,446 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.catalog; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.reel.Kinds; +import com.codenameone.examples.reel.Roles; +import com.codenameone.examples.reel.Rows; +import com.codenameone.examples.reel.account.Accounts; +import com.codenameone.examples.reel.account.Households; +import com.codenameone.examples.reel.api.ChapterDto; +import com.codenameone.examples.reel.api.EpisodeDto; +import com.codenameone.examples.reel.api.Features; +import com.codenameone.examples.reel.api.GenreDto; +import com.codenameone.examples.reel.api.HomeDto; +import com.codenameone.examples.reel.api.HomeRowDto; +import com.codenameone.examples.reel.api.SearchResultDto; +import com.codenameone.examples.reel.api.TitleCardDto; +import com.codenameone.examples.reel.api.TitleDto; +import com.codenameone.examples.reel.billing.Entitlements; +import com.codenameone.examples.reel.domain.Chapter; +import com.codenameone.examples.reel.domain.Genre; +import com.codenameone.examples.reel.domain.Household; +import com.codenameone.examples.reel.domain.ListEntry; +import com.codenameone.examples.reel.domain.Progress; +import com.codenameone.examples.reel.domain.Title; +import com.codenameone.examples.reel.domain.TitleRating; +import com.codenameone.examples.reel.domain.Video; +import com.codenameone.examples.reel.repository.ChapterRepository; +import com.codenameone.examples.reel.repository.GenreRepository; +import com.codenameone.examples.reel.repository.InteractionRepository; +import com.codenameone.examples.reel.repository.ListEntryRepository; +import com.codenameone.examples.reel.repository.ProgressRepository; +import com.codenameone.examples.reel.repository.TitleRatingRepository; +import com.codenameone.examples.reel.repository.TitleRepository; +import com.codenameone.examples.reel.repository.VideoRepository; +import com.codenameone.examples.reel.settings.Settings; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/// What a profile browses: the home rows, a title's page, genres and search. +/// +/// Everything is filtered by the profile's rating (a kids profile never sees +/// an R title, not even in search) and only published titles are shown. +/// +/// The recommendations ([Recommender]) mix what the profile watched and +/// rated with what other profiles who watched the same titles went on to +/// watch. They are computed on request from the database: a catalog of a few +/// thousand titles is a few milliseconds, and a larger service would precompute +/// the same scores in a scheduled job. +@Component +public class Catalog { + public static final int ROW = 20; + + private final TitleRepository titles; + private final VideoRepository videos; + private final GenreRepository genres; + private final ProgressRepository progress; + private final ListEntryRepository lists; + private final TitleRatingRepository ratings; + private final ChapterRepository chapters; + private final InteractionRepository interactions; + private final Households households; + private final Accounts accounts; + private final Entitlements entitlements; + private final Settings settings; + private final Recommender recommender; + + public Catalog(TitleRepository titles, VideoRepository videos, GenreRepository genres, + ProgressRepository progress, ListEntryRepository lists, TitleRatingRepository ratings, + ChapterRepository chapters, InteractionRepository interactions, Households households, + Accounts accounts, Entitlements entitlements, Settings settings, Recommender recommender) { + this.titles = titles; + this.videos = videos; + this.genres = genres; + this.progress = progress; + this.lists = lists; + this.ratings = ratings; + this.chapters = chapters; + this.interactions = interactions; + this.households = households; + this.accounts = accounts; + this.entitlements = entitlements; + this.settings = settings; + this.recommender = recommender; + } + + @Transactional(rollbackFor = IOException.class) + public HomeDto home(String username, String profileId) throws IOException { + Household profile = households.resolve(username, profileId); + List visible = visible(profile); + Map<String, Title> byId = new HashMap<String, Title>(); + for (int iter = 0; iter < visible.size(); iter++) { + byId.put(visible.get(iter).getId(), visible.get(iter)); + } + Map<String, Integer> percent = new HashMap<String, Integer>(); + List<HomeRowDto> rows = new ArrayList<HomeRowDto>(); + + List<Progress> recent = progress.findTop50ByProfileIdOrderByUpdatedAtDesc(profile.getId()); + List<TitleCardDto> watching = new ArrayList<TitleCardDto>(); + Set<String> seen = new HashSet<String>(); + String lastWatched = null; + for (int iter = 0; iter < recent.size(); iter++) { + Progress p = recent.get(iter); + Title t = byId.get(p.getTitleId()); + if (t == null || !seen.add(t.getId())) { + continue; + } + if (lastWatched == null) { + lastWatched = t.getId(); + } + int pct = p.getDurationSeconds() <= 0 ? 0 : p.getPositionSeconds() * 100 / p.getDurationSeconds(); + percent.put(t.getId(), Integer.valueOf(p.isCompleted() ? 100 : pct)); + if (!p.isCompleted() && watching.size() < ROW) { + watching.add(card(t, pct, 0)); + } + } + if (!watching.isEmpty()) { + rows.add(new HomeRowDto("continue", "Continue watching", "CONTINUE", watching)); + } + + List<ListEntry> mine = lists.findByProfileIdOrderByAddedAtDesc(profile.getId()); + List<TitleCardDto> myList = new ArrayList<TitleCardDto>(); + for (int iter = 0; iter < mine.size() && myList.size() < ROW; iter++) { + Title t = byId.get(mine.get(iter).getTitleId()); + if (t != null) { + myList.add(card(t, pct(percent, t), 0)); + } + } + if (!myList.isEmpty()) { + rows.add(new HomeRowDto("my-list", "My List", "LIST", myList)); + } + + List<Recommender.Scored> picks = recommender.forProfile(profile.getId(), visible); + if (!picks.isEmpty()) { + rows.add(new HomeRowDto("for-you", "Recommended for you", "RECOMMENDED", cards(picks, percent))); + } + if (lastWatched != null) { + List<Recommender.Scored> similar = recommender.similar(byId.get(lastWatched), visible); + if (!similar.isEmpty()) { + rows.add(new HomeRowDto("because", "Because you watched " + byId.get(lastWatched).getName(), + "RECOMMENDED", cards(similar, percent))); + } + } + + List<Title> courses = new ArrayList<Title>(); + for (int iter = 0; iter < visible.size(); iter++) { + if (Kinds.COURSE.equals(visible.get(iter).getKind())) { + courses.add(visible.get(iter)); + } + } + if (!courses.isEmpty()) { + rows.add(new HomeRowDto("courses", settings.enabled(Features.CERTIFICATES) + ? "Courses with certificates" : "Courses", "COURSES", plain(courses, percent))); + } + + List<Title> trending = new ArrayList<Title>(visible); + sort(trending, true); + rows.add(new HomeRowDto("trending", "Trending now", "TRENDING", plain(trending, percent))); + List<Title> fresh = new ArrayList<Title>(visible); + sort(fresh, false); + rows.add(new HomeRowDto("new", "New on " + settings.branding().serviceName(), "NEW", plain(fresh, percent))); + + List<Genre> all = genres.findByPositionGreaterThanEqualOrderByPositionAsc(0); + for (int iter = 0; iter < all.size(); iter++) { + List<Title> in = inGenre(visible, all.get(iter).getId()); + if (in.size() >= 2) { + rows.add(new HomeRowDto("genre:" + all.get(iter).getId(), all.get(iter).getName(), "GENRE", + plain(in, percent))); + } + } + + Title hero = null; + for (int iter = 0; iter < visible.size() && hero == null; iter++) { + if (visible.get(iter).isFeatured()) { + hero = visible.get(iter); + } + } + if (hero == null && !trending.isEmpty()) { + hero = trending.get(0); + } + return new HomeDto(hero == null ? null : card(hero, pct(percent, hero), 0), rows); + } + + @Transactional(rollbackFor = IOException.class) + public TitleDto title(String username, String id, String profileId) throws IOException { + Household profile = households.resolve(username, profileId); + Title t = titles.findById(id); + boolean own = t != null && (t.getCreator().equals(username) || Roles.isStaff()); + if (t == null || (!Kinds.PUBLISHED.equals(t.getStatus()) && !own)) { + throw new ResponseStatusException(404, "No such title"); + } + if (!Households.allows(profile, t.getRating())) { + throw new ResponseStatusException(403, "This profile cannot watch " + t.getRating() + " titles"); + } + Entitlements.Access access = entitlements.access(username, t, own); + Entitlements.Allowance allowance = entitlements.allowance(username); + List<Video> all = videos.findByTitleIdOrderBySeasonAscNumberAsc(t.getId()); + List<EpisodeDto> episodes = new ArrayList<EpisodeDto>(); + String trailer = null; + Video first = null; + boolean interactive = false; + Map<String, Progress> marks = new HashMap<String, Progress>(); + List<Progress> mine = progress.findByProfileIdAndTitleId(profile.getId(), t.getId()); + for (int iter = 0; iter < mine.size(); iter++) { + marks.put(mine.get(iter).getVideoId(), mine.get(iter)); + } + int resume = 0; + for (int iter = 0; iter < all.size(); iter++) { + Video v = all.get(iter); + if (!Kinds.READY.equals(v.getStatus())) { + continue; + } + if (Kinds.TRAILER.equals(v.getRole())) { + trailer = v.getId(); + continue; + } + if (first == null) { + first = v; + } + interactive |= !interactions.findByVideoIdOrderByAtSecondsAsc(v.getId()).isEmpty(); + Progress p = marks.get(v.getId()); + int pct = p == null || v.getDurationSeconds() <= 0 ? 0 + : p.isCompleted() ? 100 : p.getPositionSeconds() * 100 / v.getDurationSeconds(); + if (p != null && !p.isCompleted() && resume == 0) { + resume = p.getPositionSeconds(); + } + episodes.add(new EpisodeDto(v.getId(), v.getSeason(), v.getNumber(), v.getName(), v.getSynopsis(), + v.getThumbnailUrl(), v.getDurationSeconds(), pct, p != null && p.isCompleted(), !access.allowed)); + } + List<ChapterDto> cs = new ArrayList<ChapterDto>(); + if (first != null) { + List<Chapter> rows = chapters.findByVideoIdOrderByStartSecondsAsc(first.getId()); + for (int iter = 0; iter < rows.size(); iter++) { + cs.add(new ChapterDto(rows.get(iter).getId(), rows.get(iter).getStartSeconds(), + rows.get(iter).getTitle())); + } + } + TitleRating mineRating = ratings.findFirstByProfileIdAndTitleId(profile.getId(), t.getId()); + boolean listed = lists.findFirstByProfileIdAndTitleId(profile.getId(), t.getId()) != null; + List<Recommender.Scored> similar = recommender.similar(t, visible(profile)); + Map<String, Integer> none = new HashMap<String, Integer>(); + return new TitleDto(t.getId(), t.getKind(), t.getName(), t.getSynopsis(), t.getPosterUrl(), + t.getBackdropUrl(), t.getYear(), t.getRating(), Rows.split(t.getGenres()), Rows.split(t.getTags()), + t.getCreator(), accounts.displayName(t.getCreator()), first == null ? 0 : first.getDurationSeconds(), + average(t), t.getRatingCount(), mineRating == null ? 0 : mineRating.getStars(), listed, resume, + t.getAccess(), t.getRentalPriceCents(), settings.currency(), access.allowed, + access.allowed && allowance.downloads && settings.enabled(Features.DOWNLOADS), + interactive && settings.enabled(Features.INTERACTIVE), + t.isCertificate() && settings.enabled(Features.CERTIFICATES), trailer, cs, episodes, + cards(similar, none)); + } + + @Transactional(readOnly = true) + public List<GenreDto> genres() { + List<Title> published = titles.findByStatus(Kinds.PUBLISHED); + List<Genre> all = genres.findByPositionGreaterThanEqualOrderByPositionAsc(0); + List<GenreDto> out = new ArrayList<GenreDto>(); + for (int iter = 0; iter < all.size(); iter++) { + out.add(new GenreDto(all.get(iter).getId(), all.get(iter).getName(), + inGenre(published, all.get(iter).getId()).size())); + } + return out; + } + + @Transactional(rollbackFor = IOException.class) + public List<TitleCardDto> genre(String username, String id, String profileId) throws IOException { + Household profile = households.resolve(username, profileId); + List<Title> in = inGenre(visible(profile), id); + sort(in, true); + return plain(in, new HashMap<String, Integer>(), 200); + } + + /// Search over names, synopses, tags, genres and creators: every word of + /// the query must appear somewhere, and a name match ranks first. + @Transactional(rollbackFor = IOException.class) + public SearchResultDto search(String username, String q, String profileId) throws IOException { + Household profile = households.resolve(username, profileId); + String query = q == null ? "" : q.trim(); + if (query.length() == 0) { + return new SearchResultDto("", 0, new ArrayList<TitleCardDto>()); + } + String[] words = fold(query).split("[^a-z0-9]+"); + List<Title> visible = visible(profile); + List<Recommender.Scored> hits = new ArrayList<Recommender.Scored>(); + for (int iter = 0; iter < visible.size(); iter++) { + Title t = visible.get(iter); + String name = fold(t.getName()); + String rest = fold(t.getSynopsis() + " " + t.getTags() + " " + t.getGenres() + " " + + accounts.displayName(t.getCreator())); + double score = 0; + boolean all = true; + for (int w = 0; w < words.length && all; w++) { + if (words[w].length() == 0) { + continue; + } + if (name.indexOf(words[w]) >= 0) { + score += 3; + } else if (rest.indexOf(words[w]) >= 0) { + score += 1; + } else { + all = false; + } + } + if (all && score > 0) { + hits.add(new Recommender.Scored(t, score + Math.log(1 + t.getViewCount()) / 10.0)); + } + } + Recommender.sortScored(hits); + List<TitleCardDto> cards = cards(hits, new HashMap<String, Integer>()); + return new SearchResultDto(query, hits.size(), cards); + } + + // ------------------------------------------------------------ helpers + + List<Title> visible(Household profile) { + List<Title> published = titles.findByStatus(Kinds.PUBLISHED); + List<Title> out = new ArrayList<Title>(); + for (int iter = 0; iter < published.size(); iter++) { + Title t = published.get(iter); + if (Households.allows(profile, t.getRating()) && hasPlayable(t) + && (settings.enabled(Features.RENTALS) || !Entitlements.RENTAL.equals(t.getAccess()))) { + out.add(t); + } + } + return out; + } + + private boolean hasPlayable(Title t) { + List<Video> all = videos.findByTitleIdOrderBySeasonAscNumberAsc(t.getId()); + for (int iter = 0; iter < all.size(); iter++) { + if (Kinds.READY.equals(all.get(iter).getStatus()) && !Kinds.TRAILER.equals(all.get(iter).getRole()) + && !Kinds.AD.equals(all.get(iter).getRole())) { + return true; + } + } + return false; + } + + static List<Title> inGenre(List<Title> from, String genre) { + List<Title> out = new ArrayList<Title>(); + for (int iter = 0; iter < from.size(); iter++) { + if (Rows.split(from.get(iter).getGenres()).contains(genre)) { + out.add(from.get(iter)); + } + } + return out; + } + + private static void sort(List<Title> list, final boolean byViews) { + for (int i = 1; i < list.size(); i++) { + Title t = list.get(i); + int j = i - 1; + while (j >= 0 && (byViews ? list.get(j).getViewCount() < t.getViewCount() + : list.get(j).getPublishedAt() < t.getPublishedAt())) { + list.set(j + 1, list.get(j)); + j--; + } + list.set(j + 1, t); + } + } + + private List<TitleCardDto> plain(List<Title> list, Map<String, Integer> percent) { + return plain(list, percent, ROW); + } + + private List<TitleCardDto> plain(List<Title> list, Map<String, Integer> percent, int max) { + List<TitleCardDto> out = new ArrayList<TitleCardDto>(); + for (int iter = 0; iter < list.size() && out.size() < max; iter++) { + out.add(card(list.get(iter), pct(percent, list.get(iter)), 0)); + } + return out; + } + + private List<TitleCardDto> cards(List<Recommender.Scored> list, Map<String, Integer> percent) { + List<TitleCardDto> out = new ArrayList<TitleCardDto>(); + for (int iter = 0; iter < list.size() && out.size() < ROW; iter++) { + Title t = list.get(iter).title; + out.add(card(t, pct(percent, t), list.get(iter).score)); + } + return out; + } + + private static int pct(Map<String, Integer> percent, Title t) { + Integer p = percent.get(t.getId()); + return p == null ? 0 : p.intValue(); + } + + public TitleCardDto card(Title t, int progressPercent, double score) { + int duration = 0; + List<Video> all = videos.findByTitleIdOrderBySeasonAscNumberAsc(t.getId()); + for (int iter = 0; iter < all.size(); iter++) { + if (Kinds.READY.equals(all.get(iter).getStatus()) && !Kinds.TRAILER.equals(all.get(iter).getRole())) { + duration = all.get(iter).getDurationSeconds(); + break; + } + } + String badge = ""; + long now = System.currentTimeMillis(); + if (Kinds.COURSE.equals(t.getKind()) && t.isCertificate()) { + badge = "CERTIFICATE"; + } else if (t.getPublishedAt() > now - 14L * 86400000L) { + badge = "NEW"; + } + return new TitleCardDto(t.getId(), t.getKind(), t.getName(), t.getPosterUrl(), t.getBackdropUrl(), + t.getYear(), t.getRating(), duration, score, progressPercent, badge, + Entitlements.FREE.equals(t.getAccess()), Entitlements.RENTAL.equals(t.getAccess())); + } + + static double average(Title t) { + return t.getRatingCount() == 0 ? 0 : Math.round(t.getRatingSum() * 10.0 / t.getRatingCount()) / 10.0; + } + + public static String fold(String s) { + StringBuilder out = new StringBuilder(s.length()); + for (int iter = 0; iter < s.length(); iter++) { + char c = s.charAt(iter); + out.append(c >= 'A' && c <= 'Z' ? (char) (c + 32) : c); + } + return out.toString(); + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/catalog/CatalogEndpoint.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/catalog/CatalogEndpoint.java new file mode 100644 index 00000000000..eee0d024fff --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/catalog/CatalogEndpoint.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.catalog; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.reel.Caller; +import com.codenameone.examples.reel.api.CatalogApiServer; +import com.codenameone.examples.reel.api.GenreDto; +import com.codenameone.examples.reel.api.HomeDto; +import com.codenameone.examples.reel.api.ReportDto; +import com.codenameone.examples.reel.api.SearchResultDto; +import com.codenameone.examples.reel.api.TitleCardDto; +import com.codenameone.examples.reel.api.TitleDto; +import com.codenameone.examples.reel.moderation.Reviews; + +import java.util.List; + +@Component +public class CatalogEndpoint implements CatalogApiServer { + private final Catalog catalog; + private final Reviews reviews; + + public CatalogEndpoint(Catalog catalog, Reviews reviews) { + this.catalog = catalog; + this.reviews = reviews; + } + + @Override + public HomeDto home(String profile) throws Exception { + return catalog.home(Caller.name(), profile); + } + + @Override + public TitleDto title(String id, String profile) throws Exception { + return catalog.title(Caller.name(), id, profile); + } + + @Override + public List<GenreDto> genres() throws Exception { + Caller.name(); + return catalog.genres(); + } + + @Override + public List<TitleCardDto> genre(String id, String profile) throws Exception { + return catalog.genre(Caller.name(), id, profile); + } + + @Override + public SearchResultDto search(String q, String profile) throws Exception { + return catalog.search(Caller.name(), q, profile); + } + + @Override + public String report(String id, ReportDto report) throws Exception { + reviews.report(Caller.name(), id, report); + return "ok"; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/catalog/Recommender.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/catalog/Recommender.java new file mode 100644 index 00000000000..fc7fce97037 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/catalog/Recommender.java @@ -0,0 +1,240 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.catalog; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.reel.Rows; +import com.codenameone.examples.reel.domain.Progress; +import com.codenameone.examples.reel.domain.Title; +import com.codenameone.examples.reel.domain.TitleRating; +import com.codenameone.examples.reel.repository.ProgressRepository; +import com.codenameone.examples.reel.repository.TitleRatingRepository; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/// Recommendations, from two signals: +/// +/// - **Taste.** Each genre and tag of a title the profile watched counts +/// once; finishing it counts double; rating it adds or subtracts by the +/// stars (a one-star title pushes its genres down). A candidate scores the +/// sum of its genres' and tags' weights. +/// - **Co-watching.** Profiles that watched the same titles as this one -- +/// the titles they also watched score, weighted by how much they overlap. +/// This is item-to-item collaborative filtering at its simplest; it finds +/// what taste alone cannot, such as a course people take after another. +/// +/// Popularity and the average rating break ties, so a new profile with no +/// history still gets a sensible row. Titles the profile finished are left +/// out. +@Component +public class Recommender { + /// How many other profiles' histories one recommendation reads, at most. + static final int NEIGHBOURS = 200; + + private final ProgressRepository progress; + private final TitleRatingRepository ratings; + + public Recommender(ProgressRepository progress, TitleRatingRepository ratings) { + this.progress = progress; + this.ratings = ratings; + } + + public static final class Scored { + public final Title title; + public final double score; + + public Scored(Title title, double score) { + this.title = title; + this.score = score; + } + } + + @Transactional(readOnly = true) + public List<Scored> forProfile(String profileId, List<Title> candidates) { + Map<String, Title> byId = new HashMap<String, Title>(); + for (int iter = 0; iter < candidates.size(); iter++) { + byId.put(candidates.get(iter).getId(), candidates.get(iter)); + } + List<Progress> history = progress.findTop50ByProfileIdOrderByUpdatedAtDesc(profileId); + Map<String, Double> taste = new HashMap<String, Double>(); + Set<String> watched = new HashSet<String>(); + Set<String> finished = new HashSet<String>(); + for (int iter = 0; iter < history.size(); iter++) { + Progress p = history.get(iter); + watched.add(p.getTitleId()); + if (p.isCompleted()) { + finished.add(p.getTitleId()); + } + Title t = byId.get(p.getTitleId()); + if (t != null) { + weigh(taste, t, p.isCompleted() ? 2.0 : 1.0); + } + } + List<TitleRating> rated = ratings.findByProfileId(profileId); + for (int iter = 0; iter < rated.size(); iter++) { + Title t = byId.get(rated.get(iter).getTitleId()); + if (t != null) { + weigh(taste, t, rated.get(iter).getStars() - 3.0); + } + } + Map<String, Double> together = coWatched(profileId, watched); + List<Scored> out = new ArrayList<Scored>(); + for (int iter = 0; iter < candidates.size(); iter++) { + Title t = candidates.get(iter); + if (finished.contains(t.getId()) || watched.contains(t.getId())) { + continue; + } + double s = match(taste, t); + Double co = together.get(t.getId()); + if (co != null) { + s += 2.0 * co.doubleValue(); + } + s += Math.log(1 + t.getViewCount()) / 5.0 + Catalog.average(t) / 5.0; + out.add(new Scored(t, round(s))); + } + sortScored(out); + return out.size() > Catalog.ROW ? new ArrayList<Scored>(out.subList(0, Catalog.ROW)) : out; + } + + /// Titles like `seed`: shared genres and tags, and what its viewers also + /// watched. + @Transactional(readOnly = true) + public List<Scored> similar(Title seed, List<Title> candidates) { + List<Scored> out = new ArrayList<Scored>(); + if (seed == null) { + return out; + } + Map<String, Double> taste = new HashMap<String, Double>(); + weigh(taste, seed, 1.0); + Map<String, Double> together = new HashMap<String, Double>(); + List<Progress> viewers = progress.findByTitleId(seed.getId()); + Set<String> profiles = new HashSet<String>(); + for (int iter = 0; iter < viewers.size() && profiles.size() < NEIGHBOURS; iter++) { + profiles.add(viewers.get(iter).getProfileId()); + } + for (String p : profiles) { + Set<String> theirs = new HashSet<String>(); + List<Progress> rows = progress.findTop50ByProfileIdOrderByUpdatedAtDesc(p); + for (int iter = 0; iter < rows.size(); iter++) { + theirs.add(rows.get(iter).getTitleId()); + } + for (String t : theirs) { + Double d = together.get(t); + together.put(t, Double.valueOf((d == null ? 0 : d.doubleValue()) + 1.0 / profiles.size())); + } + } + for (int iter = 0; iter < candidates.size(); iter++) { + Title t = candidates.get(iter); + if (t.getId().equals(seed.getId())) { + continue; + } + double s = match(taste, t); + Double co = together.get(t.getId()); + if (co != null) { + s += 3.0 * co.doubleValue(); + } + if (s <= 0) { + continue; + } + out.add(new Scored(t, round(s + Catalog.average(t) / 10.0))); + } + sortScored(out); + return out.size() > 12 ? new ArrayList<Scored>(out.subList(0, 12)) : out; + } + + private Map<String, Double> coWatched(String profileId, Set<String> watched) { + Map<String, Double> out = new HashMap<String, Double>(); + if (watched.isEmpty()) { + return out; + } + Map<String, Integer> overlap = new HashMap<String, Integer>(); + for (String titleId : watched) { + List<Progress> rows = progress.findByTitleId(titleId); + for (int iter = 0; iter < rows.size() && overlap.size() < NEIGHBOURS; iter++) { + String other = rows.get(iter).getProfileId(); + if (!other.equals(profileId)) { + Integer n = overlap.get(other); + overlap.put(other, Integer.valueOf(n == null ? 1 : n.intValue() + 1)); + } + } + } + for (Map.Entry<String, Integer> e : overlap.entrySet()) { + double weight = e.getValue().doubleValue() / watched.size(); + List<Progress> rows = progress.findTop50ByProfileIdOrderByUpdatedAtDesc(e.getKey()); + Set<String> counted = new HashSet<String>(); + for (int iter = 0; iter < rows.size(); iter++) { + String t = rows.get(iter).getTitleId(); + if (counted.add(t)) { + Double d = out.get(t); + out.put(t, Double.valueOf((d == null ? 0 : d.doubleValue()) + weight)); + } + } + } + return out; + } + + private static void weigh(Map<String, Double> taste, Title t, double by) { + List<String> keys = Rows.split(t.getGenres()); + List<String> tags = Rows.split(t.getTags()); + for (int iter = 0; iter < tags.size(); iter++) { + keys.add("#" + tags.get(iter)); + } + for (int iter = 0; iter < keys.size(); iter++) { + Double d = taste.get(keys.get(iter)); + taste.put(keys.get(iter), Double.valueOf((d == null ? 0 : d.doubleValue()) + by)); + } + } + + private static double match(Map<String, Double> taste, Title t) { + double s = 0; + List<String> g = Rows.split(t.getGenres()); + for (int iter = 0; iter < g.size(); iter++) { + Double d = taste.get(g.get(iter)); + s += d == null ? 0 : d.doubleValue(); + } + List<String> tags = Rows.split(t.getTags()); + for (int iter = 0; iter < tags.size(); iter++) { + Double d = taste.get("#" + tags.get(iter)); + s += d == null ? 0 : 0.5 * d.doubleValue(); + } + return s; + } + + private static double round(double v) { + return Math.round(v * 100.0) / 100.0; + } + + static void sortScored(List<Scored> list) { + for (int i = 1; i < list.size(); i++) { + Scored s = list.get(i); + int j = i - 1; + while (j >= 0 && list.get(j).score < s.score) { + list.set(j + 1, list.get(j)); + j--; + } + list.set(j + 1, s); + } + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/AdCampaign.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/AdCampaign.java new file mode 100644 index 00000000000..397fa67754b --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/AdCampaign.java @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// An ad campaign, its creative and its budget. +@Entity +public class AdCampaign { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(120)") + private String advertiser = ""; + + @Column(nullable = false, type = "VARCHAR(120)") + private String name = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String creativeUrl = ""; + + @Column(nullable = false) + private int durationSeconds; + + @Column(nullable = false) + private int skippableAfterSeconds; + + @Column(nullable = false, type = "VARCHAR(255)") + private String clickUrl = ""; + + @Column(nullable = false) + private long cpmCents; + + @Column(nullable = false) + private long budgetCents; + + @Column(nullable = false) + private long spentMillicents; + + @Column(nullable = false) + private long impressions; + + @Column(nullable = false) + private long clicks; + + @Column(nullable = false, type = "VARCHAR(255)") + private String genres = ""; + + @Column(nullable = false) + private boolean active; + + @Column(nullable = false) + private long startsAt; + + @Column(nullable = false) + private long endsAt; + + @Column(nullable = false) + private long createdAt; + + public AdCampaign() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getAdvertiser() { + return advertiser; + } + + public void setAdvertiser(String advertiser) { + this.advertiser = advertiser; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getCreativeUrl() { + return creativeUrl; + } + + public void setCreativeUrl(String creativeUrl) { + this.creativeUrl = creativeUrl; + } + + public int getDurationSeconds() { + return durationSeconds; + } + + public void setDurationSeconds(int durationSeconds) { + this.durationSeconds = durationSeconds; + } + + public int getSkippableAfterSeconds() { + return skippableAfterSeconds; + } + + public void setSkippableAfterSeconds(int skippableAfterSeconds) { + this.skippableAfterSeconds = skippableAfterSeconds; + } + + public String getClickUrl() { + return clickUrl; + } + + public void setClickUrl(String clickUrl) { + this.clickUrl = clickUrl; + } + + public long getCpmCents() { + return cpmCents; + } + + public void setCpmCents(long cpmCents) { + this.cpmCents = cpmCents; + } + + public long getBudgetCents() { + return budgetCents; + } + + public void setBudgetCents(long budgetCents) { + this.budgetCents = budgetCents; + } + + public long getSpentMillicents() { + return spentMillicents; + } + + public void setSpentMillicents(long spentMillicents) { + this.spentMillicents = spentMillicents; + } + + public long getImpressions() { + return impressions; + } + + public void setImpressions(long impressions) { + this.impressions = impressions; + } + + public long getClicks() { + return clicks; + } + + public void setClicks(long clicks) { + this.clicks = clicks; + } + + public String getGenres() { + return genres; + } + + public void setGenres(String genres) { + this.genres = genres; + } + + public boolean isActive() { + return active; + } + + public void setActive(boolean active) { + this.active = active; + } + + public long getStartsAt() { + return startsAt; + } + + public void setStartsAt(long startsAt) { + this.startsAt = startsAt; + } + + public long getEndsAt() { + return endsAt; + } + + public void setEndsAt(long endsAt) { + this.endsAt = endsAt; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/AdEvent.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/AdEvent.java new file mode 100644 index 00000000000..d544eff74a2 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/AdEvent.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// An ad tracking event. +@Entity +public class AdEvent { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String campaignId = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String sessionId = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String event = ""; + + @Column(nullable = false) + private long createdAt; + + public AdEvent() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getCampaignId() { + return campaignId; + } + + public void setCampaignId(String campaignId) { + this.campaignId = campaignId; + } + + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getEvent() { + return event; + } + + public void setEvent(String event) { + this.event = event; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Answer.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Answer.java new file mode 100644 index 00000000000..089b2939915 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Answer.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A viewer's answer to an interaction. +@Entity +public class Answer { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String interactionId = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String videoId = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String titleId = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String profileId = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + private String choices = ""; + + @Column(nullable = false) + private boolean correct; + + @Column(nullable = false) + private long createdAt; + + public Answer() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getInteractionId() { + return interactionId; + } + + public void setInteractionId(String interactionId) { + this.interactionId = interactionId; + } + + public String getVideoId() { + return videoId; + } + + public void setVideoId(String videoId) { + this.videoId = videoId; + } + + public String getTitleId() { + return titleId; + } + + public void setTitleId(String titleId) { + this.titleId = titleId; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getProfileId() { + return profileId; + } + + public void setProfileId(String profileId) { + this.profileId = profileId; + } + + public String getChoices() { + return choices; + } + + public void setChoices(String choices) { + this.choices = choices; + } + + public boolean isCorrect() { + return correct; + } + + public void setCorrect(boolean correct) { + this.correct = correct; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/BlockedItem.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/BlockedItem.java new file mode 100644 index 00000000000..1ff5a3c89f4 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/BlockedItem.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A banned frame hash or keyword. +@Entity +public class BlockedItem { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String kind = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String value = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String note = ""; + + @Column(nullable = false) + private long createdAt; + + @Column(nullable = false, type = "VARCHAR(190)") + private String createdBy = ""; + + public BlockedItem() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + public String getNote() { + return note; + } + + public void setNote(String note) { + this.note = note; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } + + public String getCreatedBy() { + return createdBy; + } + + public void setCreatedBy(String createdBy) { + this.createdBy = createdBy; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Certificate.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Certificate.java new file mode 100644 index 00000000000..98b45008a6b --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Certificate.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A course completion certificate. +@Entity +public class Certificate { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String titleId = ""; + + @Column(nullable = false, type = "VARCHAR(120)") + private String holderName = ""; + + @Column(nullable = false) + private int scorePercent; + + @Column(nullable = false) + private long issuedAt; + + @Column(nullable = false, type = "VARCHAR(16)") + private String verifyCode = ""; + + public Certificate() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getTitleId() { + return titleId; + } + + public void setTitleId(String titleId) { + this.titleId = titleId; + } + + public String getHolderName() { + return holderName; + } + + public void setHolderName(String holderName) { + this.holderName = holderName; + } + + public int getScorePercent() { + return scorePercent; + } + + public void setScorePercent(int scorePercent) { + this.scorePercent = scorePercent; + } + + public long getIssuedAt() { + return issuedAt; + } + + public void setIssuedAt(long issuedAt) { + this.issuedAt = issuedAt; + } + + public String getVerifyCode() { + return verifyCode; + } + + public void setVerifyCode(String verifyCode) { + this.verifyCode = verifyCode; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Chapter.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Chapter.java new file mode 100644 index 00000000000..31234d242f9 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Chapter.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A named point in a video. +@Entity +public class Chapter { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String videoId = ""; + + @Column(nullable = false) + private int startSeconds; + + @Column(nullable = false, type = "VARCHAR(120)") + private String title = ""; + + public Chapter() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getVideoId() { + return videoId; + } + + public void setVideoId(String videoId) { + this.videoId = videoId; + } + + public int getStartSeconds() { + return startSeconds; + } + + public void setStartSeconds(int startSeconds) { + this.startSeconds = startSeconds; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/ClosedAccount.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/ClosedAccount.java new file mode 100644 index 00000000000..a6e361b5efa --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/ClosedAccount.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// What is left of an account that was closed: the hash of its e-mail, kept so +/// that the address cannot be registered again. +@Entity +public class ClosedAccount { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(64)") + private String usernameHash = ""; + + @Column(nullable = false) + private long closedAt; + + public ClosedAccount() { + } + + public String getUsernameHash() { + return usernameHash; + } + + public void setUsernameHash(String usernameHash) { + this.usernameHash = usernameHash; + } + + public long getClosedAt() { + return closedAt; + } + + public void setClosedAt(long closedAt) { + this.closedAt = closedAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Coupon.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Coupon.java new file mode 100644 index 00000000000..f529831c763 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Coupon.java @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A percent off for a number of billing periods. +@Entity +public class Coupon { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String code = ""; + + @Column(nullable = false) + private int percentOff; + + @Column(nullable = false) + private int periods; + + @Column(nullable = false) + private int maxRedemptions; + + @Column(nullable = false) + private int redemptions; + + @Column(nullable = false) + private long expiresAt; + + @Column(nullable = false) + private boolean active; + + @Column(nullable = false) + private long createdAt; + + public Coupon() { + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public int getPercentOff() { + return percentOff; + } + + public void setPercentOff(int percentOff) { + this.percentOff = percentOff; + } + + public int getPeriods() { + return periods; + } + + public void setPeriods(int periods) { + this.periods = periods; + } + + public int getMaxRedemptions() { + return maxRedemptions; + } + + public void setMaxRedemptions(int maxRedemptions) { + this.maxRedemptions = maxRedemptions; + } + + public int getRedemptions() { + return redemptions; + } + + public void setRedemptions(int redemptions) { + this.redemptions = redemptions; + } + + public long getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(long expiresAt) { + this.expiresAt = expiresAt; + } + + public boolean isActive() { + return active; + } + + public void setActive(boolean active) { + this.active = active; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Device.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Device.java new file mode 100644 index 00000000000..0788fc58371 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Device.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A device that signed in to an account, keyed by the id the app made for itself. +@Entity +public class Device { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(64)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(80)") + private String name = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String platform = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + private String lastIp = ""; + + @Column(nullable = false, type = "VARCHAR(8)") + private String lastCountry = ""; + + @Column(nullable = false) + private long lastSeenAt; + + @Column(nullable = false) + private long createdAt; + + @Column(nullable = false) + private long revokedAt; + + public Device() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getPlatform() { + return platform; + } + + public void setPlatform(String platform) { + this.platform = platform; + } + + public String getLastIp() { + return lastIp; + } + + public void setLastIp(String lastIp) { + this.lastIp = lastIp; + } + + public String getLastCountry() { + return lastCountry; + } + + public void setLastCountry(String lastCountry) { + this.lastCountry = lastCountry; + } + + public long getLastSeenAt() { + return lastSeenAt; + } + + public void setLastSeenAt(long lastSeenAt) { + this.lastSeenAt = lastSeenAt; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } + + public long getRevokedAt() { + return revokedAt; + } + + public void setRevokedAt(long revokedAt) { + this.revokedAt = revokedAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Download.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Download.java new file mode 100644 index 00000000000..79fb457c448 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Download.java @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A title licensed for offline play on one device. +@Entity +public class Download { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + private String deviceId = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String titleId = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String videoId = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String scheme = ""; + + @Column(nullable = false) + private long licenseExpiresAt; + + @Column(nullable = false) + private long createdAt; + + public Download() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getDeviceId() { + return deviceId; + } + + public void setDeviceId(String deviceId) { + this.deviceId = deviceId; + } + + public String getTitleId() { + return titleId; + } + + public void setTitleId(String titleId) { + this.titleId = titleId; + } + + public String getVideoId() { + return videoId; + } + + public void setVideoId(String videoId) { + this.videoId = videoId; + } + + public String getScheme() { + return scheme; + } + + public void setScheme(String scheme) { + this.scheme = scheme; + } + + public long getLicenseExpiresAt() { + return licenseExpiresAt; + } + + public void setLicenseExpiresAt(long licenseExpiresAt) { + this.licenseExpiresAt = licenseExpiresAt; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/FrameHash.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/FrameHash.java new file mode 100644 index 00000000000..021fe5f0c62 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/FrameHash.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A perceptual hash of one sampled frame, for matching against the blocklist. +@Entity +public class FrameHash { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String videoId = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String hash = ""; + + @Column(nullable = false) + private int position; + + public FrameHash() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getVideoId() { + return videoId; + } + + public void setVideoId(String videoId) { + this.videoId = videoId; + } + + public String getHash() { + return hash; + } + + public void setHash(String hash) { + this.hash = hash; + } + + public int getPosition() { + return position; + } + + public void setPosition(int position) { + this.position = position; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Genre.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Genre.java new file mode 100644 index 00000000000..950e8607186 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Genre.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A genre, or an academy subject. +@Entity +public class Genre { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(60)") + private String name = ""; + + @Column(nullable = false) + private int position; + + public Genre() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public int getPosition() { + return position; + } + + public void setPosition(int position) { + this.position = position; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Household.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Household.java new file mode 100644 index 00000000000..b44fb9fe8a3 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Household.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A viewer profile inside an account: its own list, history and rating limit. +@Entity +public class Household { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(60)") + private String name = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String avatar = ""; + + @Column(nullable = false) + private boolean kids; + + @Column(nullable = false, type = "VARCHAR(8)") + private String maxRating = ""; + + @Column(nullable = false, type = "VARCHAR(128)") + private String pinHash = ""; + + @Column(nullable = false, type = "VARCHAR(8)") + private String language = ""; + + @Column(nullable = false) + private long createdAt; + + public Household() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getAvatar() { + return avatar; + } + + public void setAvatar(String avatar) { + this.avatar = avatar; + } + + public boolean isKids() { + return kids; + } + + public void setKids(boolean kids) { + this.kids = kids; + } + + public String getMaxRating() { + return maxRating; + } + + public void setMaxRating(String maxRating) { + this.maxRating = maxRating; + } + + public String getPinHash() { + return pinHash; + } + + public void setPinHash(String pinHash) { + this.pinHash = pinHash; + } + + public String getLanguage() { + return language; + } + + public void setLanguage(String language) { + this.language = language; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Interaction.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Interaction.java new file mode 100644 index 00000000000..3a5b3046e5a --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Interaction.java @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A pause point in a video: a quiz, a poll, a branch or a note. `options` are one per line. +@Entity +public class Interaction { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String videoId = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String kind = ""; + + @Column(nullable = false) + private int atSeconds; + + @Column(nullable = false, type = "VARCHAR(500)") + private String prompt = ""; + + @Column(nullable = false, type = "TEXT") + private String options = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + private String answers = ""; + + @Column(nullable = false, type = "VARCHAR(500)") + private String explanation = ""; + + @Column(nullable = false) + private boolean required; + + @Column(nullable = false, type = "VARCHAR(255)") + private String branchTargets = ""; + + @Column(nullable = false) + private int position; + + public Interaction() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getVideoId() { + return videoId; + } + + public void setVideoId(String videoId) { + this.videoId = videoId; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public int getAtSeconds() { + return atSeconds; + } + + public void setAtSeconds(int atSeconds) { + this.atSeconds = atSeconds; + } + + public String getPrompt() { + return prompt; + } + + public void setPrompt(String prompt) { + this.prompt = prompt; + } + + public String getOptions() { + return options; + } + + public void setOptions(String options) { + this.options = options; + } + + public String getAnswers() { + return answers; + } + + public void setAnswers(String answers) { + this.answers = answers; + } + + public String getExplanation() { + return explanation; + } + + public void setExplanation(String explanation) { + this.explanation = explanation; + } + + public boolean isRequired() { + return required; + } + + public void setRequired(boolean required) { + this.required = required; + } + + public String getBranchTargets() { + return branchTargets; + } + + public void setBranchTargets(String branchTargets) { + this.branchTargets = branchTargets; + } + + public int getPosition() { + return position; + } + + public void setPosition(int position) { + this.position = position; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Invoice.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Invoice.java new file mode 100644 index 00000000000..40d09221d0a --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Invoice.java @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A billing record: a period, a rental, a proration, a refund. +@Entity +public class Invoice { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String kind = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String description = ""; + + @Column(nullable = false) + private long amountCents; + + @Column(nullable = false, type = "VARCHAR(3)") + private String currency = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String status = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + private String providerRef = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String titleId = ""; + + @Column(nullable = false) + private long createdAt; + + public Invoice() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public long getAmountCents() { + return amountCents; + } + + public void setAmountCents(long amountCents) { + this.amountCents = amountCents; + } + + public String getCurrency() { + return currency; + } + + public void setCurrency(String currency) { + this.currency = currency; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getProviderRef() { + return providerRef; + } + + public void setProviderRef(String providerRef) { + this.providerRef = providerRef; + } + + public String getTitleId() { + return titleId; + } + + public void setTitleId(String titleId) { + this.titleId = titleId; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Job.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Job.java new file mode 100644 index 00000000000..7f3b1ea8036 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Job.java @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A packaging job a worker claims, with the lease it holds it under. +@Entity +public class Job { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String videoId = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String kind = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String status = ""; + + @Column(nullable = false) + private int progressPercent; + + @Column(nullable = false, type = "VARCHAR(64)") + private String worker = ""; + + @Column(nullable = false, type = "VARCHAR(500)") + private String error = ""; + + @Column(nullable = false) + private int attempts; + + @Column(nullable = false) + private long createdAt; + + @Column(nullable = false) + private long startedAt; + + @Column(nullable = false) + private long finishedAt; + + @Column(nullable = false) + private long leaseUntil; + + public Job() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getVideoId() { + return videoId; + } + + public void setVideoId(String videoId) { + this.videoId = videoId; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public int getProgressPercent() { + return progressPercent; + } + + public void setProgressPercent(int progressPercent) { + this.progressPercent = progressPercent; + } + + public String getWorker() { + return worker; + } + + public void setWorker(String worker) { + this.worker = worker; + } + + public String getError() { + return error; + } + + public void setError(String error) { + this.error = error; + } + + public int getAttempts() { + return attempts; + } + + public void setAttempts(int attempts) { + this.attempts = attempts; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } + + public long getStartedAt() { + return startedAt; + } + + public void setStartedAt(long startedAt) { + this.startedAt = startedAt; + } + + public long getFinishedAt() { + return finishedAt; + } + + public void setFinishedAt(long finishedAt) { + this.finishedAt = finishedAt; + } + + public long getLeaseUntil() { + return leaseUntil; + } + + public void setLeaseUntil(long leaseUntil) { + this.leaseUntil = leaseUntil; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/ListEntry.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/ListEntry.java new file mode 100644 index 00000000000..d96677699e5 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/ListEntry.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A title on a profile's list. The id is the profile id and the title id. +@Entity +public class ListEntry { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(80)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String profileId = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String titleId = ""; + + @Column(nullable = false) + private long addedAt; + + public ListEntry() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getProfileId() { + return profileId; + } + + public void setProfileId(String profileId) { + this.profileId = profileId; + } + + public String getTitleId() { + return titleId; + } + + public void setTitleId(String titleId) { + this.titleId = titleId; + } + + public long getAddedAt() { + return addedAt; + } + + public void setAddedAt(long addedAt) { + this.addedAt = addedAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/ModerationEvent.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/ModerationEvent.java new file mode 100644 index 00000000000..a6e2bca2351 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/ModerationEvent.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// One thing an admin did to an account, and why. Written once, never changed. +@Entity +public class ModerationEvent { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false) + private long createdAt; + + @Column(nullable = false, type = "VARCHAR(190)") + private String actor = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String action = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String reason = ""; + + public ModerationEvent() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } + + public String getActor() { + return actor; + } + + public void setActor(String actor) { + this.actor = actor; + } + + public String getAction() { + return action; + } + + public void setAction(String action) { + this.action = action; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/ModerationItem.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/ModerationItem.java new file mode 100644 index 00000000000..c13fbbb7be3 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/ModerationItem.java @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// Something waiting for a moderator: a title, a report or an appeal. +@Entity +public class ModerationItem { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String kind = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String subjectId = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String owner = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + private String reason = ""; + + @Column(nullable = false, type = "VARCHAR(1000)") + private String detail = ""; + + @Column(nullable = false, type = "VARCHAR(500)") + private String signals = ""; + + @Column(nullable = false) + private double score; + + @Column(nullable = false, type = "VARCHAR(16)") + private String status = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String decidedBy = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String decision = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String note = ""; + + @Column(nullable = false) + private long createdAt; + + @Column(nullable = false) + private long decidedAt; + + public ModerationItem() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public String getSubjectId() { + return subjectId; + } + + public void setSubjectId(String subjectId) { + this.subjectId = subjectId; + } + + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + + public String getDetail() { + return detail; + } + + public void setDetail(String detail) { + this.detail = detail; + } + + public String getSignals() { + return signals; + } + + public void setSignals(String signals) { + this.signals = signals; + } + + public double getScore() { + return score; + } + + public void setScore(double score) { + this.score = score; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getDecidedBy() { + return decidedBy; + } + + public void setDecidedBy(String decidedBy) { + this.decidedBy = decidedBy; + } + + public String getDecision() { + return decision; + } + + public void setDecision(String decision) { + this.decision = decision; + } + + public String getNote() { + return note; + } + + public void setNote(String note) { + this.note = note; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } + + public long getDecidedAt() { + return decidedAt; + } + + public void setDecidedAt(long decidedAt) { + this.decidedAt = decidedAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/PaymentCustomer.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/PaymentCustomer.java new file mode 100644 index 00000000000..e9d77b9a7aa --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/PaymentCustomer.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// The payment provider's name for an account. +@Entity +public class PaymentCustomer { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + private String reference = ""; + + public PaymentCustomer() { + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getReference() { + return reference; + } + + public void setReference(String reference) { + this.reference = reference; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/PaymentMethod.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/PaymentMethod.java new file mode 100644 index 00000000000..f09db9f4e4a --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/PaymentMethod.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A saved card: what the provider calls it and what to show for it. The +/// number itself is never here. +@Entity +public class PaymentMethod { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(24)") + private String brand = ""; + + @Column(nullable = false, type = "VARCHAR(4)") + private String last4 = ""; + + @Column(nullable = false) + private int expMonth; + + @Column(nullable = false) + private int expYear; + + @Column(nullable = false, type = "VARCHAR(64)") + private String token = ""; + + @Column(nullable = false) + private boolean preferred; + + @Column(nullable = false) + private long createdAt; + + public PaymentMethod() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getBrand() { + return brand; + } + + public void setBrand(String brand) { + this.brand = brand; + } + + public String getLast4() { + return last4; + } + + public void setLast4(String last4) { + this.last4 = last4; + } + + public int getExpMonth() { + return expMonth; + } + + public void setExpMonth(int expMonth) { + this.expMonth = expMonth; + } + + public int getExpYear() { + return expYear; + } + + public void setExpYear(int expYear) { + this.expYear = expYear; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public boolean isPreferred() { + return preferred; + } + + public void setPreferred(boolean preferred) { + this.preferred = preferred; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/PaymentSetup.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/PaymentSetup.java new file mode 100644 index 00000000000..22d83425044 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/PaymentSetup.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A card being added on the provider's page, until the browser comes back. +@Entity +public class PaymentSetup { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String reference = ""; + + @Column(nullable = false) + private long createdAt; + + @Column(nullable = false) + private boolean completed; + + public PaymentSetup() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getReference() { + return reference; + } + + public void setReference(String reference) { + this.reference = reference; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } + + public boolean isCompleted() { + return completed; + } + + public void setCompleted(boolean completed) { + this.completed = completed; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Plan.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Plan.java new file mode 100644 index 00000000000..b985cd05bab --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Plan.java @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A subscription plan. +@Entity +public class Plan { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String code = ""; + + @Column(nullable = false, type = "VARCHAR(60)") + private String name = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String description = ""; + + @Column(nullable = false) + private long priceCents; + + @Column(nullable = false, type = "VARCHAR(3)") + private String currency = ""; + + @Column(nullable = false, type = "VARCHAR(8)") + private String billingInterval = ""; + + @Column(nullable = false) + private int maxStreams; + + @Column(nullable = false, type = "VARCHAR(8)") + private String maxQuality = ""; + + @Column(nullable = false) + private boolean adSupported; + + @Column(nullable = false) + private boolean downloads; + + @Column(nullable = false) + private int trialDays; + + @Column(nullable = false) + private boolean active; + + @Column(nullable = false) + private int position; + + public Plan() { + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public long getPriceCents() { + return priceCents; + } + + public void setPriceCents(long priceCents) { + this.priceCents = priceCents; + } + + public String getCurrency() { + return currency; + } + + public void setCurrency(String currency) { + this.currency = currency; + } + + public String getBillingInterval() { + return billingInterval; + } + + public void setBillingInterval(String billingInterval) { + this.billingInterval = billingInterval; + } + + public int getMaxStreams() { + return maxStreams; + } + + public void setMaxStreams(int maxStreams) { + this.maxStreams = maxStreams; + } + + public String getMaxQuality() { + return maxQuality; + } + + public void setMaxQuality(String maxQuality) { + this.maxQuality = maxQuality; + } + + public boolean isAdSupported() { + return adSupported; + } + + public void setAdSupported(boolean adSupported) { + this.adSupported = adSupported; + } + + public boolean isDownloads() { + return downloads; + } + + public void setDownloads(boolean downloads) { + this.downloads = downloads; + } + + public int getTrialDays() { + return trialDays; + } + + public void setTrialDays(int trialDays) { + this.trialDays = trialDays; + } + + public boolean isActive() { + return active; + } + + public void setActive(boolean active) { + this.active = active; + } + + public int getPosition() { + return position; + } + + public void setPosition(int position) { + this.position = position; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Playback.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Playback.java new file mode 100644 index 00000000000..149c1ed950a --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Playback.java @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A stream: who plays what where, and the lease its heartbeats renew. +@Entity +public class Playback { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String profileId = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String titleId = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String videoId = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + private String deviceId = ""; + + @Column(nullable = false, type = "VARCHAR(80)") + private String deviceName = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + private String ip = ""; + + @Column(nullable = false, type = "VARCHAR(8)") + private String country = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String scheme = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + private String tokenHash = ""; + + @Column(nullable = false) + private long startedAt; + + @Column(nullable = false) + private long lastHeartbeatAt; + + @Column(nullable = false) + private int positionSeconds; + + @Column(nullable = false) + private long endedAt; + + @Column(nullable = false, type = "VARCHAR(32)") + private String endedReason = ""; + + public Playback() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getProfileId() { + return profileId; + } + + public void setProfileId(String profileId) { + this.profileId = profileId; + } + + public String getTitleId() { + return titleId; + } + + public void setTitleId(String titleId) { + this.titleId = titleId; + } + + public String getVideoId() { + return videoId; + } + + public void setVideoId(String videoId) { + this.videoId = videoId; + } + + public String getDeviceId() { + return deviceId; + } + + public void setDeviceId(String deviceId) { + this.deviceId = deviceId; + } + + public String getDeviceName() { + return deviceName; + } + + public void setDeviceName(String deviceName) { + this.deviceName = deviceName; + } + + public String getIp() { + return ip; + } + + public void setIp(String ip) { + this.ip = ip; + } + + public String getCountry() { + return country; + } + + public void setCountry(String country) { + this.country = country; + } + + public String getScheme() { + return scheme; + } + + public void setScheme(String scheme) { + this.scheme = scheme; + } + + public String getTokenHash() { + return tokenHash; + } + + public void setTokenHash(String tokenHash) { + this.tokenHash = tokenHash; + } + + public long getStartedAt() { + return startedAt; + } + + public void setStartedAt(long startedAt) { + this.startedAt = startedAt; + } + + public long getLastHeartbeatAt() { + return lastHeartbeatAt; + } + + public void setLastHeartbeatAt(long lastHeartbeatAt) { + this.lastHeartbeatAt = lastHeartbeatAt; + } + + public int getPositionSeconds() { + return positionSeconds; + } + + public void setPositionSeconds(int positionSeconds) { + this.positionSeconds = positionSeconds; + } + + public long getEndedAt() { + return endedAt; + } + + public void setEndedAt(long endedAt) { + this.endedAt = endedAt; + } + + public String getEndedReason() { + return endedReason; + } + + public void setEndedReason(String endedReason) { + this.endedReason = endedReason; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Profile.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Profile.java new file mode 100644 index 00000000000..8909f6fa48c --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Profile.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// What Reel knows about an account beyond its sign-in, keyed by the same username. +@Entity +public class Profile { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(120)") + private String displayName = ""; + + @Column(nullable = false) + private long createdAt; + + @Column(nullable = false, type = "VARCHAR(8)") + private String language = ""; + + @Column(nullable = false) + private boolean flagged; + + @Column(nullable = false, type = "VARCHAR(255)") + private String flagReason = ""; + + @Column(nullable = false) + private long lastSeenAt; + + public Profile() { + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } + + public String getLanguage() { + return language; + } + + public void setLanguage(String language) { + this.language = language; + } + + public boolean isFlagged() { + return flagged; + } + + public void setFlagged(boolean flagged) { + this.flagged = flagged; + } + + public String getFlagReason() { + return flagReason; + } + + public void setFlagReason(String flagReason) { + this.flagReason = flagReason; + } + + public long getLastSeenAt() { + return lastSeenAt; + } + + public void setLastSeenAt(long lastSeenAt) { + this.lastSeenAt = lastSeenAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Progress.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Progress.java new file mode 100644 index 00000000000..9e6dce6a87e --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Progress.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// Where a profile is in a video. The id is the profile id and the video id. +@Entity +public class Progress { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(80)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String profileId = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String titleId = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String videoId = ""; + + @Column(nullable = false) + private int positionSeconds; + + @Column(nullable = false) + private int durationSeconds; + + @Column(nullable = false) + private boolean completed; + + @Column(nullable = false) + private long updatedAt; + + public Progress() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getProfileId() { + return profileId; + } + + public void setProfileId(String profileId) { + this.profileId = profileId; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getTitleId() { + return titleId; + } + + public void setTitleId(String titleId) { + this.titleId = titleId; + } + + public String getVideoId() { + return videoId; + } + + public void setVideoId(String videoId) { + this.videoId = videoId; + } + + public int getPositionSeconds() { + return positionSeconds; + } + + public void setPositionSeconds(int positionSeconds) { + this.positionSeconds = positionSeconds; + } + + public int getDurationSeconds() { + return durationSeconds; + } + + public void setDurationSeconds(int durationSeconds) { + this.durationSeconds = durationSeconds; + } + + public boolean isCompleted() { + return completed; + } + + public void setCompleted(boolean completed) { + this.completed = completed; + } + + public long getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(long updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/PushDevice.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/PushDevice.java new file mode 100644 index 00000000000..4ca0bbe3f10 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/PushDevice.java @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A device an account is sent push notifications on. +/// +/// Keyed by the hash of its provider and token, so a token is on one account at +/// a time: the phone belongs to whoever signed in on it last. +@Entity +public class PushDevice { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(64)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + /// `fcm`, `apns`, `huawei`, `wns` or `web`. + @Column(nullable = false, type = "VARCHAR(16)") + private String provider = ""; + + @Column(nullable = false, type = "VARCHAR(1024)") + private String token = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String platform = ""; + + @Column(nullable = false) + private long updatedAt; + + public PushDevice() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getProvider() { + return provider; + } + + public void setProvider(String provider) { + this.provider = provider; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getPlatform() { + return platform; + } + + public void setPlatform(String platform) { + this.platform = platform; + } + + public long getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(long updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Rental.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Rental.java new file mode 100644 index 00000000000..8c17dfe79d1 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Rental.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A title an account may play until the rental expires. +@Entity +public class Rental { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String titleId = ""; + + @Column(nullable = false) + private long priceCents; + + @Column(nullable = false, type = "VARCHAR(3)") + private String currency = ""; + + @Column(nullable = false) + private long createdAt; + + @Column(nullable = false) + private long expiresAt; + + @Column(nullable = false) + private long startedAt; + + @Column(nullable = false, type = "VARCHAR(32)") + private String invoiceId = ""; + + public Rental() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getTitleId() { + return titleId; + } + + public void setTitleId(String titleId) { + this.titleId = titleId; + } + + public long getPriceCents() { + return priceCents; + } + + public void setPriceCents(long priceCents) { + this.priceCents = priceCents; + } + + public String getCurrency() { + return currency; + } + + public void setCurrency(String currency) { + this.currency = currency; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } + + public long getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(long expiresAt) { + this.expiresAt = expiresAt; + } + + public long getStartedAt() { + return startedAt; + } + + public void setStartedAt(long startedAt) { + this.startedAt = startedAt; + } + + public String getInvoiceId() { + return invoiceId; + } + + public void setInvoiceId(String invoiceId) { + this.invoiceId = invoiceId; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Setting.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Setting.java new file mode 100644 index 00000000000..410fe21f827 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Setting.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// One admin-controlled setting: a feature switch, the preset, the branding. +@Entity +public class Setting { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(64)") + private String name = ""; + + @Column(nullable = false, type = "TEXT") + private String value = ""; + + @Column(nullable = false) + private long updatedAt; + + @Column(nullable = false, type = "VARCHAR(190)") + private String updatedBy = ""; + + public Setting() { + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + public long getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(long updatedAt) { + this.updatedAt = updatedAt; + } + + public String getUpdatedBy() { + return updatedBy; + } + + public void setUpdatedBy(String updatedBy) { + this.updatedBy = updatedBy; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/SharingAlert.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/SharingAlert.java new file mode 100644 index 00000000000..73ead4d0002 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/SharingAlert.java @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A sign an account is used beyond its household. +@Entity +public class SharingAlert { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String kind = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String detail = ""; + + @Column(nullable = false) + private int distinctIps; + + @Column(nullable = false) + private int distinctCountries; + + @Column(nullable = false, type = "VARCHAR(16)") + private String status = ""; + + @Column(nullable = false) + private long createdAt; + + @Column(nullable = false, type = "VARCHAR(190)") + private String decidedBy = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String note = ""; + + public SharingAlert() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public String getDetail() { + return detail; + } + + public void setDetail(String detail) { + this.detail = detail; + } + + public int getDistinctIps() { + return distinctIps; + } + + public void setDistinctIps(int distinctIps) { + this.distinctIps = distinctIps; + } + + public int getDistinctCountries() { + return distinctCountries; + } + + public void setDistinctCountries(int distinctCountries) { + this.distinctCountries = distinctCountries; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } + + public String getDecidedBy() { + return decidedBy; + } + + public void setDecidedBy(String decidedBy) { + this.decidedBy = decidedBy; + } + + public String getNote() { + return note; + } + + public void setNote(String note) { + this.note = note; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Subscription.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Subscription.java new file mode 100644 index 00000000000..9c18056a6b6 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Subscription.java @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// An account's subscription. One row per account, kept after it ends. +@Entity +public class Subscription { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String planCode = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String status = ""; + + @Column(nullable = false) + private long startedAt; + + @Column(nullable = false) + private long periodEndsAt; + + @Column(nullable = false) + private boolean cancelAtPeriodEnd; + + @Column(nullable = false, type = "VARCHAR(32)") + private String pendingPlanCode = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String couponCode = ""; + + @Column(nullable = false) + private int couponPeriodsLeft; + + @Column(nullable = false, type = "VARCHAR(32)") + private String paymentMethodId = ""; + + @Column(nullable = false) + private int failedAttempts; + + @Column(nullable = false, type = "VARCHAR(16)") + private String store = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String storeRef = ""; + + @Column(nullable = false) + private long updatedAt; + + public Subscription() { + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPlanCode() { + return planCode; + } + + public void setPlanCode(String planCode) { + this.planCode = planCode; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public long getStartedAt() { + return startedAt; + } + + public void setStartedAt(long startedAt) { + this.startedAt = startedAt; + } + + public long getPeriodEndsAt() { + return periodEndsAt; + } + + public void setPeriodEndsAt(long periodEndsAt) { + this.periodEndsAt = periodEndsAt; + } + + public boolean isCancelAtPeriodEnd() { + return cancelAtPeriodEnd; + } + + public void setCancelAtPeriodEnd(boolean cancelAtPeriodEnd) { + this.cancelAtPeriodEnd = cancelAtPeriodEnd; + } + + public String getPendingPlanCode() { + return pendingPlanCode; + } + + public void setPendingPlanCode(String pendingPlanCode) { + this.pendingPlanCode = pendingPlanCode; + } + + public String getCouponCode() { + return couponCode; + } + + public void setCouponCode(String couponCode) { + this.couponCode = couponCode; + } + + public int getCouponPeriodsLeft() { + return couponPeriodsLeft; + } + + public void setCouponPeriodsLeft(int couponPeriodsLeft) { + this.couponPeriodsLeft = couponPeriodsLeft; + } + + public String getPaymentMethodId() { + return paymentMethodId; + } + + public void setPaymentMethodId(String paymentMethodId) { + this.paymentMethodId = paymentMethodId; + } + + public int getFailedAttempts() { + return failedAttempts; + } + + public void setFailedAttempts(int failedAttempts) { + this.failedAttempts = failedAttempts; + } + + public String getStore() { + return store; + } + + public void setStore(String store) { + this.store = store; + } + + public String getStoreRef() { + return storeRef; + } + + public void setStoreRef(String storeRef) { + this.storeRef = storeRef; + } + + public long getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(long updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Title.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Title.java new file mode 100644 index 00000000000..b5657e87659 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Title.java @@ -0,0 +1,295 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A movie, a series, a course or a creator's video, with its listing and moderation state. +@Entity +public class Title { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String kind = ""; + + @Column(nullable = false, type = "VARCHAR(200)") + private String name = ""; + + @Column(nullable = false, type = "TEXT") + private String synopsis = ""; + + @Column(nullable = false) + private int year; + + @Column(nullable = false, type = "VARCHAR(8)") + private String rating = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String genres = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String tags = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String creator = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String access = ""; + + @Column(nullable = false) + private long rentalPriceCents; + + @Column(nullable = false, type = "VARCHAR(16)") + private String status = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String moderationNote = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String posterUrl = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String backdropUrl = ""; + + @Column(nullable = false) + private boolean certificate; + + @Column(nullable = false) + private int passPercent; + + @Column(nullable = false) + private boolean featured; + + @Column(nullable = false) + private boolean ageRestricted; + + @Column(nullable = false) + private long createdAt; + + @Column(nullable = false) + private long publishedAt; + + @Column(nullable = false) + private long ratingSum; + + @Column(nullable = false) + private int ratingCount; + + @Column(nullable = false) + private long viewCount; + + public Title() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getSynopsis() { + return synopsis; + } + + public void setSynopsis(String synopsis) { + this.synopsis = synopsis; + } + + public int getYear() { + return year; + } + + public void setYear(int year) { + this.year = year; + } + + public String getRating() { + return rating; + } + + public void setRating(String rating) { + this.rating = rating; + } + + public String getGenres() { + return genres; + } + + public void setGenres(String genres) { + this.genres = genres; + } + + public String getTags() { + return tags; + } + + public void setTags(String tags) { + this.tags = tags; + } + + public String getCreator() { + return creator; + } + + public void setCreator(String creator) { + this.creator = creator; + } + + public String getAccess() { + return access; + } + + public void setAccess(String access) { + this.access = access; + } + + public long getRentalPriceCents() { + return rentalPriceCents; + } + + public void setRentalPriceCents(long rentalPriceCents) { + this.rentalPriceCents = rentalPriceCents; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getModerationNote() { + return moderationNote; + } + + public void setModerationNote(String moderationNote) { + this.moderationNote = moderationNote; + } + + public String getPosterUrl() { + return posterUrl; + } + + public void setPosterUrl(String posterUrl) { + this.posterUrl = posterUrl; + } + + public String getBackdropUrl() { + return backdropUrl; + } + + public void setBackdropUrl(String backdropUrl) { + this.backdropUrl = backdropUrl; + } + + public boolean isCertificate() { + return certificate; + } + + public void setCertificate(boolean certificate) { + this.certificate = certificate; + } + + public int getPassPercent() { + return passPercent; + } + + public void setPassPercent(int passPercent) { + this.passPercent = passPercent; + } + + public boolean isFeatured() { + return featured; + } + + public void setFeatured(boolean featured) { + this.featured = featured; + } + + public boolean isAgeRestricted() { + return ageRestricted; + } + + public void setAgeRestricted(boolean ageRestricted) { + this.ageRestricted = ageRestricted; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } + + public long getPublishedAt() { + return publishedAt; + } + + public void setPublishedAt(long publishedAt) { + this.publishedAt = publishedAt; + } + + public long getRatingSum() { + return ratingSum; + } + + public void setRatingSum(long ratingSum) { + this.ratingSum = ratingSum; + } + + public int getRatingCount() { + return ratingCount; + } + + public void setRatingCount(int ratingCount) { + this.ratingCount = ratingCount; + } + + public long getViewCount() { + return viewCount; + } + + public void setViewCount(long viewCount) { + this.viewCount = viewCount; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/TitleRating.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/TitleRating.java new file mode 100644 index 00000000000..611ac801038 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/TitleRating.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A profile's rating of a title. The id is the profile id and the title id. +@Entity +public class TitleRating { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(80)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String profileId = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String titleId = ""; + + @Column(nullable = false) + private int stars; + + @Column(nullable = false) + private long updatedAt; + + public TitleRating() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getProfileId() { + return profileId; + } + + public void setProfileId(String profileId) { + this.profileId = profileId; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getTitleId() { + return titleId; + } + + public void setTitleId(String titleId) { + this.titleId = titleId; + } + + public int getStars() { + return stars; + } + + public void setStars(int stars) { + this.stars = stars; + } + + public long getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(long updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Upload.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Upload.java new file mode 100644 index 00000000000..9efff179148 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Upload.java @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A resumable upload of a video's source file. +@Entity +public class Upload { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String videoId = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(200)") + private String fileName = ""; + + @Column(nullable = false) + private long totalBytes; + + @Column(nullable = false) + private long receivedBytes; + + @Column(nullable = false, type = "VARCHAR(64)") + private String sha256 = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String status = ""; + + @Column(nullable = false) + private long createdAt; + + @Column(nullable = false) + private long updatedAt; + + public Upload() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getVideoId() { + return videoId; + } + + public void setVideoId(String videoId) { + this.videoId = videoId; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + + public long getTotalBytes() { + return totalBytes; + } + + public void setTotalBytes(long totalBytes) { + this.totalBytes = totalBytes; + } + + public long getReceivedBytes() { + return receivedBytes; + } + + public void setReceivedBytes(long receivedBytes) { + this.receivedBytes = receivedBytes; + } + + public String getSha256() { + return sha256; + } + + public void setSha256(String sha256) { + this.sha256 = sha256; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } + + public long getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(long updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Video.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Video.java new file mode 100644 index 00000000000..7c5fce6ab54 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Video.java @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// One playable file of a title: the feature, an episode or lesson, or the trailer. +@Entity +public class Video { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String titleId = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String role = ""; + + @Column(nullable = false) + private int season; + + @Column(nullable = false) + private int number; + + @Column(nullable = false, type = "VARCHAR(200)") + private String name = ""; + + @Column(nullable = false, type = "TEXT") + private String synopsis = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String status = ""; + + @Column(nullable = false) + private int progressPercent; + + @Column(nullable = false, type = "VARCHAR(255)") + private String error = ""; + + @Column(nullable = false) + private int durationSeconds; + + @Column(nullable = false) + private int width; + + @Column(nullable = false) + private int height; + + @Column(nullable = false, type = "VARCHAR(255)") + private String renditions = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + private String schemes = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String thumbnailUrl = ""; + + @Column(nullable = false) + private long createdAt; + + public Video() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getTitleId() { + return titleId; + } + + public void setTitleId(String titleId) { + this.titleId = titleId; + } + + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } + + public int getSeason() { + return season; + } + + public void setSeason(int season) { + this.season = season; + } + + public int getNumber() { + return number; + } + + public void setNumber(int number) { + this.number = number; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getSynopsis() { + return synopsis; + } + + public void setSynopsis(String synopsis) { + this.synopsis = synopsis; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public int getProgressPercent() { + return progressPercent; + } + + public void setProgressPercent(int progressPercent) { + this.progressPercent = progressPercent; + } + + public String getError() { + return error; + } + + public void setError(String error) { + this.error = error; + } + + public int getDurationSeconds() { + return durationSeconds; + } + + public void setDurationSeconds(int durationSeconds) { + this.durationSeconds = durationSeconds; + } + + public int getWidth() { + return width; + } + + public void setWidth(int width) { + this.width = width; + } + + public int getHeight() { + return height; + } + + public void setHeight(int height) { + this.height = height; + } + + public String getRenditions() { + return renditions; + } + + public void setRenditions(String renditions) { + this.renditions = renditions; + } + + public String getSchemes() { + return schemes; + } + + public void setSchemes(String schemes) { + this.schemes = schemes; + } + + public String getThumbnailUrl() { + return thumbnailUrl; + } + + public void setThumbnailUrl(String thumbnailUrl) { + this.thumbnailUrl = thumbnailUrl; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/VideoKey.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/VideoKey.java new file mode 100644 index 00000000000..029b117d53a --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/VideoKey.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// The content key a video is encrypted with. Never written to the media directory. +@Entity +public class VideoKey { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String videoId = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String keyId = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String keyHex = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String ivHex = ""; + + @Column(nullable = false) + private long createdAt; + + public VideoKey() { + } + + public String getVideoId() { + return videoId; + } + + public void setVideoId(String videoId) { + this.videoId = videoId; + } + + public String getKeyId() { + return keyId; + } + + public void setKeyId(String keyId) { + this.keyId = keyId; + } + + public String getKeyHex() { + return keyHex; + } + + public void setKeyHex(String keyHex) { + this.keyHex = keyHex; + } + + public String getIvHex() { + return ivHex; + } + + public void setIvHex(String ivHex) { + this.ivHex = ivHex; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/learning/CertificatePage.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/learning/CertificatePage.java new file mode 100644 index 00000000000..44794072a66 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/learning/CertificatePage.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.learning; + +import com.codename1.backend.HttpServer; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.GetMapping; +import com.codename1.backend.annotations.PathVariable; +import com.codename1.backend.annotations.RestController; +import com.codenameone.examples.reel.api.CertificateDto; +import com.codenameone.examples.reel.settings.Settings; + +import java.text.SimpleDateFormat; +import java.util.Date; + +/// The printable certificate page at `/verify/{code}`: what the code on a +/// certificate leads to, and what the app opens to print or share one. No +/// sign-in: the code is the credential, and it proves only that the named +/// person passed the named course. +@RestController +public class CertificatePage { + private final Learning learning; + private final Settings settings; + + public CertificatePage(Learning learning, Settings settings) { + this.learning = learning; + this.settings = settings; + } + + @GetMapping("/verify/{code}") + public HttpServer.Response page(@PathVariable("code") String code) throws Exception { + CertificateDto c; + try { + c = learning.verify(code); + } catch (ResponseStatusException missing) { + return html(404, "<h1>Not found</h1><p>No certificate has the code " + escape(code) + ".</p>"); + } + String service = escape(settings.branding().serviceName()); + String date = new SimpleDateFormat("yyyy-MM-dd").format(new Date(c.issuedAt())); + return html(200, "<div class=\"cert\"><p class=\"by\">" + service + " certifies that</p><h1>" + + escape(c.holderName()) + "</h1><p>completed</p><h2>" + escape(c.titleName()) + "</h2><p>with a score of " + + c.scorePercent() + "% on " + date + "</p><p class=\"code\">Verification code " + + escape(c.verifyCode()) + "</p></div>"); + } + + private static HttpServer.Response html(int status, String body) throws Exception { + String page = "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">" + + "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>Certificate" + + "" + body + + ""; + return new HttpServer.Response(status, "text/html; charset=utf-8", page.getBytes("UTF-8")); + } + + static String escape(String s) { + if (s == null) { + return ""; + } + StringBuilder out = new StringBuilder(s.length()); + for (int iter = 0; iter < s.length(); iter++) { + char ch = s.charAt(iter); + switch (ch) { + case '<': out.append("<"); break; + case '>': out.append(">"); break; + case '&': out.append("&"); break; + case '"': out.append("""); break; + case '\'': out.append("'"); break; + default: out.append(ch); + } + } + return out.toString(); + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/learning/Learning.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/learning/Learning.java new file mode 100644 index 00000000000..715ae369b05 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/learning/Learning.java @@ -0,0 +1,325 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.reel.learning; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.reel.Ids; +import com.codenameone.examples.reel.Kinds; +import com.codenameone.examples.reel.Rows; +import com.codenameone.examples.reel.account.Accounts; +import com.codenameone.examples.reel.account.Households; +import com.codenameone.examples.reel.api.AnswerDto; +import com.codenameone.examples.reel.api.AnswerResultDto; +import com.codenameone.examples.reel.api.CertificateDto; +import com.codenameone.examples.reel.api.CourseProgressDto; +import com.codenameone.examples.reel.api.Features; +import com.codenameone.examples.reel.domain.Answer; +import com.codenameone.examples.reel.domain.Certificate; +import com.codenameone.examples.reel.domain.Household; +import com.codenameone.examples.reel.domain.Interaction; +import com.codenameone.examples.reel.domain.Playback; +import com.codenameone.examples.reel.domain.Progress; +import com.codenameone.examples.reel.domain.Title; +import com.codenameone.examples.reel.domain.Video; +import com.codenameone.examples.reel.repository.AnswerRepository; +import com.codenameone.examples.reel.repository.CertificateRepository; +import com.codenameone.examples.reel.repository.InteractionRepository; +import com.codenameone.examples.reel.repository.PlaybackRepository; +import com.codenameone.examples.reel.repository.ProgressRepository; +import com.codenameone.examples.reel.repository.TitleRepository; +import com.codenameone.examples.reel.repository.VideoRepository; +import com.codenameone.examples.reel.settings.Settings; +import com.codename1.backend.Crypto; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/// The academy: answers to quizzes, polls and branches, course progress and +/// certificates. +/// +/// Answers are checked here, never in the app: the player receives the +/// questions without their answers ([com.codenameone.examples.reel.playback.Playbacks]). +/// A viewer's latest answer to a question is the one that counts, so a quiz +/// can be retaken. A course's score is the share of its quiz questions whose +/// latest answer is right; a certificate is issued once every lesson is +/// complete and the score reaches the course's pass mark. Each certificate has +/// a short public code anyone can check at `/verify/{code}`. +@Component +public class Learning { + public static final String QUIZ = "QUIZ"; + public static final String POLL = "POLL"; + public static final String BRANCH = "BRANCH"; + /// Unambiguous characters for a verification code. + private static final String CODE = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + + private final AnswerRepository answers; + private final InteractionRepository interactions; + private final VideoRepository videos; + private final TitleRepository titles; + private final ProgressRepository progress; + private final CertificateRepository certificates; + private final PlaybackRepository sessions; + private final Households households; + private final Accounts accounts; + private final Settings settings; + + public Learning(AnswerRepository answers, InteractionRepository interactions, VideoRepository videos, + TitleRepository titles, ProgressRepository progress, CertificateRepository certificates, + PlaybackRepository sessions, Households households, Accounts accounts, Settings settings) { + this.answers = answers; + this.interactions = interactions; + this.videos = videos; + this.titles = titles; + this.progress = progress; + this.certificates = certificates; + this.sessions = sessions; + this.households = households; + this.accounts = accounts; + this.settings = settings; + } + + @Transactional(rollbackFor = IOException.class) + public AnswerResultDto answer(String username, AnswerDto dto) throws IOException { + settings.require(Features.INTERACTIVE); + Interaction q = dto == null || dto.interactionId() == null ? null : interactions.findById(dto.interactionId()); + if (q == null) { + throw new ResponseStatusException(404, "No such question"); + } + Video video = videos.findById(q.getVideoId()); + Title title = video == null ? null : titles.findById(video.getTitleId()); + if (title == null) { + throw new ResponseStatusException(404, "No such question"); + } + // The answer is tied to the profile of the session it was given in, so + // a household's profiles keep separate scores. + // It also proves the caller was entitled to the video: a question is + // only answered from inside a playback of it. + Playback s = dto.sessionId() == null ? null : sessions.findById(dto.sessionId()); + if (s == null || !s.getUsername().equals(username) || !s.getVideoId().equals(video.getId())) { + throw new ResponseStatusException(404, "No such session"); + } + String profileId = s.getProfileId(); + List options = Rows.splitLines(q.getOptions()); + List chosen = dto.choices() == null ? new ArrayList() : dto.choices(); + if (chosen.isEmpty()) { + throw new ResponseStatusException(400, "Choose an answer"); + } + Set picked = new HashSet(); + for (int iter = 0; iter < chosen.size(); iter++) { + Integer c = chosen.get(iter); + if (c == null || c.intValue() < 0 || c.intValue() >= options.size()) { + throw new ResponseStatusException(400, "That option does not exist"); + } + picked.add(c); + } + if ((POLL.equals(q.getKind()) || BRANCH.equals(q.getKind())) && picked.size() != 1) { + throw new ResponseStatusException(400, "Choose one option"); + } + Set right = new HashSet(Rows.ints(q.getAnswers())); + boolean correct = QUIZ.equals(q.getKind()) ? right.equals(picked) : true; + + // A quiz answer is final. Every answer is told whether it was right, + // so a quiz that took another would be passed by trying each option + // in turn -- and the certificate would say nothing. A poll or a + // branch can be changed. + List previous = answers.findByUsernameAndInteractionId(username, q.getId()); + for (int iter = 0; iter < previous.size(); iter++) { + if (previous.get(iter).getProfileId().equals(profileId)) { + if (QUIZ.equals(q.getKind())) { + throw new ResponseStatusException(409, "This question was already answered"); + } + answers.deleteById(previous.get(iter).getId()); + } + } + Answer row = new Answer(); + row.setId(Ids.next()); + row.setInteractionId(q.getId()); + row.setVideoId(video.getId()); + row.setTitleId(title.getId()); + row.setUsername(username); + row.setProfileId(profileId); + row.setChoices(Rows.joinInts(new ArrayList(picked))); + row.setCorrect(correct); + row.setCreatedAt(System.currentTimeMillis()); + answers.insert(row); + + List tally = new ArrayList(); + if (POLL.equals(q.getKind())) { + int[] counts = new int[options.size()]; + List all = answers.findByInteractionId(q.getId()); + for (int iter = 0; iter < all.size(); iter++) { + List c = Rows.ints(all.get(iter).getChoices()); + for (int k = 0; k < c.size(); k++) { + if (c.get(k).intValue() < counts.length) { + counts[c.get(k).intValue()]++; + } + } + } + for (int iter = 0; iter < counts.length; iter++) { + tally.add(Integer.valueOf(counts[iter])); + } + } + String next = null; + if (BRANCH.equals(q.getKind())) { + List targets = Rows.split(q.getBranchTargets()); + int at = picked.iterator().next().intValue(); + next = at < targets.size() ? targets.get(at) : null; + } + int score = score(username, profileId, title.getId()); + if (QUIZ.equals(q.getKind())) { + maybeCertify(username, profileId, title); + } + return new AnswerResultDto(correct, QUIZ.equals(q.getKind()) ? q.getExplanation() : "", tally, next, score); + } + + @Transactional(rollbackFor = IOException.class) + public List progress(String username, String profileId) throws IOException { + Household profile = households.resolve(username, profileId); + List rows = progress.findTop50ByProfileIdOrderByUpdatedAtDesc(profile.getId()); + Set seen = new HashSet(); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + Title t = titles.findById(rows.get(iter).getTitleId()); + if (t == null || !Kinds.COURSE.equals(t.getKind()) || !seen.add(t.getId())) { + continue; + } + int[] lessons = lessons(profile.getId(), t.getId()); + Certificate cert = maybeCertify(username, profile.getId(), t); + out.add(new CourseProgressDto(t.getId(), t.getName(), lessons[0], lessons[1], + score(username, profile.getId(), t.getId()), cert != null, cert == null ? null : cert.getId())); + } + return out; + } + + @Transactional(readOnly = true) + public List certificates(String username) { + List rows = certificates.findByUsernameOrderByIssuedAtDesc(username); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + out.add(dto(rows.get(iter))); + } + return out; + } + + /// Public: anyone holding a code can check it. + @Transactional(readOnly = true) + public CertificateDto verify(String code) { + Certificate c = code == null ? null : certificates.findFirstByVerifyCode(code.trim()); + if (c == null) { + throw new ResponseStatusException(404, "No certificate has that code"); + } + return dto(c); + } + + /// Issues the certificate of a course once it is earned; answers the + /// existing one when it already was. + Certificate maybeCertify(String username, String profileId, Title t) throws IOException { + List have = certificates.findByUsernameAndTitleId(username, t.getId()); + if (!have.isEmpty()) { + return have.get(0); + } + if (!t.isCertificate() || !settings.enabled(Features.CERTIFICATES)) { + return null; + } + int[] lessons = lessons(profileId, t.getId()); + int score = score(username, profileId, t.getId()); + if (lessons[0] == 0 || lessons[1] < lessons[0] || score < t.getPassPercent()) { + return null; + } + Certificate c = new Certificate(); + c.setId(Ids.next()); + c.setUsername(username); + c.setTitleId(t.getId()); + c.setHolderName(accounts.displayName(username)); + c.setScorePercent(score); + c.setIssuedAt(System.currentTimeMillis()); + c.setVerifyCode(code()); + certificates.insert(c); + return c; + } + + /// {total lessons, completed lessons} of a course for a profile. + private int[] lessons(String profileId, String titleId) { + List