find_hardware_check.py
#!/usr/bin/env python3
"""
find_hardware_check.py
=======================
Détecte automatiquement, dans un decompile apktool de l'app "Connect TV"
(SFR / Group SFR - Android TV), la vérification "modèle de box supporté"
qui bloque l'application quand elle tourne sur un appareil non officiel
(Chromecast avec Google TV, Nvidia Shield, box Android générique, etc.).
Ce script est générique : il ne contient AUCUN chemin ni nom de fichier
codé en dur pour une version particulière de l'APK. Le nommage des
classes/méthodes change à chaque build (obfuscation R8/minification
différente), mais la STRUCTURE du code reste la même d'une version à
l'autre :
- Une classe abstraite "sealed-class style" (héritage Kotlin) avec
plusieurs sous-classes qui sont des singletons `object` Kotlin
(compilées en `.field public static final <x>:L<Super>;` rempli une
seule fois dans un <clinit>).
- Au moins une de ces sous-classes lit `Landroid/os/Build;->MODEL` ou
`Landroid/os/Build;->PRODUCT` (c'est la liste des box connues:
Sagemcom, SDMC, etc., plus un cas "Other" pour le matériel inconnu).
- Une méthode qui retourne un booléen (`)Z`) quelque part dans le code
et qui compare le modèle détecté à plusieurs de ces singletons
(plusieurs `sget-object` suivis d'un appel d'égalité `invoke-static`
à 2 arguments retournant Z, motif classique de compilation d'un
`when (x) { A -> ...; B -> ... }` ou d'une suite de `x == A || x == B`
en Kotlin).
Ce script se contente de repérer les CANDIDATS. Il ne modifie aucun
fichier. Une vérification manuelle du résultat reste nécessaire avant de
patcher quoi que ce soit (voir le GUIDE.md fourni à côté de ce script).
Usage :
python3 find_hardware_check.py /chemin/vers/decompile_apktool
Aucune dépendance externe : Python 3 standard uniquement.
"""
import re
import sys
from pathlib import Path
from collections import defaultdict
SGET_SINGLETON_RE = re.compile(
r'sget-object\s+v\d+,\s+(L[\w/$]+;)->(\w+):(L[\w/$]+;)'
)
METHOD_RE = re.compile(
r'\s*\.method\s.*?\s(\S+)\(([^)]*)\)(\S+)'
)
# Super-classes trop génériques pour être une "liste de box matérielles" :
# on les ignore pour éviter les hiérarchies bruyantes (Enum, Exception,
# classes internes de coroutines/serialization qui ont parfois des
# centaines de sous-types).
IGNORED_SUPERS = {
"Ljava/lang/Object;",
"Ljava/lang/Enum;",
"Ljava/lang/Exception;",
"Ljava/lang/RuntimeException;",
"Ljava/lang/Throwable;",
}
# Une vraie liste de modèles de box reste courte (quelques box connues +
# un cas générique "Other"). Bornes larges pour tolérer les variations
# entre versions.
MIN_SUBCLASSES = 3
MAX_SUBCLASSES = 20
def iter_smali_files(root: Path):
yield from root.rglob("*.smali")
def find_singleton_hierarchies(root: Path):
"""
Repère les classes ABSTRAITES ayant plusieurs sous-classes directes.
Retourne (hierarchies, file_of_class) où :
- hierarchies: dict{super_class -> set(sous_classes)}
- file_of_class: dict{class_name -> Path du fichier .smali}
"""
super_of = {}
file_of_class = {}
abstract_classes = set()
for f in iter_smali_files(root):
try:
text = f.read_text(errors="ignore")
except Exception:
continue
m_class = re.search(r'^\.class\s+(.*?)\s(L[\w/$]+;)', text, re.M)
m_super = re.search(r'^\.super\s+(L[\w/$]+;)', text, re.M)
if m_class:
modifiers, cls_name = m_class.group(1), m_class.group(2)
file_of_class[cls_name] = f
if "abstract" in modifiers:
abstract_classes.add(cls_name)
if m_super:
super_of[cls_name] = m_super.group(1)
hierarchies = defaultdict(set)
for cls, sup in super_of.items():
if sup in IGNORED_SUPERS:
continue
if sup not in abstract_classes:
continue
hierarchies[sup].add(cls)
return hierarchies, file_of_class
def hierarchy_has_model_check(hierarchy_classes, file_of_class):
"""Vrai si une des sous-classes référence Build.MODEL ou Build.PRODUCT."""
for cls in hierarchy_classes:
f = file_of_class.get(cls)
if not f:
continue
try:
text = f.read_text(errors="ignore")
except Exception:
continue
if "Landroid/os/Build;->MODEL" in text or "Landroid/os/Build;->PRODUCT" in text:
return True
return False
def find_boolean_gate_methods(root: Path, known_subclasses):
"""
Cherche les méthodes ()Z qui font au moins 2 comparaisons contre des
singletons appartenant à `known_subclasses`.
"""
candidates = []
for f in iter_smali_files(root):
try:
text = f.read_text(errors="ignore")
except Exception:
continue
lines = text.splitlines()
method_start = None
method_name = None
method_sig = None
hits = 0
for i, line in enumerate(lines):
m = METHOD_RE.match(line)
if m:
method_start = i
method_name = m.group(1)
method_sig = m.group(3)
hits = 0
continue
if line.strip() == ".end method":
if method_start is not None and method_sig == "Z" and hits >= 2:
candidates.append({
"file": f,
"method": method_name,
"start_line": method_start + 1,
"end_line": i + 1,
"hits": hits,
})
method_start = None
continue
if method_start is not None:
m2 = SGET_SINGLETON_RE.search(line)
if m2 and m2.group(1) in known_subclasses:
hits += 1
return candidates
def main():
if len(sys.argv) != 2:
print(f"Usage: {sys.argv[0]} /chemin/vers/decompile_apktool")
sys.exit(1)
root = Path(sys.argv[1])
if not root.is_dir():
print(f"Dossier introuvable: {root}")
sys.exit(1)
print("[1/3] Indexation des hiérarchies de classes (sealed-class style)...")
hierarchies, file_of_class = find_singleton_hierarchies(root)
print("[2/3] Recherche des hiérarchies liées à Build.MODEL/PRODUCT...")
model_hierarchies = {}
for sup, subs in hierarchies.items():
if not (MIN_SUBCLASSES <= len(subs) <= MAX_SUBCLASSES):
continue
if hierarchy_has_model_check(subs, file_of_class):
model_hierarchies[sup] = subs
if not model_hierarchies:
print("Aucune hiérarchie candidate trouvée. La logique a peut-être "
"changé de forme dans cette version -> inspection manuelle "
"nécessaire (chercher 'Landroid/os/Build;->MODEL' à la main "
"avec grep dans tout le dossier décompilé).")
sys.exit(2)
print("[3/3] Recherche des méthodes de vérification (gate) associées...")
found_any_gate = False
for sup, subs in model_hierarchies.items():
gates = find_boolean_gate_methods(root, subs)
if not gates:
continue
found_any_gate = True
print(f"\n=== Hiérarchie candidate : {sup} ===")
print(f" Sous-classes ({len(subs)}): {', '.join(sorted(subs))}")
for g in sorted(gates, key=lambda x: -x["hits"]):
rel = g["file"].relative_to(root)
print(f" -> {rel} méthode {g['method']}()Z "
f"(lignes {g['start_line']}-{g['end_line']}, "
f"{g['hits']} comparaisons)")
if not found_any_gate:
print("Aucune méthode de gate trouvée automatiquement. "
"Inspection manuelle nécessaire (voir GUIDE.md, section "
"'Si le script ne trouve rien').")
sys.exit(2)
print("\nVérifie manuellement le meilleur candidat (celui avec le plus "
"de comparaisons) avant de patcher : ouvre le fichier, confirme "
"que la méthode retourne bien 'vrai si le modèle est supporté', "
"et regarde ses appelants (recherche du nom de la méthode dans "
"tout le dossier décompilé) pour t'assurer qu'elle est utilisée "
"à un endroit lié à l'authentification / au démarrage de l'app. "
"Voir GUIDE.md pour la marche à suivre complète.")
if __name__ == "__main__":
main()