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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ jobs:

- name: Validate patch tooling
run: |
python3 tools/test_activity_theme.py -v
python3 tools/test_export_patches.py -v
python3 tools/test_conversation_patch.py -v
python3 tools/test_signal_830_patch.py -v
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
strategy:
fail-fast: false
matrix:
signal: [pinned, v8.31.1]
signal: [pinned, v8.32.1]

steps:
- name: Check out Vesper patchset
Expand All @@ -33,6 +33,7 @@ jobs:

- name: Validate patch tooling
run: |
python3 tools/test_activity_theme.py -v
python3 tools/test_export_patches.py -v
python3 tools/test_conversation_patch.py -v
python3 tools/test_signal_830_patch.py -v
Expand Down
92 changes: 31 additions & 61 deletions patches/0001-feat-add-Material-You-dynamic-theming.patch

Large diffs are not rendered by default.

188 changes: 54 additions & 134 deletions patches/0005-feat-finish-Vesper-settings-and-help-surfaces.patch

Large diffs are not rendered by default.

83 changes: 83 additions & 0 deletions tools/activity_theme.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Copyright 2026 Vesper contributors
# SPDX-License-Identifier: AGPL-3.0-only
"""Theme hooks for activities renamed or replaced across supported Signal tags."""
from pathlib import Path


def _replace_once(source: str, old: str, new: str) -> str:
if source.count(old) != 1:
raise RuntimeError(f"Unexpected activity lifecycle context: {old!r}")
return source.replace(old, new, 1)


def transform_activity_themes(checkout: Path) -> int:
root = checkout / "app/src/main/java/org/thoughtcrime/securesms"
changed = 0
# Signal 8.32 renamed this host; keep both layouts and navigation policies.
for name in ("components/settings/DSLSettingsActivity.kt", "components/NavGraphHostActivity.kt"):
path = root / name
if not path.exists():
continue
source = path.read_text()
if "private var themeAppliedBeforeInflation = false" in source:
continue
source = _replace_once(source, " dynamicTheme.onCreate(this)\n", "")
source = _replace_once(source, " override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {", ''' private var themeAppliedBeforeInflation = false

override fun onPreCreate() {
super.onPreCreate()
dynamicTheme.onCreate(this)
themeAppliedBeforeInflation = true
}

override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {''')
source = _replace_once(source, " enableSettingsEdgeToEdge()\n", ''' enableSettingsEdgeToEdge()
check(themeAppliedBeforeInflation) {
"The dynamic settings theme must be applied before inflating views."
}
''')
# Preserve the exact previously patched layout (one blank line).
source = source.replace("\n\n\n onBackPressedDispatcher", "\n\n onBackPressedDispatcher")
path.write_text(source)
changed += 1
# The old picker was replaced by a passphrase-gated Compose activity.
# Never resurrect the removed activity or change the new result contract.
legacy = root / "scribbles/ImageEditorStickerSelectActivity.java"
modern = root / "stickers/search/StickerSearchActivity.kt"
for path in (legacy, modern):
if not path.exists():
continue
source = path.read_text()
if "dynamicTheme.onCreate(this)" in source:
continue
if path == legacy:
source = _replace_once(source, "import org.thoughtcrime.securesms.util.ViewUtil;", "import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;\nimport org.thoughtcrime.securesms.util.DynamicTheme;\nimport org.thoughtcrime.securesms.util.ViewUtil;")
anchor = ' public static final String EXTRA_FEATURE_STICKER = "imageEditor.featureSticker";'
source = _replace_once(source, anchor, anchor + '''
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();

@Override
protected void onResume() {
super.onResume();
dynamicTheme.onResume(this);
}''')
source = _replace_once(source, " EdgeToEdge.enable(this);", " dynamicTheme.onCreate(this);\n EdgeToEdge.enable(this);")
else:
source = _replace_once(source, "import org.thoughtcrime.securesms.PassphraseRequiredActivity", "import org.thoughtcrime.securesms.util.DynamicMediaPreviewTheme\nimport org.thoughtcrime.securesms.PassphraseRequiredActivity")
anchor = "class StickerSearchActivity : PassphraseRequiredActivity() {"
source = _replace_once(source, anchor, anchor + '''

private val dynamicTheme = DynamicMediaPreviewTheme()

override fun onPreCreate() {
super.onPreCreate()
dynamicTheme.onCreate(this)
}

override fun onResume() {
super.onResume()
dynamicTheme.onResume(this)
}''')
path.write_text(source)
changed += 1
return changed
94 changes: 94 additions & 0 deletions tools/test_activity_theme.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Copyright 2026 Vesper contributors
# SPDX-License-Identifier: AGPL-3.0-only
"""Cross-version lifecycle hooks; the real source is also checked after replay."""
from pathlib import Path
import tempfile
import unittest
from activity_theme import transform_activity_themes


class ActivityThemeTest(unittest.TestCase):
def test_settings_theme_precedes_inflation_for_both_names(self):
for name, layout in (("components/settings/DSLSettingsActivity.kt", "dsl_settings_activity"),
("components/NavGraphHostActivity.kt", "nav_graph_host_activity")):
with self.subTest(name=name), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
path = root / "app/src/main/java/org/thoughtcrime/securesms" / name
path.parent.mkdir(parents=True)
path.write_text('''package example
class Activity {
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
enableSettingsEdgeToEdge()

setContentView(R.layout.LAYOUT)
val navGraphId = resolveNavGraphId()
dynamicTheme.onCreate(this)

onBackPressedDispatcher.addCallback(this, OnBackPressed())
}
}
'''.replace("LAYOUT", layout))
self.assertEqual(transform_activity_themes(root), 1)
source = path.read_text()
self.assertEqual(source.count("dynamicTheme.onCreate(this)"), 1)
self.assertLess(source.index("dynamicTheme.onCreate(this)"), source.index("setContentView"))
self.assertIn("super.onPreCreate()", source)
self.assertIn("val navGraphId = resolveNavGraphId()", source)
self.assertIn("check(themeAppliedBeforeInflation)", source)
self.assertEqual(transform_activity_themes(root), 0)
self.assertEqual(source, path.read_text())

def test_sticker_picker_hooks_preserve_each_upstream_lifecycle(self):
for name, source, creation in (
("scribbles/ImageEditorStickerSelectActivity.java", '''package example;
import org.thoughtcrime.securesms.util.ViewUtil;
public final class ImageEditorStickerSelectActivity extends AppCompatActivity {
public static final String EXTRA_FEATURE_STICKER = "imageEditor.featureSticker";
protected void onCreate(@Nullable Bundle savedInstanceState) {
EdgeToEdge.enable(this);
super.onCreate(savedInstanceState);
}
}
''', " EdgeToEdge.enable(this);"),
("stickers/search/StickerSearchActivity.kt", '''package example
import org.thoughtcrime.securesms.PassphraseRequiredActivity
class StickerSearchActivity : PassphraseRequiredActivity() {
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
super.onCreate(savedInstanceState, ready)
SignalTheme(isDarkMode = true) { ImageEditorStickerSearch(onResult = ::finishWithResult) }
}
}
''', " override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {"),
):
with self.subTest(name=name), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
path = root / "app/src/main/java/org/thoughtcrime/securesms" / name
path.parent.mkdir(parents=True)
path.write_text(source)
self.assertEqual(transform_activity_themes(root), 1)
result = path.read_text()
self.assertEqual(result.count("dynamicTheme.onCreate(this)"), 1)
self.assertEqual(result.count("dynamicTheme.onResume(this)"), 1)
self.assertLess(result.index("dynamicTheme.onCreate(this)"), result.index(creation))
if name.endswith(".kt"):
self.assertIn("DynamicMediaPreviewTheme()", result)
self.assertIn("SignalTheme(isDarkMode = true)", result)
self.assertIn("super.onPreCreate()", result)
else:
self.assertIn("new DynamicNoActionBarTheme()", result)
self.assertEqual(transform_activity_themes(root), 0)
self.assertEqual(result, path.read_text())

def test_unknown_settings_lifecycle_fails_without_writing(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
path = root / "app/src/main/java/org/thoughtcrime/securesms/components/NavGraphHostActivity.kt"
path.parent.mkdir(parents=True)
path.write_text("upstream lifecycle changed")
with self.assertRaises(RuntimeError):
transform_activity_themes(root)
self.assertEqual(path.read_text(), "upstream lifecycle changed")


if __name__ == "__main__":
unittest.main()
28 changes: 28 additions & 0 deletions tools/test_materialised_theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,34 @@


class MaterialisedThemeContractTest(unittest.TestCase):
def test_settings_host_theme_precedes_inflation(self) -> None:
paths = [SOURCE / "components/settings/DSLSettingsActivity.kt", SOURCE / "components/NavGraphHostActivity.kt"]
sources = [path.read_text() for path in paths if path.exists()]
self.assertEqual(len(sources), 1)
source = sources[0]
self.assertEqual(source.count("dynamicTheme.onCreate(this)"), 1)
self.assertLess(source.index("dynamicTheme.onCreate(this)"), source.index("setContentView("))
self.assertIn("super.onPreCreate()", source)
self.assertIn("check(themeAppliedBeforeInflation)", source)
self.assertIn("val navGraphId = resolveNavGraphId()", source)

def test_sticker_picker_retains_theme_and_upstream_security(self) -> None:
legacy = SOURCE / "scribbles/ImageEditorStickerSelectActivity.java"
modern = SOURCE / "stickers/search/StickerSearchActivity.kt"
self.assertNotEqual(legacy.exists(), modern.exists())
source = (legacy if legacy.exists() else modern).read_text()
self.assertEqual(source.count("dynamicTheme.onCreate(this)"), 1)
self.assertEqual(source.count("dynamicTheme.onResume(this)"), 1)
if modern.exists():
self.assertIn("StickerSearchActivity : PassphraseRequiredActivity()", source)
self.assertIn("DynamicMediaPreviewTheme()", source)
self.assertIn("SignalTheme(isDarkMode = true)", source)
self.assertIn("ImageEditorStickerSearch(onResult = ::finishWithResult)", source)
self.assertLess(source.index("dynamicTheme.onCreate(this)"), source.index("setContent {"))
else:
self.assertIn("new DynamicNoActionBarTheme()", source)
self.assertLess(source.index("dynamicTheme.onCreate(this)"), source.index("super.onCreate(savedInstanceState)"))

def test_conversation_retains_material_and_upstream_compose_colors(self) -> None:
source = (SOURCE / "conversation/v2/ConversationFragment.kt").read_text()
self.assertEqual(source.count('import com.google.android.material.R as MaterialR\n'), 1)
Expand Down
2 changes: 2 additions & 0 deletions tools/transform.py
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,8 @@ def main() -> int:
manifest = load_string_manifest(STRING_MANIFEST_PATH)
mappings = load_color_mappings(COLORS_PATH)

from activity_theme import transform_activity_themes
transform_activity_themes(checkout)
transform_gradle(checkout, config)
string_files, missing_strings = transform_strings(checkout, config, manifest)
themed_resource_files = transform_theme_color_references(checkout)
Expand Down
Loading