mirror of
git://f0xx.org/android_cast
synced 2026-07-29 05:17:39 +03:00
git flow, infra descriptions, cast flow changes (mobile side)
This commit is contained in:
46
.cursor/rules/git-flow.mdc
Normal file
46
.cursor/rules/git-flow.mdc
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
description: Green-master git flow — branch from next, releases and hotfixes per GIT_FLOW.md
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Git flow (green master)
|
||||||
|
|
||||||
|
Follow [docs/GIT_FLOW.md](docs/GIT_FLOW.md) and [docs/GIT_FLOW.pdf](docs/GIT_FLOW.pdf). Summary:
|
||||||
|
|
||||||
|
## Branches
|
||||||
|
|
||||||
|
| Branch | Use |
|
||||||
|
|--------|-----|
|
||||||
|
| **master** | Always green; **tags** (`vX.Y.Z`); merge only |
|
||||||
|
| **next** | Integration; all feature work merges here first |
|
||||||
|
| **feature/***, **fix/*** | Fork **next**; delete after merge |
|
||||||
|
| **hotfix/*** | Fork **master** for **released** bugs; merge → master → tag → merge master into **next** |
|
||||||
|
|
||||||
|
## Agent rules
|
||||||
|
|
||||||
|
1. **Do not** commit, push, or merge to **master** or **next** unless the user explicitly asks (release, hotfix, or sync step).
|
||||||
|
2. **Default work:** `git checkout next && git pull`, then `git checkout -b feature/...` or `fix/...`.
|
||||||
|
3. **Before merge to next:** run relevant tests (`./gradlew :app:testDebugUnitTest` for app changes; backend tests for PHP).
|
||||||
|
4. **Release:** only after user confirms — merge `next` → `master`, CI green, **tag on master**, then **merge master → next**.
|
||||||
|
5. **Hotfix (production):** branch from **master**, not next; after tag, **merge master into next**.
|
||||||
|
6. **Hotfix (next-only bug):** `fix/*` from **next** → merge to **next** only.
|
||||||
|
7. **Never** force-push `master` or `next`.
|
||||||
|
8. When working on a named branch, call **SetActiveBranch** with that branch name.
|
||||||
|
|
||||||
|
## Quick commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start feature
|
||||||
|
git fetch && git checkout next && git pull && git checkout -b feature/my-change
|
||||||
|
|
||||||
|
# After hotfix on master
|
||||||
|
git checkout next && git merge master && git push origin next
|
||||||
|
```
|
||||||
|
|
||||||
|
If `next` is missing locally: create from `master` and `git push -u origin next` (maintainer once).
|
||||||
|
|
||||||
|
## Dual remote (private primary)
|
||||||
|
|
||||||
|
- **`origin`** (`git://f0xx.org/...`) = canonical; fetch/pull/push day-to-day work here.
|
||||||
|
- **`github`** = publish mirror; push `master` + tags (and optionally `next`) after release — do not treat GitHub as sole remote unless user says so.
|
||||||
|
- Topic branches: `git push -u origin feature/...` only unless user asks to publish to GitHub.
|
||||||
@@ -86,3 +86,10 @@ Activities bind via AIDL (`ICastSenderService` / `ICastReceiverService`) for sta
|
|||||||
- UDP mode may glitch under load
|
- UDP mode may glitch under load
|
||||||
- Audio captures **playback** routed through the system (not microphone); some apps may be silent by policy
|
- Audio captures **playback** routed through the system (not microphone); some apps may be silent by policy
|
||||||
- Single sender → single receiver
|
- Single sender → single receiver
|
||||||
|
|
||||||
|
## Development (git flow)
|
||||||
|
|
||||||
|
We use a **green `master`** plus integration branch **`next`**: features merge to `next` first; releases merge `next` → `master` and are tagged there; production hotfixes branch from `master` and sync back into `next`.
|
||||||
|
|
||||||
|
- Narrative: [docs/GIT_FLOW.md](docs/GIT_FLOW.md)
|
||||||
|
- PDF (diagrams): [docs/GIT_FLOW.pdf](docs/GIT_FLOW.pdf) — regenerate: `python3 docs/_pdf_build/build_git_flow_pdf.py`
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import android.text.TextUtils;
|
|||||||
import com.foxx.androidcast.display.ExternalDisplayCapturePolicy;
|
import com.foxx.androidcast.display.ExternalDisplayCapturePolicy;
|
||||||
import com.foxx.androidcast.receiver.av.ReceiverAudioPreset;
|
import com.foxx.androidcast.receiver.av.ReceiverAudioPreset;
|
||||||
import com.foxx.androidcast.receiver.av.ReceiverVideoPreset;
|
import com.foxx.androidcast.receiver.av.ReceiverVideoPreset;
|
||||||
|
import com.foxx.androidcast.sender.SenderResolutionUiMode;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global app preferences: identity, PIN, tray icon, and default sender/receiver cast options.
|
* Global app preferences: identity, PIN, tray icon, and default sender/receiver cast options.
|
||||||
@@ -52,6 +53,7 @@ public final class AppPreferences {
|
|||||||
|
|
||||||
private static final String KEY_DEV_WIRED_DISPLAY_HINTS = "dev_wired_display_hints";
|
private static final String KEY_DEV_WIRED_DISPLAY_HINTS = "dev_wired_display_hints";
|
||||||
private static final String KEY_DEV_USB_TETHER_TRANSPORT = "dev_usb_tether_transport";
|
private static final String KEY_DEV_USB_TETHER_TRANSPORT = "dev_usb_tether_transport";
|
||||||
|
private static final String KEY_DEV_SENDER_RESOLUTION_UI_MODE = "dev_sender_resolution_ui_mode";
|
||||||
private static final String KEY_DEV_EXTERNAL_CAPTURE_POLICY = "dev_external_capture_policy";
|
private static final String KEY_DEV_EXTERNAL_CAPTURE_POLICY = "dev_external_capture_policy";
|
||||||
private static final String KEY_DEV_COMMERCIAL_ADS = "dev_commercial_ads";
|
private static final String KEY_DEV_COMMERCIAL_ADS = "dev_commercial_ads";
|
||||||
private static final String KEY_DEV_COMMERCIAL_IAP = "dev_commercial_iap";
|
private static final String KEY_DEV_COMMERCIAL_IAP = "dev_commercial_iap";
|
||||||
@@ -330,6 +332,22 @@ public final class AppPreferences {
|
|||||||
prefs(context).edit().putBoolean(KEY_DEV_USB_TETHER_TRANSPORT, enabled).apply();
|
prefs(context).edit().putBoolean(KEY_DEV_USB_TETHER_TRANSPORT, enabled).apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Default: {@link SenderResolutionUiMode#SEPARATED}. */
|
||||||
|
public static SenderResolutionUiMode getDevSenderResolutionUiMode(Context context) {
|
||||||
|
String name = prefs(context).getString(
|
||||||
|
KEY_DEV_SENDER_RESOLUTION_UI_MODE, SenderResolutionUiMode.SEPARATED.name());
|
||||||
|
try {
|
||||||
|
return SenderResolutionUiMode.valueOf(name);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return SenderResolutionUiMode.SEPARATED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void setDevSenderResolutionUiMode(Context context, SenderResolutionUiMode mode) {
|
||||||
|
SenderResolutionUiMode value = mode != null ? mode : SenderResolutionUiMode.SEPARATED;
|
||||||
|
prefs(context).edit().putString(KEY_DEV_SENDER_RESOLUTION_UI_MODE, value.name()).apply();
|
||||||
|
}
|
||||||
|
|
||||||
public static ExternalDisplayCapturePolicy getExternalDisplayCapturePolicy(Context context) {
|
public static ExternalDisplayCapturePolicy getExternalDisplayCapturePolicy(Context context) {
|
||||||
return ExternalDisplayCapturePolicy.fromName(
|
return ExternalDisplayCapturePolicy.fromName(
|
||||||
prefs(context).getString(KEY_DEV_EXTERNAL_CAPTURE_POLICY, null),
|
prefs(context).getString(KEY_DEV_EXTERNAL_CAPTURE_POLICY, null),
|
||||||
|
|||||||
@@ -102,7 +102,9 @@ public class CastSettings implements Serializable {
|
|||||||
HD_720P(1.0f, 720, 0),
|
HD_720P(1.0f, 720, 0),
|
||||||
FIXED_1080P(0f, 1080, 1920),
|
FIXED_1080P(0f, 1080, 1920),
|
||||||
FIXED_720P(0f, 720, 1280),
|
FIXED_720P(0f, 720, 1280),
|
||||||
ADAPTIVE(1.0f, 0, 0);
|
ADAPTIVE(1.0f, 0, 0),
|
||||||
|
/** Long-edge cap 480p (aspect preserved). */
|
||||||
|
SD_480P(1.0f, 480, 0);
|
||||||
|
|
||||||
public final float scale;
|
public final float scale;
|
||||||
public final int maxHeight;
|
public final int maxHeight;
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ public final class CastSettingsBinder {
|
|||||||
bindVideoCodec(activity, settings, R.id.spinner_sender_video_codec);
|
bindVideoCodec(activity, settings, R.id.spinner_sender_video_codec);
|
||||||
bindBitrate(activity, settings);
|
bindBitrate(activity, settings);
|
||||||
bindNetworkAdoption(activity, settings, R.id.spinner_sender_network);
|
bindNetworkAdoption(activity, settings, R.id.spinner_sender_network);
|
||||||
bindSenderResolution(activity, settings);
|
com.foxx.androidcast.sender.SenderResolutionUi.bindSenderResolution(activity, settings);
|
||||||
bindCaptureMode(activity, settings);
|
bindCaptureMode(activity, settings);
|
||||||
bindStreamProtection(activity, settings);
|
bindStreamProtection(activity, settings);
|
||||||
bindAudioSpinner(activity, settings, R.id.spinner_sender_audio);
|
bindAudioSpinner(activity, settings, R.id.spinner_sender_audio);
|
||||||
@@ -323,27 +323,8 @@ public final class CastSettingsBinder {
|
|||||||
readEnumSpinner(activity.findViewById(spinnerId), settings::setNetworkAdoption);
|
readEnumSpinner(activity.findViewById(spinnerId), settings::setNetworkAdoption);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void bindSenderResolution(AppCompatActivity activity, CastSettings settings) {
|
|
||||||
Spinner spinner = activity.findViewById(R.id.spinner_sender_resolution);
|
|
||||||
CastSettings.Resolution[] modes = {
|
|
||||||
CastSettings.Resolution.ADAPTIVE,
|
|
||||||
CastSettings.Resolution.FIXED_1080P,
|
|
||||||
CastSettings.Resolution.FIXED_720P,
|
|
||||||
CastSettings.Resolution.FULL,
|
|
||||||
CastSettings.Resolution.THREE_QUARTERS
|
|
||||||
};
|
|
||||||
String[] labels = {
|
|
||||||
activity.getString(R.string.resolution_adaptive),
|
|
||||||
activity.getString(R.string.resolution_fixed_1080p),
|
|
||||||
activity.getString(R.string.resolution_fixed_720p),
|
|
||||||
activity.getString(R.string.resolution_full),
|
|
||||||
activity.getString(R.string.resolution_75)
|
|
||||||
};
|
|
||||||
bindEnumSpinner(spinner, modes, labels, settings.getResolution(), settings::setResolution);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void readSenderResolution(AppCompatActivity activity, CastSettings settings) {
|
private static void readSenderResolution(AppCompatActivity activity, CastSettings settings) {
|
||||||
readEnumSpinner(activity.findViewById(R.id.spinner_sender_resolution), settings::setResolution);
|
com.foxx.androidcast.sender.SenderResolutionUi.readSenderResolution(activity, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void bindReceiverPip(AppCompatActivity activity) {
|
public static void bindReceiverPip(AppCompatActivity activity) {
|
||||||
@@ -551,7 +532,7 @@ public final class CastSettingsBinder {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static <T extends Enum<T>> void bindEnumSpinner(Spinner spinner, T[] values, String[] labels,
|
public static <T extends Enum<T>> void bindEnumSpinner(Spinner spinner, T[] values, String[] labels,
|
||||||
T current, java.util.function.Consumer<T> onSelect) {
|
T current, java.util.function.Consumer<T> onSelect) {
|
||||||
spinner.setTag(R.id.tag_spinner_enum, values);
|
spinner.setTag(R.id.tag_spinner_enum, values);
|
||||||
SettingsStringArrayAdapter adapter = new SettingsStringArrayAdapter(spinner.getContext(), labels);
|
SettingsStringArrayAdapter adapter = new SettingsStringArrayAdapter(spinner.getContext(), labels);
|
||||||
@@ -581,7 +562,7 @@ public final class CastSettingsBinder {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static <T extends Enum<T>> void readEnumSpinner(Spinner spinner,
|
public static <T extends Enum<T>> void readEnumSpinner(Spinner spinner,
|
||||||
java.util.function.Consumer<T> consumer) {
|
java.util.function.Consumer<T> consumer) {
|
||||||
Object tag = spinner.getTag(R.id.tag_spinner_enum);
|
Object tag = spinner.getTag(R.id.tag_spinner_enum);
|
||||||
if (tag instanceof Enum[]) {
|
if (tag instanceof Enum[]) {
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ public final class CastTuningEngine {
|
|||||||
case HD_720P:
|
case HD_720P:
|
||||||
capLong = 720;
|
capLong = 720;
|
||||||
break;
|
break;
|
||||||
|
case SD_480P:
|
||||||
|
capLong = 480;
|
||||||
|
break;
|
||||||
case FIXED_1080P:
|
case FIXED_1080P:
|
||||||
boxW = 1920;
|
boxW = 1920;
|
||||||
boxH = 1080;
|
boxH = 1080;
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import com.foxx.androidcast.ota.OtaUpdateCoordinator;
|
|||||||
import com.foxx.androidcast.receiver.av.ReceiverAudioPreset;
|
import com.foxx.androidcast.receiver.av.ReceiverAudioPreset;
|
||||||
import com.foxx.androidcast.receiver.av.ReceiverAvRuntime;
|
import com.foxx.androidcast.receiver.av.ReceiverAvRuntime;
|
||||||
import com.foxx.androidcast.receiver.av.ReceiverVideoPreset;
|
import com.foxx.androidcast.receiver.av.ReceiverVideoPreset;
|
||||||
|
import com.foxx.androidcast.sender.SenderResolutionUiMode;
|
||||||
import com.foxx.androidcast.stats.ui.SessionStatsAnalyzerActivity;
|
import com.foxx.androidcast.stats.ui.SessionStatsAnalyzerActivity;
|
||||||
import com.google.android.material.textfield.TextInputEditText;
|
import com.google.android.material.textfield.TextInputEditText;
|
||||||
|
|
||||||
@@ -88,6 +89,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
|||||||
});
|
});
|
||||||
|
|
||||||
bindAvPresets();
|
bindAvPresets();
|
||||||
|
bindSenderResolutionUiModeSection();
|
||||||
bindWiredDisplaySection();
|
bindWiredDisplaySection();
|
||||||
bindCommercialSection();
|
bindCommercialSection();
|
||||||
}
|
}
|
||||||
@@ -111,10 +113,37 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
|||||||
findViewById(R.id.edit_ota_manifest_url),
|
findViewById(R.id.edit_ota_manifest_url),
|
||||||
findViewById(R.id.check_ota_on_launch));
|
findViewById(R.id.check_ota_on_launch));
|
||||||
bindAvPresets();
|
bindAvPresets();
|
||||||
|
bindSenderResolutionUiModeSection();
|
||||||
bindWiredDisplaySection();
|
bindWiredDisplaySection();
|
||||||
bindCommercialSection();
|
bindCommercialSection();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void bindSenderResolutionUiModeSection() {
|
||||||
|
Spinner spinner = findViewById(R.id.spinner_dev_sender_resolution_ui_mode);
|
||||||
|
if (spinner == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final SenderResolutionUiMode[] modes = SenderResolutionUiMode.values();
|
||||||
|
String[] labels = {
|
||||||
|
getString(R.string.dev_sender_resolution_ui_separated),
|
||||||
|
getString(R.string.dev_sender_resolution_ui_legacy),
|
||||||
|
getString(R.string.dev_sender_resolution_ui_combined),
|
||||||
|
};
|
||||||
|
ArrayAdapter<String> adapter = new ArrayAdapter<>(this,
|
||||||
|
android.R.layout.simple_spinner_item, labels);
|
||||||
|
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||||
|
spinner.setAdapter(adapter);
|
||||||
|
spinner.setOnItemSelectedListener(null);
|
||||||
|
SenderResolutionUiMode current = AppPreferences.getDevSenderResolutionUiMode(this);
|
||||||
|
spinner.setSelection(current.ordinal(), false);
|
||||||
|
spinner.setOnItemSelectedListener(simpleSelection(() -> {
|
||||||
|
int pos = spinner.getSelectedItemPosition();
|
||||||
|
if (pos >= 0 && pos < modes.length) {
|
||||||
|
AppPreferences.setDevSenderResolutionUiMode(this, modes[pos]);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
private void bindWiredDisplaySection() {
|
private void bindWiredDisplaySection() {
|
||||||
TextView status = findViewById(R.id.text_wired_display_status);
|
TextView status = findViewById(R.id.text_wired_display_status);
|
||||||
if (status != null) {
|
if (status != null) {
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ public final class CastDiagnosticsLabels {
|
|||||||
return "50% display scale";
|
return "50% display scale";
|
||||||
case HD_720P:
|
case HD_720P:
|
||||||
return "720p long-edge cap";
|
return "720p long-edge cap";
|
||||||
|
case SD_480P:
|
||||||
|
return "480p long-edge cap";
|
||||||
case FIXED_1080P:
|
case FIXED_1080P:
|
||||||
return "Fixed 1080p box";
|
return "Fixed 1080p box";
|
||||||
case FIXED_720P:
|
case FIXED_720P:
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package com.foxx.androidcast.sender;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
|
||||||
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
|
||||||
|
import android.widget.Spinner;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.AppPreferences;
|
||||||
|
import com.foxx.androidcast.CastSettings;
|
||||||
|
import com.foxx.androidcast.CastSettingsBinder;
|
||||||
|
import com.foxx.androidcast.R;
|
||||||
|
|
||||||
|
/** Binds {@link R.id#spinner_sender_resolution} per {@link SenderResolutionUiMode}. */
|
||||||
|
public final class SenderResolutionUi {
|
||||||
|
private SenderResolutionUi() {}
|
||||||
|
|
||||||
|
public static SenderResolutionUiMode mode(Context context) {
|
||||||
|
return AppPreferences.getDevSenderResolutionUiMode(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void bindSenderResolution(AppCompatActivity activity, CastSettings settings) {
|
||||||
|
Spinner spinner = activity.findViewById(R.id.spinner_sender_resolution);
|
||||||
|
if (spinner == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (mode(activity)) {
|
||||||
|
case LEGACY:
|
||||||
|
bindLegacy(activity, settings, spinner);
|
||||||
|
break;
|
||||||
|
case COMBINED:
|
||||||
|
bindCombined(activity, settings, spinner);
|
||||||
|
break;
|
||||||
|
case SEPARATED:
|
||||||
|
default:
|
||||||
|
bindSeparated(activity, settings, spinner);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void readSenderResolution(AppCompatActivity activity, CastSettings settings) {
|
||||||
|
Spinner spinner = activity.findViewById(R.id.spinner_sender_resolution);
|
||||||
|
if (spinner != null) {
|
||||||
|
CastSettingsBinder.readEnumSpinner(spinner, settings::setResolution);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Original / 720p / 480p / Auto. */
|
||||||
|
private static void bindSeparated(AppCompatActivity activity, CastSettings settings, Spinner spinner) {
|
||||||
|
CastSettings.Resolution[] modes = {
|
||||||
|
CastSettings.Resolution.FULL,
|
||||||
|
CastSettings.Resolution.HD_720P,
|
||||||
|
CastSettings.Resolution.SD_480P,
|
||||||
|
CastSettings.Resolution.ADAPTIVE,
|
||||||
|
};
|
||||||
|
String[] labels = {
|
||||||
|
activity.getString(R.string.resolution_preset_original),
|
||||||
|
activity.getString(R.string.resolution_preset_720p),
|
||||||
|
activity.getString(R.string.resolution_preset_480p),
|
||||||
|
activity.getString(R.string.resolution_preset_auto),
|
||||||
|
};
|
||||||
|
CastSettingsBinder.bindEnumSpinner(spinner, modes, labels, settings.getResolution(),
|
||||||
|
settings::setResolution);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fixed 480p + fixed 720p + native original + bandwidth adaptive. */
|
||||||
|
private static void bindCombined(AppCompatActivity activity, CastSettings settings, Spinner spinner) {
|
||||||
|
CastSettings.Resolution[] modes = {
|
||||||
|
CastSettings.Resolution.SD_480P,
|
||||||
|
CastSettings.Resolution.HD_720P,
|
||||||
|
CastSettings.Resolution.FULL,
|
||||||
|
CastSettings.Resolution.ADAPTIVE,
|
||||||
|
};
|
||||||
|
String[] labels = {
|
||||||
|
activity.getString(R.string.resolution_preset_480p),
|
||||||
|
activity.getString(R.string.resolution_preset_720p),
|
||||||
|
activity.getString(R.string.resolution_combined_original),
|
||||||
|
activity.getString(R.string.resolution_combined_adaptive),
|
||||||
|
};
|
||||||
|
CastSettingsBinder.bindEnumSpinner(spinner, modes, labels, settings.getResolution(),
|
||||||
|
settings::setResolution);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pre-refactor sender resolution list (subset previously in CastSettingsBinder). */
|
||||||
|
private static void bindLegacy(AppCompatActivity activity, CastSettings settings, Spinner spinner) {
|
||||||
|
CastSettings.Resolution[] modes = {
|
||||||
|
CastSettings.Resolution.ADAPTIVE,
|
||||||
|
CastSettings.Resolution.FIXED_1080P,
|
||||||
|
CastSettings.Resolution.FIXED_720P,
|
||||||
|
CastSettings.Resolution.FULL,
|
||||||
|
CastSettings.Resolution.THREE_QUARTERS,
|
||||||
|
CastSettings.Resolution.HALF,
|
||||||
|
CastSettings.Resolution.HD_720P,
|
||||||
|
};
|
||||||
|
String[] labels = {
|
||||||
|
activity.getString(R.string.resolution_adaptive),
|
||||||
|
activity.getString(R.string.resolution_fixed_1080p),
|
||||||
|
activity.getString(R.string.resolution_fixed_720p),
|
||||||
|
activity.getString(R.string.resolution_full),
|
||||||
|
activity.getString(R.string.resolution_75),
|
||||||
|
activity.getString(R.string.resolution_half),
|
||||||
|
activity.getString(R.string.resolution_720p),
|
||||||
|
};
|
||||||
|
CastSettingsBinder.bindEnumSpinner(spinner, modes, labels, settings.getResolution(),
|
||||||
|
settings::setResolution);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.foxx.androidcast.sender;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Developer-only: how the sender resolution spinner is presented.
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link #SEPARATED} — Original / 720p / 480p / Auto (default)</li>
|
||||||
|
* <li>{@link #LEGACY} — full pre-refactor preset list</li>
|
||||||
|
* <li>{@link #COMBINED} — fixed 480p + fixed 720p + full original + BW adaptive</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public enum SenderResolutionUiMode {
|
||||||
|
SEPARATED,
|
||||||
|
LEGACY,
|
||||||
|
COMBINED
|
||||||
|
}
|
||||||
@@ -45,6 +45,34 @@
|
|||||||
android:layout_marginTop="12dp"
|
android:layout_marginTop="12dp"
|
||||||
android:text="@string/dev_receiver_adaptive_display" />
|
android:text="@string/dev_receiver_adaptive_display" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="28dp"
|
||||||
|
android:text="@string/dev_sender_resolution_ui_section"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/dev_sender_resolution_ui_mode"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
<Spinner
|
||||||
|
android:id="@+id/spinner_dev_sender_resolution_ui_mode"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/dev_sender_resolution_ui_hint"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
|||||||
@@ -46,6 +46,12 @@
|
|||||||
<string name="resolution_fixed_1080p">1080p (1920×1080)</string>
|
<string name="resolution_fixed_1080p">1080p (1920×1080)</string>
|
||||||
<string name="resolution_fixed_720p">720p (1280×720)</string>
|
<string name="resolution_fixed_720p">720p (1280×720)</string>
|
||||||
<string name="resolution_adaptive">Адаптивный (предсказание, в разработке)</string>
|
<string name="resolution_adaptive">Адаптивный (предсказание, в разработке)</string>
|
||||||
|
<string name="resolution_preset_original">Оригинал</string>
|
||||||
|
<string name="resolution_preset_720p">720p</string>
|
||||||
|
<string name="resolution_preset_480p">480p</string>
|
||||||
|
<string name="resolution_preset_auto">Авто</string>
|
||||||
|
<string name="resolution_combined_original">Оригинал (нативное)</string>
|
||||||
|
<string name="resolution_combined_adaptive">Адаптивный (оценка канала)</string>
|
||||||
<string name="label_video_codec">Видео кодек</string>
|
<string name="label_video_codec">Видео кодек</string>
|
||||||
<string name="label_adjustment_preset">Тонкие быстрые настройки</string>
|
<string name="label_adjustment_preset">Тонкие быстрые настройки</string>
|
||||||
<string name="label_bitrate_mode">Bitrate</string>
|
<string name="label_bitrate_mode">Bitrate</string>
|
||||||
@@ -188,6 +194,12 @@
|
|||||||
<string name="licenses_title">Лицензии</string>
|
<string name="licenses_title">Лицензии</string>
|
||||||
<string name="licenses_load_error">Не могу найти факл лицензий</string>
|
<string name="licenses_load_error">Не могу найти факл лицензий</string>
|
||||||
<string name="developer_settings_title">Для разработчиков</string>
|
<string name="developer_settings_title">Для разработчиков</string>
|
||||||
|
<string name="dev_sender_resolution_ui_section">Разрешение отправителя (тест)</string>
|
||||||
|
<string name="dev_sender_resolution_ui_mode">Режим списка разрешений</string>
|
||||||
|
<string name="dev_sender_resolution_ui_separated">Раздельный (Оригинал / 720p / 480p / Авто)</string>
|
||||||
|
<string name="dev_sender_resolution_ui_legacy">Старый (полный список)</string>
|
||||||
|
<string name="dev_sender_resolution_ui_combined">Комбинированный (480p + 720p + оригинал + адаптив)</string>
|
||||||
|
<string name="dev_sender_resolution_ui_hint">Меняет список разрешений в настройках трансляции. По умолчанию: Раздельный. Откройте настройки cast заново.</string>
|
||||||
<string name="dev_wired_display_section">USB-C / HDMI (план D)</string>
|
<string name="dev_wired_display_section">USB-C / HDMI (план D)</string>
|
||||||
<string name="dev_commercial_section">Монетизация (выкл. по умолчанию)</string>
|
<string name="dev_commercial_section">Монетизация (выкл. по умолчанию)</string>
|
||||||
<string name="transport_usb_tether">USB-тетер (эксперимент)</string>
|
<string name="transport_usb_tether">USB-тетер (эксперимент)</string>
|
||||||
|
|||||||
@@ -46,6 +46,12 @@
|
|||||||
<string name="resolution_fixed_1080p">Fixed 1080p (fit in 1920×1080)</string>
|
<string name="resolution_fixed_1080p">Fixed 1080p (fit in 1920×1080)</string>
|
||||||
<string name="resolution_fixed_720p">Fixed 720p (fit in 1280×720)</string>
|
<string name="resolution_fixed_720p">Fixed 720p (fit in 1280×720)</string>
|
||||||
<string name="resolution_adaptive">Adaptive (BW estimator, preview)</string>
|
<string name="resolution_adaptive">Adaptive (BW estimator, preview)</string>
|
||||||
|
<string name="resolution_preset_original">Original</string>
|
||||||
|
<string name="resolution_preset_720p">720p</string>
|
||||||
|
<string name="resolution_preset_480p">480p</string>
|
||||||
|
<string name="resolution_preset_auto">Auto</string>
|
||||||
|
<string name="resolution_combined_original">Original (native)</string>
|
||||||
|
<string name="resolution_combined_adaptive">Adaptive (BW estimator)</string>
|
||||||
<string name="label_video_codec">Video codec</string>
|
<string name="label_video_codec">Video codec</string>
|
||||||
<string name="label_adjustment_preset">Cast adjustment preset</string>
|
<string name="label_adjustment_preset">Cast adjustment preset</string>
|
||||||
<string name="label_bitrate_mode">Bitrate</string>
|
<string name="label_bitrate_mode">Bitrate</string>
|
||||||
@@ -199,6 +205,12 @@
|
|||||||
<string name="licenses_title">Licenses</string>
|
<string name="licenses_title">Licenses</string>
|
||||||
<string name="licenses_load_error">Could not load license text.</string>
|
<string name="licenses_load_error">Could not load license text.</string>
|
||||||
<string name="developer_settings_title">Developer settings</string>
|
<string name="developer_settings_title">Developer settings</string>
|
||||||
|
<string name="dev_sender_resolution_ui_section">Sender resolution UI (test)</string>
|
||||||
|
<string name="dev_sender_resolution_ui_mode">Resolution preset mode</string>
|
||||||
|
<string name="dev_sender_resolution_ui_separated">Separated (Original / 720p / 480p / Auto)</string>
|
||||||
|
<string name="dev_sender_resolution_ui_legacy">Legacy (full preset list)</string>
|
||||||
|
<string name="dev_sender_resolution_ui_combined">Combined (480p + 720p + original + adaptive)</string>
|
||||||
|
<string name="dev_sender_resolution_ui_hint">Changes the sender resolution spinner in cast settings. Default: Separated. Re-open cast settings to refresh labels.</string>
|
||||||
<string name="dev_wired_display_section">USB-C / HDMI (roadmap D)</string>
|
<string name="dev_wired_display_section">USB-C / HDMI (roadmap D)</string>
|
||||||
<string name="dev_wired_display_hints">Show sender status when external display is plugged in</string>
|
<string name="dev_wired_display_hints">Show sender status when external display is plugged in</string>
|
||||||
<string name="dev_wired_display_hint">Wired HDMI uses the OS display stack, not the cast protocol. If no second screen appears in system Display settings, adapters cannot be fixed in-app. See docs/USB_HDMI_CAST.md.</string>
|
<string name="dev_wired_display_hint">Wired HDMI uses the OS display stack, not the cast protocol. If no second screen appears in system Display settings, adapters cannot be fixed in-app. See docs/USB_HDMI_CAST.md.</string>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ package com.foxx.androidcast;
|
|||||||
* - Cursor Agent (project assistant)
|
* - Cursor Agent (project assistant)
|
||||||
* Digest: SHA256 6839d31665eec94c6c24a1a54c86dfaaef483d19196fe6b6a754eb7156c09b26
|
* Digest: SHA256 6839d31665eec94c6c24a1a54c86dfaaef483d19196fe6b6a754eb7156c09b26
|
||||||
**********************************************************************/
|
**********************************************************************/
|
||||||
|
import android.util.DisplayMetrics;
|
||||||
|
|
||||||
import com.foxx.androidcast.sender.calibration.CalibrationSourceDimensions;
|
import com.foxx.androidcast.sender.calibration.CalibrationSourceDimensions;
|
||||||
|
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
@@ -27,4 +29,18 @@ public class CastResolutionTest {
|
|||||||
assertEquals(400, size.height);
|
assertEquals(400, size.height);
|
||||||
assertTrue(size.width > size.height);
|
assertTrue(size.width > size.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void sd480pCapsLongEdge() {
|
||||||
|
CastSettings settings = new CastSettings();
|
||||||
|
settings.setResolution(CastSettings.Resolution.SD_480P);
|
||||||
|
DisplayMetrics metrics = new DisplayMetrics();
|
||||||
|
metrics.widthPixels = 2000;
|
||||||
|
metrics.heightPixels = 1200;
|
||||||
|
metrics.densityDpi = 320;
|
||||||
|
CastTuningEngine.CaptureLimits limits = CastTuningEngine.resolveCaptureLimits(settings, null);
|
||||||
|
assertEquals(480, limits.maxHeight);
|
||||||
|
CastResolution.Size size = CastResolution.compute(metrics, settings, null);
|
||||||
|
assertEquals(480, Math.max(size.width, size.height));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
384
docs/GIT_FLOW.md
Normal file
384
docs/GIT_FLOW.md
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
# Android Cast — Git flow (green `master`)
|
||||||
|
|
||||||
|
**Version:** 1.0 · **Last updated:** 2026-05-21
|
||||||
|
**PDF:** regenerate with `python3 docs/_pdf_build/build_git_flow_pdf.py` → `docs/GIT_FLOW.pdf`
|
||||||
|
|
||||||
|
This document defines how we develop, integrate, release, and hotfix. **Cursor agents and humans follow the same rules.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goals
|
||||||
|
|
||||||
|
| Goal | How |
|
||||||
|
|------|-----|
|
||||||
|
| **Always shippable `master`** | Only merges that passed CI/tests; tagged releases point here |
|
||||||
|
| **Safe integration** | Day-to-day work lands on `next` first |
|
||||||
|
| **Simple mental model** | Two long-lived branches + short-lived topic branches |
|
||||||
|
| **Recoverable hotfixes** | Production fixes branch from what users actually run |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Branches
|
||||||
|
|
||||||
|
| Branch | Role | Direct commits? |
|
||||||
|
|--------|------|-----------------|
|
||||||
|
| **`master`** | Production line; **green** (build + tests pass); release tags (`v0.1.2`) | **No** — merge only |
|
||||||
|
| **`next`** | Integration / release candidate; absorbs features until a release is approved | **No** — merge only |
|
||||||
|
| **`feature/*`**, **`fix/*`**, **`topic/*`** | Short-lived work; fork **`next`** | Yes (normal dev) |
|
||||||
|
| **`hotfix/*`** | Urgent fix for **released** code | Yes; merge target **`master`** first |
|
||||||
|
|
||||||
|
**Naming:** `feature/cast-resolution-presets`, `fix/crash-upload-500`, `hotfix/v0.1.1-pin-crash`.
|
||||||
|
|
||||||
|
If `next` does not exist yet on the remote:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout master
|
||||||
|
git pull
|
||||||
|
git checkout -b next
|
||||||
|
git push -u origin next
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Branch diagram (steady state)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph long["Long-lived"]
|
||||||
|
M[master\nshippable + tagged]
|
||||||
|
N[next\nintegration]
|
||||||
|
end
|
||||||
|
F[feature/*\nfix/*]
|
||||||
|
H[hotfix/*]
|
||||||
|
|
||||||
|
F -->|PR / merge after CI| N
|
||||||
|
N -->|release merge after CI| M
|
||||||
|
H -->|merge after CI| M
|
||||||
|
M -->|sync after release or hotfix| N
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Daily development
|
||||||
|
|
||||||
|
1. **Start from `next`:** `git fetch && git checkout next && git pull`
|
||||||
|
2. **Create topic branch:** `git checkout -b feature/my-change`
|
||||||
|
3. **Commit** in small logical chunks on the topic branch.
|
||||||
|
4. **Before merge:** run project checks (Android: `./gradlew :app:testDebugUnitTest` or CI equivalent; backend: relevant tests/lint).
|
||||||
|
5. **Merge to `next`:** prefer merge commit or squash per team habit; **do not** merge unfinished work.
|
||||||
|
6. **Delete** topic branch after merge (local + remote).
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Dev as Developer / agent
|
||||||
|
participant F as feature/*
|
||||||
|
participant N as next
|
||||||
|
participant CI as CI / local tests
|
||||||
|
|
||||||
|
Dev->>F: branch from next
|
||||||
|
Dev->>F: commits
|
||||||
|
Dev->>CI: test
|
||||||
|
CI-->>Dev: green
|
||||||
|
Dev->>N: merge feature → next
|
||||||
|
Dev->>F: delete branch
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Release: `next` → `master`
|
||||||
|
|
||||||
|
**When:** `next` is feature-complete for the version, soak/QA agreed, CI green.
|
||||||
|
|
||||||
|
1. Ensure **`next`** is up to date and **all tests pass**.
|
||||||
|
2. **Merge `next` → `master`** (merge commit recommended for traceability).
|
||||||
|
3. On **`master`:** final smoke / CI.
|
||||||
|
4. **Tag** on `master`: `git tag -a v0.1.2 -m "Release v0.1.2"` then `git push origin master --tags`.
|
||||||
|
5. **Sync `next` with `master`** so both lines share the release commit and any last-minute release fixes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout next
|
||||||
|
git merge master # fast-forward or merge commit
|
||||||
|
git push origin next
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Continue new work: branch **`feature/*` from `next`** again.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant N as next
|
||||||
|
participant M as master
|
||||||
|
participant T as tag v0.1.x
|
||||||
|
|
||||||
|
Note over N: CI green, QA ok
|
||||||
|
N->>M: merge next → master
|
||||||
|
M->>M: CI / smoke
|
||||||
|
M->>T: annotated tag
|
||||||
|
M->>N: merge master → next (sync)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Versioning:** tags on `master` only (`vMAJOR.MINOR.PATCH`). Bump per [SemVer](https://semver.org/) conventions you adopt for the app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Hotfixes — chosen strategy
|
||||||
|
|
||||||
|
### Default: **hotfix from `master` (option 1)** ✓
|
||||||
|
|
||||||
|
Use when the bug affects **what is already released** (code on `master` / latest tag).
|
||||||
|
|
||||||
|
1. `git checkout master && git pull`
|
||||||
|
2. `git checkout -b hotfix/v0.1.2-crash-on-stop`
|
||||||
|
3. Fix + tests
|
||||||
|
4. Merge **`hotfix/*` → `master`**, CI green
|
||||||
|
5. **Tag** patch on `master`: e.g. `v0.1.3`
|
||||||
|
6. **`git checkout next && git merge master`** — bring hotfix into integration (resolve conflicts if `next` diverged)
|
||||||
|
7. Push `master`, `next`, tags; delete `hotfix/*`
|
||||||
|
|
||||||
|
**Why not hotfix only on `next` (option 2)?**
|
||||||
|
`next` often contains unreleased features. Fixing there delays shipping the patch and risks dragging unrelated commits into production when you later merge `next` → `master`.
|
||||||
|
|
||||||
|
### Exception: fix only on `next` (option 2)
|
||||||
|
|
||||||
|
Use when the bug **does not exist on `master`** (introduced on `next` only).
|
||||||
|
|
||||||
|
1. `git checkout -b fix/...` from **`next`**
|
||||||
|
2. Merge to **`next`** only
|
||||||
|
3. No tag until the normal **release** merge `next` → `master`
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Q{BUG IN RELEASED\nmaster / tag?}
|
||||||
|
Q -->|Yes| H[hotfix/* from master]
|
||||||
|
H --> M[merge → master]
|
||||||
|
M --> T[tag patch on master]
|
||||||
|
T --> S[merge master → next]
|
||||||
|
Q -->|No, next only| F[fix/* from next]
|
||||||
|
F --> N[merge → next]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Protection rules (CI + policy)
|
||||||
|
|
||||||
|
| Rule | `master` | `next` | topic branches |
|
||||||
|
|------|----------|--------|----------------|
|
||||||
|
| Required CI (build + tests) before merge | ✓ | ✓ | ✓ (before PR/merge) |
|
||||||
|
| No direct pushes of WIP | ✓ | ✓ | — |
|
||||||
|
| Only maintainers merge to `master` | ✓ | optional | — |
|
||||||
|
| Tag only on `master` | ✓ | — | — |
|
||||||
|
|
||||||
|
See **[§7.1 How to protect branches](#71-how-to-protect-branches-not-local-hooks-alone)** below.
|
||||||
|
|
||||||
|
### 7.1 How to protect branches (not local hooks alone)
|
||||||
|
|
||||||
|
**Short answer:** use **server-side** rules on `f0xx.org` (or whatever hosts `origin`). **Local git hooks** on your laptop are optional helpers only — they do not stop you or someone else from pushing bad history from another machine.
|
||||||
|
|
||||||
|
| Layer | Where it runs | Stops bad pushes? | Good for |
|
||||||
|
|-------|----------------|-------------------|----------|
|
||||||
|
| **Hosting UI / branch protection** | Git server (GitHub, GitLab, Gitea, …) | **Yes** | Most teams; use this if available |
|
||||||
|
| **Server git hooks** (`pre-receive` / `update`) | Bare repo on server | **Yes** | Self-hosted `git://` / SSH without a web UI |
|
||||||
|
| **Local hooks** (`.git/hooks/pre-push`) | Your PC only | **No** (easy to skip) | Personal reminders, format/tests before push |
|
||||||
|
| **CI** (build on push/PR) | CI runner | Indirect | Prove tests passed; pair with “required check” on server |
|
||||||
|
|
||||||
|
#### What to enable on `master` and `next`
|
||||||
|
|
||||||
|
1. **No force-push** (no `git push --force` rewriting history).
|
||||||
|
2. **No direct push** (optional but strong): changes only via merge from `feature/*` or from PR/MR — you merge locally or on the web, but the server rejects `git push origin master` with a normal commit on top without review (exact option name varies).
|
||||||
|
3. **Require CI green** before merge (if you have Actions/GitLab CI/Gitea Actions).
|
||||||
|
4. **Restrict who can push** to you (and CI deploy key if needed).
|
||||||
|
|
||||||
|
`feature/*` branches stay **unprotected** — push freely.
|
||||||
|
|
||||||
|
#### If your host has a web UI (Gitea / GitLab / GitHub)
|
||||||
|
|
||||||
|
Look for **Branch protection** / **Protected branches**:
|
||||||
|
|
||||||
|
- Add rules for `master` and `next`.
|
||||||
|
- Enable: prevent force-push, require pull request (optional), require status checks.
|
||||||
|
|
||||||
|
No git internals required — the server rejects illegal pushes.
|
||||||
|
|
||||||
|
#### If you only have bare git on a server (`git://` / SSH)
|
||||||
|
|
||||||
|
Ask whoever administers `git://f0xx.org/android_cast` to install **server-side hooks** in the **bare repository** (not in your clone):
|
||||||
|
|
||||||
|
- `hooks/pre-receive` or `hooks/update` — script receives branch name + old/new SHA; **exit 1** to reject (e.g. block direct push to `refs/heads/master` unless user is deploy; block all force-push to `master`/`next`).
|
||||||
|
|
||||||
|
Example policy in words: “reject push if ref is `master` or `next` and user is not in allowlist” or “reject non-fast-forward updates to `master`/`next`”.
|
||||||
|
|
||||||
|
Local clone hooks live in `.git/hooks/` and are **not copied** when others clone — so they are **not** team protection.
|
||||||
|
|
||||||
|
#### Local hooks (optional, for you only)
|
||||||
|
|
||||||
|
Useful on your machine, not security:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .git/hooks/pre-push (chmod +x) — example: warn before pushing to master
|
||||||
|
protected_regex='^(refs/heads/)?(master|next)$'
|
||||||
|
while read local_ref local_sha remote_ref remote_sha; do
|
||||||
|
if echo "$remote_ref" | grep -qE "$protected_regex"; then
|
||||||
|
echo "Blocked: push to master/next — use feature branch + merge (see docs/GIT_FLOW.md)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Skip with `git push --no-verify` — another reason server rules matter.
|
||||||
|
|
||||||
|
#### CI without branch UI
|
||||||
|
|
||||||
|
Run tests on every push; you still **choose** not to merge until green. Branch protection + “required check” automates that on modern hosts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Sync cheat sheet
|
||||||
|
|
||||||
|
| Event | Action |
|
||||||
|
|-------|--------|
|
||||||
|
| After **release** (`next` → `master` + tag) | `git checkout next && git merge master && git push` |
|
||||||
|
| After **hotfix** to `master` + tag | Same: **merge `master` → `next`** |
|
||||||
|
| Feature finished | merge **topic → `next`** only |
|
||||||
|
| Started wrong base | rebase topic onto latest `next` (coordinate if already shared) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Agent / Cursor checklist
|
||||||
|
|
||||||
|
Agents working in this repo **must**:
|
||||||
|
|
||||||
|
1. **Never** commit or push directly to `master` or `next` unless the user explicitly asks for that merge/release step.
|
||||||
|
2. **Branch from `next`** for features and non-production fixes (`feature/*`, `fix/*`).
|
||||||
|
3. **Run tests** (or state clearly if not run) before recommending merge to `next`.
|
||||||
|
4. **Releases:** only describe merging `next` → `master` and tagging after user confirms QA.
|
||||||
|
5. **Hotfixes on released code:** branch from `master`, merge back to `master`, tag, then **merge `master` into `next`**.
|
||||||
|
6. **Set active branch** metadata when using a named topic branch (project convention).
|
||||||
|
7. **Do not** force-push `master` / `next`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Comparison to classic Git Flow
|
||||||
|
|
||||||
|
| Git Flow | This repo |
|
||||||
|
|----------|-----------|
|
||||||
|
| `main` | `master` (green, tagged) |
|
||||||
|
| `develop` | `next` |
|
||||||
|
| `feature/*` | same, base = `next` |
|
||||||
|
| `release/*` | optional short branch; default = test on `next` then merge to `master` |
|
||||||
|
| `hotfix/*` | from **`master`**, merge to **`master`**, sync to **`next`** |
|
||||||
|
|
||||||
|
We intentionally avoid a permanently divergent `develop` without a green production branch: **`master` is always releasable.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. First-time setup (maintainer)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ensure next exists and tracks origin
|
||||||
|
git checkout master && git pull
|
||||||
|
git checkout -b next 2>/dev/null || git checkout next
|
||||||
|
git push -u origin next
|
||||||
|
|
||||||
|
# Optional: protect branches on hosting (GitHub example)
|
||||||
|
# gh api repos/{owner}/{repo}/branches/master/protection ...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Private primary + GitHub publish (dual remote)
|
||||||
|
|
||||||
|
**Canonical repo:** your server — today `origin` → `git://f0xx.org/android_cast`.
|
||||||
|
**Daily work:** always `git push` / `git pull` against **`origin`** first.
|
||||||
|
**GitHub:** secondary remote for visibility, issues, and (optional) GitHub Actions — not the source of truth unless you decide otherwise.
|
||||||
|
|
||||||
|
### 12.1 Add GitHub without replacing origin
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create empty repo on GitHub (no README if you already have history)
|
||||||
|
git remote add github git@github.com:YOUR_USER/android-cast.git
|
||||||
|
# origin stays: git://f0xx.org/android_cast
|
||||||
|
git remote -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep **`origin`** = private server. Name the public remote **`github`** (or `publish`) so commands stay obvious.
|
||||||
|
|
||||||
|
### 12.2 What to push where
|
||||||
|
|
||||||
|
| Ref | Private `origin` | GitHub `github` |
|
||||||
|
|-----|------------------|-----------------|
|
||||||
|
| `feature/*`, WIP | ✓ always | optional (omit for cleaner public) |
|
||||||
|
| `next` | ✓ always | your choice: public integration or keep private |
|
||||||
|
| `master` + tags | ✓ always | ✓ publish releases you want visible |
|
||||||
|
| Secrets, `local.properties`, logs | never in either | never |
|
||||||
|
|
||||||
|
**Typical OSS-style:** push everything to **`origin`**; push only **`master`** and **tags** to **`github`** until you are ready to open `next`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# After merge to master on private server
|
||||||
|
git push origin master --tags
|
||||||
|
git push github master --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
Push `next` to GitHub only when you want public integration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin next
|
||||||
|
git push github next
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.3 Daily habit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch origin
|
||||||
|
git checkout next && git pull origin next
|
||||||
|
# ... work on feature/foo ...
|
||||||
|
git push -u origin feature/foo
|
||||||
|
# merge to next on origin (PR or local merge + push)
|
||||||
|
git push origin next
|
||||||
|
|
||||||
|
# When ready to publish a release line to GitHub
|
||||||
|
git push github master --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
Set upstream on topic branches to **origin**, not github:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push -u origin feature/my-change
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.4 Where branch protection lives
|
||||||
|
|
||||||
|
| Server | Protect |
|
||||||
|
|--------|---------|
|
||||||
|
| **f0xx.org (`origin`)** | `master`, `next` — server hooks or admin UI (primary enforcement) |
|
||||||
|
| **GitHub (`github`)** | `master` (and `next` if public) — Settings → Branch protection |
|
||||||
|
|
||||||
|
GitHub rules only affect pushes **to GitHub**. They do not protect your private server unless you also configure **`origin`**.
|
||||||
|
|
||||||
|
### 12.5 One-way mirror (optional automation)
|
||||||
|
|
||||||
|
On the private server (cron or post-receive hook), after a successful push to `origin`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push --prune github +refs/heads/master:refs/heads/master +refs/tags/*:refs/tags/*
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use GitHub’s “mirror repository” importers only for initial import; ongoing sync is usually `git push github …` from CI or hook.
|
||||||
|
|
||||||
|
Avoid `git push github --mirror` unless GitHub is a full duplicate of **private** history — it copies all branches and deletes remote branches you deleted locally.
|
||||||
|
|
||||||
|
### 12.6 Suggested order of operations (your situation)
|
||||||
|
|
||||||
|
1. On **f0xx.org:** create **`next`** from `master`, push `origin next`.
|
||||||
|
2. On **f0xx.org:** enable server protection for `master` / `next` (admin).
|
||||||
|
3. Create **GitHub** repo; `git remote add github …`.
|
||||||
|
4. First publish: `git push github master` and tags you want public.
|
||||||
|
5. Enable **GitHub branch protection** on `master` (block force-push).
|
||||||
|
6. Continue feature flow on **`origin`** only; publish to GitHub when you merge/tag on `master`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [ROADMAP.md](ROADMAP.md) — product tracks
|
||||||
|
- [.cursor/rules/git-flow.mdc](../.cursor/rules/git-flow.mdc) — agent enforcement
|
||||||
|
- `docs/_pdf_build/build_git_flow_pdf.py` — PDF generator
|
||||||
200
docs/GIT_FLOW.pdf
Normal file
200
docs/GIT_FLOW.pdf
Normal file
File diff suppressed because one or more lines are too long
76
docs/OPEN_API.md
Normal file
76
docs/OPEN_API.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Crash console — open ingest API (design)
|
||||||
|
|
||||||
|
Triage **real device crashes** vs **synthetic / bulk test traffic** when exposing upload or query APIs beyond the Android app. Builds on RBAC ([`examples/crash_reporter/backend/README.md`](../examples/crash_reporter/backend/README.md)).
|
||||||
|
|
||||||
|
## Problems
|
||||||
|
|
||||||
|
- Load tests and CI may POST many similar JSON blobs → drown signal in the reports list.
|
||||||
|
- Third-party integrations need stable auth and filters without console login cookies.
|
||||||
|
- Similarity grouping already uses payload fingerprints; need **provenance** to exclude known harness traffic.
|
||||||
|
|
||||||
|
## Payload extension (ingest)
|
||||||
|
|
||||||
|
Add optional top-level fields on upload (backward compatible):
|
||||||
|
|
||||||
|
| Field | Type | Meaning |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `ingest.source` | string | `device` (default), `open_api`, `ci`, `manual` |
|
||||||
|
| `ingest.client_id` | string | Stable id per API key or CI job |
|
||||||
|
| `ingest.run_id` | string | Correlates a burst of reports from one test run |
|
||||||
|
|
||||||
|
Android app omits these (defaults to `device`). Harness sets `open_api` + `run_id`.
|
||||||
|
|
||||||
|
Store in DB:
|
||||||
|
|
||||||
|
- Column `reports.ingest_source` (indexed) **or** JSON path in `payload_json` with generated column later.
|
||||||
|
- Prefer column for list filters and MariaDB permissions story.
|
||||||
|
|
||||||
|
## Upload API (`POST …/api/upload.php`)
|
||||||
|
|
||||||
|
Phases:
|
||||||
|
|
||||||
|
1. **Phase A (now):** Accept extra JSON fields; persist inside `payload_json`; console search includes `ingest.source` in blob.
|
||||||
|
2. **Phase B:** API key header `X-Crash-Api-Key` → maps to `company_id` (RBAC); rate limit per key.
|
||||||
|
3. **Phase C:** Reject or quarantine when `ingest.source=open_api` and fingerprint matches org blocklist.
|
||||||
|
|
||||||
|
## Console / query API (read)
|
||||||
|
|
||||||
|
Thin JSON endpoints (session or API key):
|
||||||
|
|
||||||
|
| Endpoint | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `GET /api/reports?ingest_source=device` | Default triage view |
|
||||||
|
| `GET /api/reports?ingest_source_ne=open_api` | Hide harness |
|
||||||
|
| `GET /api/reports?group=fingerprint&min_count=N` | Bulk duplicate detector |
|
||||||
|
| `GET /api/reports/{id}` | Detail (existing shape) |
|
||||||
|
|
||||||
|
UI: filter chips on reports toolbar — **Devices only** / **Include API traffic** / tag `synthetic`.
|
||||||
|
|
||||||
|
## Classifying synthetic traffic (heuristics)
|
||||||
|
|
||||||
|
Mark report or tag `synthetic` when any:
|
||||||
|
|
||||||
|
- `ingest.source` in (`open_api`, `ci`) **and** user has not promoted it.
|
||||||
|
- Same `ingest.run_id` + fingerprint count > threshold in 10 minutes.
|
||||||
|
- Identical `report_id` prefix pattern from known test fixture.
|
||||||
|
|
||||||
|
Manual: console **Clear synthetic tag** / **Promote to real** on detail page.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- API keys scoped to `company_id`; no cross-tenant list.
|
||||||
|
- Separate write-only keys for CI upload vs read keys for dashboards.
|
||||||
|
- Log upload failures to existing crash event log; no stack traces in response body.
|
||||||
|
|
||||||
|
## Android
|
||||||
|
|
||||||
|
No change required for v1. Optional later: dev setting to set `ingest.source=manual` for dogfood builds.
|
||||||
|
|
||||||
|
## Implementation order
|
||||||
|
|
||||||
|
1. Document fields; parse and store in payload (Phase A).
|
||||||
|
2. List filter `ingest_source` in `ReportRepository` + UI chip.
|
||||||
|
3. API keys table + `X-Crash-Api-Key` on upload.
|
||||||
|
4. Grouped bulk endpoint for duplicate review.
|
||||||
|
|
||||||
|
See also [CRASH_REPORTER.md](CRASH_REPORTER.md) and `examples/crash_reporter/ROADMAP.md`.
|
||||||
77
docs/PRO_AAR.md
Normal file
77
docs/PRO_AAR.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Pro / OSS split (cast-core + cast-pro AAR)
|
||||||
|
|
||||||
|
Design notes for a commercial build without forking the cast protocol. **Not implemented yet** — complements [COMMERCIAL.md](COMMERCIAL.md) (dev-gated Play Billing placeholders).
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- **OSS (`cast-core`)** — protocol, UDP/TCP transport, receiver, sender engine, crash reporter client, settings model. Publishable repo for trust and forks.
|
||||||
|
- **Closed (`cast-pro` AAR)** — entitlements, license check, optional premium codecs/UI, Play Billing glue. Distributed via private Maven or Play App Bundle module (not on public Maven).
|
||||||
|
- **Single APK** on Play: `app` depends on `cast-core` + `cast-pro` at compile time; F-Droid / OSS build uses `cast-core` only with stub entitlements.
|
||||||
|
|
||||||
|
## Entitlements API (in core)
|
||||||
|
|
||||||
|
Keep a thin interface in core so free code never imports Play Billing:
|
||||||
|
|
||||||
|
```java
|
||||||
|
public interface CastEntitlements {
|
||||||
|
boolean isPro(Context ctx);
|
||||||
|
int maxReceivers();
|
||||||
|
boolean allowsResolution(CastSettings.Resolution r);
|
||||||
|
boolean allowsStreamProtection();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Default (OSS):** `FreeCastEntitlements` — e.g. max 1 receiver, cap outgoing long edge at 480 unless dev override, FEC/NACK off or dev-only.
|
||||||
|
- **Pro:** `ProCastEntitlements` in AAR — reads signed license / Play purchase cache, returns higher limits.
|
||||||
|
|
||||||
|
Call sites (minimal first pass):
|
||||||
|
|
||||||
|
| Area | Gate |
|
||||||
|
|------|------|
|
||||||
|
| `CastTuningEngine` / sender encode | `allowsResolution()` |
|
||||||
|
| Multi-receiver connect | `maxReceivers()` |
|
||||||
|
| Stream protection UI | `allowsStreamProtection()` |
|
||||||
|
| Developer resolution UI modes | Always allowed when `BuildConfig.DEBUG` or `AppPreferences` dev flags |
|
||||||
|
|
||||||
|
`CommercialFeatures` stays **developer toggles** for ads/IAP/Play review; **pro entitlement** is runtime product state from Billing + AAR.
|
||||||
|
|
||||||
|
## Module layout (Gradle)
|
||||||
|
|
||||||
|
```
|
||||||
|
cast-core/ # android library, published OSS
|
||||||
|
cast-pro/ # android library, proprietary, implementation project OR maven coords
|
||||||
|
app/ # application
|
||||||
|
```
|
||||||
|
|
||||||
|
`app/build.gradle`:
|
||||||
|
|
||||||
|
- `implementation project(':cast-core')`
|
||||||
|
- `implementation 'com.foxx.androidcast:cast-pro:…'` (release) or `implementation project(':cast-pro')` (internal)
|
||||||
|
- Optional `ossRelease` flavor: `implementation project(':cast-core')` only + `FreeCastEntitlements`
|
||||||
|
|
||||||
|
**ServiceLoader or manifest meta-data** to bind `CastEntitlements` implementation avoids `compileOnly` reflection hacks.
|
||||||
|
|
||||||
|
## Play Billing flow
|
||||||
|
|
||||||
|
1. User buys SKU (see `InAppPurchaseCoordinator.SKU_PREMIUM`).
|
||||||
|
2. Pro AAR verifies purchase token, caches entitlement locally (encrypted prefs or Play Library `BillingClient` + acknowledge).
|
||||||
|
3. `CastEntitlements` refreshes; UI shows unlocked resolutions / receivers.
|
||||||
|
4. Offline grace period (e.g. 7 days) documented in product terms.
|
||||||
|
|
||||||
|
## Build & release
|
||||||
|
|
||||||
|
- Version **cast-core** and **cast-pro** independently; app pins both in `gradle/libs.versions.toml`.
|
||||||
|
- CI: OSS job builds `cast-core` + F-Droid APK; private job signs `cast-pro` and release APK.
|
||||||
|
- Do not embed license secrets in OSS; pro AAR may contain obfuscated verification only.
|
||||||
|
|
||||||
|
## Migration steps (suggested order)
|
||||||
|
|
||||||
|
1. Extract `CastEntitlements` + `FreeCastEntitlements` in app (no module split yet).
|
||||||
|
2. Wire gates at encode + multi-receiver (behind dev “simulate free tier” toggle).
|
||||||
|
3. Create `:cast-core` library — move `network/`, `cast/`, shared settings (large refactor).
|
||||||
|
4. Add `:cast-pro` with `ProCastEntitlements` + Billing bridge.
|
||||||
|
5. Play Console product + privacy policy ([COMMERCIAL.md](COMMERCIAL.md)).
|
||||||
|
|
||||||
|
## Relation to sender resolution test
|
||||||
|
|
||||||
|
Developer **Sender resolution UI mode** ([`SenderResolutionUi`](../app/src/main/java/com/foxx/androidcast/sender/SenderResolutionUi.java)) is for UX experiments only. Production free tier should still enforce caps in `CastTuningEngine` via entitlements, not by hiding spinner entries alone.
|
||||||
@@ -66,7 +66,17 @@ All **off by default**. Enable only in **Developer settings** for internal testi
|
|||||||
| AdMob / unit IDs / banner layouts in main UI | TODO |
|
| AdMob / unit IDs / banner layouts in main UI | TODO |
|
||||||
| Play Console products + privacy policy | TODO |
|
| Play Console products + privacy policy | TODO |
|
||||||
|
|
||||||
See [COMMERCIAL.md](COMMERCIAL.md).
|
See [COMMERCIAL.md](COMMERCIAL.md). Pro/OSS module split: [PRO_AAR.md](PRO_AAR.md) (design).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Crash console open API
|
||||||
|
|
||||||
|
| Item | Status |
|
||||||
|
|------|--------|
|
||||||
|
| `ingest.source` / run metadata in payload | Design — [OPEN_API.md](OPEN_API.md) |
|
||||||
|
| List filters + synthetic tagging | TODO |
|
||||||
|
| API key upload auth (per company) | TODO |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -126,6 +136,8 @@ See [ndk/README.md](../ndk/README.md).
|
|||||||
| Multi-receiver 1:N | Done (soak or gate for alpha) |
|
| Multi-receiver 1:N | Done (soak or gate for alpha) |
|
||||||
| Legacy `ReceiverActivity` in manifest | Orphan — remove or wire |
|
| Legacy `ReceiverActivity` in manifest | Orphan — remove or wire |
|
||||||
| Immersive A/V presets (dev) | Experimental |
|
| Immersive A/V presets (dev) | Experimental |
|
||||||
|
| Sender resolution UI modes (dev: Separated / Legacy / Combined) | Done (test) |
|
||||||
|
| True dual-stream encode (480p + 720p simultaneous) | Deferred |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -142,8 +154,11 @@ See [ndk/README.md](../ndk/README.md).
|
|||||||
|
|
||||||
## Related docs
|
## Related docs
|
||||||
|
|
||||||
|
- [GIT_FLOW.md](GIT_FLOW.md) — green `master` / `next` development cycle ([PDF](GIT_FLOW.pdf))
|
||||||
- [README.md](../README.md) — quick start
|
- [README.md](../README.md) — quick start
|
||||||
- [USB_HDMI_CAST.md](USB_HDMI_CAST.md) — D & E
|
- [USB_HDMI_CAST.md](USB_HDMI_CAST.md) — D & E
|
||||||
- [COMMERCIAL.md](COMMERCIAL.md) — ads / IAP / Play
|
- [COMMERCIAL.md](COMMERCIAL.md) — ads / IAP / Play
|
||||||
|
- [PRO_AAR.md](PRO_AAR.md) — cast-core / cast-pro entitlements
|
||||||
|
- [OPEN_API.md](OPEN_API.md) — crash ingest triage API
|
||||||
- [OTA.md](OTA.md) — backend layout v0
|
- [OTA.md](OTA.md) — backend layout v0
|
||||||
- [CRASH_REPORTER.md](CRASH_REPORTER.md) — crash upload
|
- [CRASH_REPORTER.md](CRASH_REPORTER.md) — crash upload
|
||||||
|
|||||||
298
docs/_pdf_build/build_git_flow_pdf.py
Normal file
298
docs/_pdf_build/build_git_flow_pdf.py
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build docs/GIT_FLOW.pdf from structured content and Mermaid diagrams.
|
||||||
|
|
||||||
|
Source of truth for edits: docs/GIT_FLOW.md (narrative) + this script (PDF layout).
|
||||||
|
Regenerate: python3 docs/_pdf_build/build_git_flow_pdf.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from reportlab.lib import colors
|
||||||
|
from reportlab.lib.enums import TA_LEFT
|
||||||
|
from reportlab.lib.pagesizes import A4
|
||||||
|
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||||
|
from reportlab.lib.units import cm
|
||||||
|
from reportlab.platypus import (
|
||||||
|
Image,
|
||||||
|
PageBreak,
|
||||||
|
Paragraph,
|
||||||
|
SimpleDocTemplate,
|
||||||
|
Spacer,
|
||||||
|
Table,
|
||||||
|
TableStyle,
|
||||||
|
)
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
BUILD = Path(__file__).resolve().parent
|
||||||
|
OUT_PDF = ROOT / "docs" / "GIT_FLOW.pdf"
|
||||||
|
|
||||||
|
DIAGRAMS = [
|
||||||
|
("git_flow_diagram_branches.mmd", "git_flow_diagram_branches.png", "Branch model (green master)"),
|
||||||
|
("git_flow_diagram_feature.mmd", "git_flow_diagram_feature.png", "Daily development"),
|
||||||
|
("git_flow_diagram_release.mmd", "git_flow_diagram_release.png", "Release: next → master"),
|
||||||
|
("git_flow_diagram_hotfix.mmd", "git_flow_diagram_hotfix.png", "Hotfix decision and flow"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def render_mermaid() -> None:
|
||||||
|
for mmd, png, _ in DIAGRAMS:
|
||||||
|
src = BUILD / mmd
|
||||||
|
dst = BUILD / png
|
||||||
|
if not src.exists():
|
||||||
|
raise FileNotFoundError(src)
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"npx",
|
||||||
|
"--yes",
|
||||||
|
"@mermaid-js/mermaid-cli@11.4.0",
|
||||||
|
"-i",
|
||||||
|
str(src),
|
||||||
|
"-o",
|
||||||
|
str(dst),
|
||||||
|
"-b",
|
||||||
|
"white",
|
||||||
|
"-w",
|
||||||
|
"1200",
|
||||||
|
"-H",
|
||||||
|
"800",
|
||||||
|
],
|
||||||
|
cwd=str(ROOT),
|
||||||
|
check=True,
|
||||||
|
timeout=180,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def p(text: str, style) -> Paragraph:
|
||||||
|
return Paragraph(text.replace("\n", "<br/>"), style)
|
||||||
|
|
||||||
|
|
||||||
|
def add_table(story, headers: list[str], rows: list[list[str]], col_widths) -> None:
|
||||||
|
data = [headers] + rows
|
||||||
|
t = Table(data, colWidths=col_widths)
|
||||||
|
t.setStyle(
|
||||||
|
TableStyle(
|
||||||
|
[
|
||||||
|
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1565C0")),
|
||||||
|
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
|
||||||
|
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
||||||
|
("FONTSIZE", (0, 0), (-1, -1), 9),
|
||||||
|
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||||
|
("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey),
|
||||||
|
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F5F5F5")]),
|
||||||
|
("LEFTPADDING", (0, 0), (-1, -1), 6),
|
||||||
|
("RIGHTPADDING", (0, 0), (-1, -1), 6),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 4),
|
||||||
|
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
story.append(t)
|
||||||
|
|
||||||
|
|
||||||
|
def add_diagram(story, mmd_png: str, caption: str, h2, iw: float = 16 * cm) -> None:
|
||||||
|
path = BUILD / mmd_png
|
||||||
|
if path.exists():
|
||||||
|
story.append(p(caption, h2))
|
||||||
|
story.append(Image(str(path), width=iw, height=iw * 0.55))
|
||||||
|
story.append(Spacer(1, 0.35 * cm))
|
||||||
|
|
||||||
|
|
||||||
|
def build_pdf() -> None:
|
||||||
|
styles = getSampleStyleSheet()
|
||||||
|
title = ParagraphStyle(
|
||||||
|
"DocTitle",
|
||||||
|
parent=styles["Title"],
|
||||||
|
fontSize=18,
|
||||||
|
spaceAfter=10,
|
||||||
|
textColor=colors.HexColor("#1565C0"),
|
||||||
|
)
|
||||||
|
h1 = ParagraphStyle("H1", parent=styles["Heading1"], fontSize=14, spaceBefore=12, spaceAfter=6)
|
||||||
|
h2 = ParagraphStyle("H2", parent=styles["Heading2"], fontSize=11, spaceBefore=8, spaceAfter=4)
|
||||||
|
body = ParagraphStyle("Body", parent=styles["Normal"], fontSize=10, leading=14, alignment=TA_LEFT)
|
||||||
|
small = ParagraphStyle("Small", parent=body, fontSize=9, textColor=colors.grey)
|
||||||
|
bullet = ParagraphStyle("Bullet", parent=body, leftIndent=14, bulletIndent=6)
|
||||||
|
|
||||||
|
story: list = []
|
||||||
|
story.append(p("Android Cast — Git flow (green master)", title))
|
||||||
|
story.append(
|
||||||
|
p(
|
||||||
|
"Development cycles, branch roles, release and hotfix strategy. "
|
||||||
|
"Version 1.0 · 2026-05-21. Markdown source: docs/GIT_FLOW.md",
|
||||||
|
small,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
story.append(Spacer(1, 0.3 * cm))
|
||||||
|
|
||||||
|
# 1 Goals
|
||||||
|
story.append(p("1. Goals", h1))
|
||||||
|
add_table(
|
||||||
|
story,
|
||||||
|
["Goal", "Mechanism"],
|
||||||
|
[
|
||||||
|
["Always shippable master", "Merge to master only when CI/tests pass"],
|
||||||
|
["Safe integration", "Features land on next first"],
|
||||||
|
["Clear production line", "Release tags only on master"],
|
||||||
|
["Fast production patches", "hotfix/* from master, then sync to next"],
|
||||||
|
],
|
||||||
|
[4.5 * cm, 12 * cm],
|
||||||
|
)
|
||||||
|
story.append(Spacer(1, 0.4 * cm))
|
||||||
|
|
||||||
|
# 2 Branches
|
||||||
|
story.append(p("2. Branches", h1))
|
||||||
|
add_table(
|
||||||
|
story,
|
||||||
|
["Branch", "Role", "Direct commits?"],
|
||||||
|
[
|
||||||
|
["master", "Production; green; annotated tags vX.Y.Z", "No — merge only"],
|
||||||
|
["next", "Integration / release candidate", "No — merge only"],
|
||||||
|
["feature/*, fix/*", "Short-lived work", "Yes; merge → next"],
|
||||||
|
["hotfix/*", "Urgent fix for released code", "Yes; merge → master first"],
|
||||||
|
],
|
||||||
|
[3 * cm, 8.5 * cm, 4 * cm],
|
||||||
|
)
|
||||||
|
story.append(Spacer(1, 0.3 * cm))
|
||||||
|
add_diagram(story, DIAGRAMS[0][1], DIAGRAMS[0][2], h2)
|
||||||
|
story.append(PageBreak())
|
||||||
|
|
||||||
|
# 3 Daily dev
|
||||||
|
story.append(p("3. Daily development", h1))
|
||||||
|
for line in [
|
||||||
|
"1. git fetch && git checkout next && git pull",
|
||||||
|
"2. git checkout -b feature/short-description",
|
||||||
|
"3. Commit on topic branch; run tests before merge",
|
||||||
|
"4. Merge topic → next (after CI green)",
|
||||||
|
"5. Delete topic branch",
|
||||||
|
]:
|
||||||
|
story.append(p(f"• {line}", bullet))
|
||||||
|
story.append(Spacer(1, 0.2 * cm))
|
||||||
|
story.append(
|
||||||
|
p(
|
||||||
|
"<b>Agents:</b> never push directly to master or next unless the user explicitly "
|
||||||
|
"requests a release or hotfix merge.",
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
add_diagram(story, DIAGRAMS[1][1], DIAGRAMS[1][2], h2, iw=15 * cm)
|
||||||
|
story.append(PageBreak())
|
||||||
|
|
||||||
|
# 4 Release
|
||||||
|
story.append(p("4. Release (next → master)", h1))
|
||||||
|
for line in [
|
||||||
|
"Confirm next is QA-ready and CI green",
|
||||||
|
"Merge next → master",
|
||||||
|
"Smoke / CI on master",
|
||||||
|
"Tag on master: git tag -a v0.1.2 -m \"Release v0.1.2\"",
|
||||||
|
"Sync: git checkout next && git merge master && git push",
|
||||||
|
"Continue feature branches from updated next",
|
||||||
|
]:
|
||||||
|
story.append(p(f"• {line}", bullet))
|
||||||
|
add_diagram(story, DIAGRAMS[2][1], DIAGRAMS[2][2], h2, iw=15 * cm)
|
||||||
|
story.append(PageBreak())
|
||||||
|
|
||||||
|
# 5 Hotfix
|
||||||
|
story.append(p("5. Hotfixes — recommended strategy", h1))
|
||||||
|
story.append(
|
||||||
|
p(
|
||||||
|
"<b>Default (option 1):</b> branch hotfix/* from <b>master</b> when the bug affects "
|
||||||
|
"released code. After fix and tests: merge → master, tag patch (e.g. v0.1.3), then "
|
||||||
|
"<b>merge master → next</b> so integration does not miss the fix.",
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
story.append(Spacer(1, 0.15 * cm))
|
||||||
|
story.append(
|
||||||
|
p(
|
||||||
|
"<b>Exception (option 2):</b> bug exists only on next — branch fix/* from next, "
|
||||||
|
"merge to next only; ships with the next normal release.",
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
story.append(Spacer(1, 0.15 * cm))
|
||||||
|
story.append(
|
||||||
|
p(
|
||||||
|
"<b>Why not hotfix only on next?</b> next often contains unreleased features; "
|
||||||
|
"merging next to master for a patch would ship extra code or block the fix.",
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
add_diagram(story, DIAGRAMS[3][1], DIAGRAMS[3][2], h2, iw=14 * cm)
|
||||||
|
|
||||||
|
story.append(p("5.1 Sync cheat sheet", h2))
|
||||||
|
add_table(
|
||||||
|
story,
|
||||||
|
["Event", "Action"],
|
||||||
|
[
|
||||||
|
["After release + tag", "merge master → next"],
|
||||||
|
["After hotfix + tag", "merge master → next"],
|
||||||
|
["Feature done", "merge topic → next only"],
|
||||||
|
["Wrong base branch", "rebase topic onto latest next"],
|
||||||
|
],
|
||||||
|
[4.5 * cm, 12 * cm],
|
||||||
|
)
|
||||||
|
story.append(PageBreak())
|
||||||
|
|
||||||
|
# 6 Protection + agents
|
||||||
|
story.append(p("6. Branch protection and CI", h1))
|
||||||
|
add_table(
|
||||||
|
story,
|
||||||
|
["Rule", "master", "next"],
|
||||||
|
[
|
||||||
|
["CI required before merge", "yes", "yes"],
|
||||||
|
["No WIP direct push", "yes", "yes"],
|
||||||
|
["Annotated release tags", "yes", "no"],
|
||||||
|
["Force-push", "forbidden", "forbidden"],
|
||||||
|
],
|
||||||
|
[5.5 * cm, 5 * cm, 5 * cm],
|
||||||
|
)
|
||||||
|
story.append(Spacer(1, 0.35 * cm))
|
||||||
|
story.append(p("7. Cursor / agent checklist", h1))
|
||||||
|
for line in [
|
||||||
|
"Branch from next for features and non-production fixes",
|
||||||
|
"Run tests before recommending merge to next",
|
||||||
|
"Releases and tags only with explicit user approval",
|
||||||
|
"Hotfix released code from master; sync master into next after tag",
|
||||||
|
"Never force-push master or next",
|
||||||
|
]:
|
||||||
|
story.append(p(f"• {line}", bullet))
|
||||||
|
story.append(Spacer(1, 0.4 * cm))
|
||||||
|
story.append(
|
||||||
|
p(
|
||||||
|
"Full narrative and Mermaid sources: docs/GIT_FLOW.md, "
|
||||||
|
"docs/_pdf_build/git_flow_diagram_*.mmd · "
|
||||||
|
"Regenerate PDF: python3 docs/_pdf_build/build_git_flow_pdf.py",
|
||||||
|
small,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
doc = SimpleDocTemplate(
|
||||||
|
str(OUT_PDF),
|
||||||
|
pagesize=A4,
|
||||||
|
leftMargin=2 * cm,
|
||||||
|
rightMargin=2 * cm,
|
||||||
|
topMargin=2 * cm,
|
||||||
|
bottomMargin=2 * cm,
|
||||||
|
)
|
||||||
|
doc.build(story)
|
||||||
|
print(f"Wrote {OUT_PDF}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
try:
|
||||||
|
render_mermaid()
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print("Mermaid render failed:", e, file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
print(e, file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
build_pdf()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
21
docs/_pdf_build/git_flow_diagram_branches.mmd
Normal file
21
docs/_pdf_build/git_flow_diagram_branches.mmd
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
flowchart TB
|
||||||
|
subgraph legend[" "]
|
||||||
|
direction LR
|
||||||
|
L1[Long-lived]
|
||||||
|
L2[Short-lived]
|
||||||
|
end
|
||||||
|
|
||||||
|
M["master\n(always green, tagged releases)"]
|
||||||
|
N["next\n(integration / release candidate)"]
|
||||||
|
F["feature/* fix/*\n(fork next)"]
|
||||||
|
H["hotfix/*\n(fork master)"]
|
||||||
|
|
||||||
|
F -->|"merge + CI green"| N
|
||||||
|
N -->|"release: merge + CI green"| M
|
||||||
|
H -->|"urgent patch + CI green"| M
|
||||||
|
M -->|"sync after release or hotfix"| N
|
||||||
|
|
||||||
|
style M fill:#c8e6c9,stroke:#2e7d32
|
||||||
|
style N fill:#bbdefb,stroke:#1565c0
|
||||||
|
style F fill:#fff9c4,stroke:#f9a825
|
||||||
|
style H fill:#ffccbc,stroke:#e64a19
|
||||||
BIN
docs/_pdf_build/git_flow_diagram_branches.png
Normal file
BIN
docs/_pdf_build/git_flow_diagram_branches.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
15
docs/_pdf_build/git_flow_diagram_feature.mmd
Normal file
15
docs/_pdf_build/git_flow_diagram_feature.mmd
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
participant Dev as Developer or agent
|
||||||
|
participant Topic as feature/* or fix/*
|
||||||
|
participant Next as next
|
||||||
|
participant CI as Tests / CI
|
||||||
|
|
||||||
|
Dev->>Next: fetch, checkout, pull
|
||||||
|
Dev->>Topic: create branch from next
|
||||||
|
Dev->>Topic: commits
|
||||||
|
Dev->>CI: run tests
|
||||||
|
CI-->>Dev: pass
|
||||||
|
Dev->>Next: merge topic branch
|
||||||
|
Note over Next: next stays integration line
|
||||||
|
Dev->>Topic: delete branch
|
||||||
BIN
docs/_pdf_build/git_flow_diagram_feature.png
Normal file
BIN
docs/_pdf_build/git_flow_diagram_feature.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
21
docs/_pdf_build/git_flow_diagram_hotfix.mmd
Normal file
21
docs/_pdf_build/git_flow_diagram_hotfix.mmd
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
flowchart TD
|
||||||
|
Start([Defect found])
|
||||||
|
Q{Affects released code\non master / latest tag?}
|
||||||
|
|
||||||
|
Start --> Q
|
||||||
|
Q -->|Yes| A[Branch hotfix/* from master]
|
||||||
|
A --> B[Fix + tests]
|
||||||
|
B --> C[Merge to master]
|
||||||
|
C --> D[Tag patch on master e.g. v0.1.3]
|
||||||
|
D --> E[Merge master into next]
|
||||||
|
E --> End([Done])
|
||||||
|
|
||||||
|
Q -->|No, only on next| F[Branch fix/* from next]
|
||||||
|
F --> G[Fix + tests]
|
||||||
|
G --> H[Merge to next only]
|
||||||
|
H --> End2([Ships with next release])
|
||||||
|
|
||||||
|
style C fill:#c8e6c9
|
||||||
|
style D fill:#c8e6c9
|
||||||
|
style E fill:#bbdefb
|
||||||
|
style H fill:#bbdefb
|
||||||
BIN
docs/_pdf_build/git_flow_diagram_hotfix.png
Normal file
BIN
docs/_pdf_build/git_flow_diagram_hotfix.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
16
docs/_pdf_build/git_flow_diagram_release.mmd
Normal file
16
docs/_pdf_build/git_flow_diagram_release.mmd
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
participant Next as next
|
||||||
|
participant Master as master
|
||||||
|
participant CI as Tests / CI
|
||||||
|
participant Tag as tag vX.Y.Z
|
||||||
|
|
||||||
|
Note over Next: feature complete, QA ok
|
||||||
|
Next->>CI: full test suite
|
||||||
|
CI-->>Next: green
|
||||||
|
Next->>Master: merge next to master
|
||||||
|
Master->>CI: smoke on master
|
||||||
|
CI-->>Master: green
|
||||||
|
Master->>Tag: annotated tag on master
|
||||||
|
Master->>Next: merge master into next (sync)
|
||||||
|
Note over Next: continue features from synced next
|
||||||
BIN
docs/_pdf_build/git_flow_diagram_release.png
Normal file
BIN
docs/_pdf_build/git_flow_diagram_release.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Reference in New Issue
Block a user