    invoke-static {p1}, Lcom/hjq/toast/ToastUtils;->show(Ljava/lang/CharSequence;)V

    return-void

    .line 723
    :cond_a
    iget-object v0, p0, Lcom/tcn/background/standard/fragmentv2/system/AndroidSystemFragment;->btCopyLog:Landroid/widget/Button;

    sget v1, Lcom/tcn/background/R$string;->bstand_backup_now:I

    invoke-virtual {p0, v1}, Lcom/tcn/background/standard/fragmentv2/system/AndroidSystemFragment;->getStringSkin(I)Ljava/lang/String;

    move-result-object v1

    invoke-virtual {v0, v1}, Landroid/widget/Button;->setText(Ljava/lang/CharSequence;)V

    .line 724
    new-instance v0, Lcom/tcn/background/standard/fragmentv2/system/AndroidSystemFragment$SystemThread;

    new-instance v1, Lcom/tcn/background/standard/fragmentv2/system/AndroidSystemFragment$8;

    invoke-direct {v1, p0, p1}, Lcom/tcn/background/standard/fragmentv2/system/AndroidSystemFragment$8;-><init>(Lcom/tcn/background/standard/fragmentv2/system/AndroidSystemFragment;Ljava/lang/String;)V

    invoke-direct {v0, v1}, Lcom/tcn/background/standard/fragmentv2/system/AndroidSystemFragment$SystemThread;-><init>(Ljava/lang/Runnable;)V

    const-string p1, "copyLog"

    .line 782
    invoke-virtual {v0, p1}, Lcom/tcn/background/standard/fragmentv2/system/AndroidSystemFragment$SystemThread;->setName(Ljava/lang/String;)V

    .line 783
    invoke-virtual {v0}, Lcom/tcn/background/standard/fragmentv2/system/AndroidSystemFragment$SystemThread;->start()V

    goto/16 :goto_6

    .line 785
    :cond_b
    invoke-virtual {p1}, Landroid/view/View;->getId()I

    move-result v0

    sget v1, Lcom/tcn/background/R$id;->btDeleteAllAds:I

    if-ne v0, v1, :cond_c

    .line 786
    iget-object p1, p0, Lcom/tcn/background/standard/fragmentv2/system/AndroidSystemFragment;->btDeleteAllAds:Landroid/widget/Button;

    invoke-virtual {p1}, Landroid/widget/Button;->getText()Ljava/lang/CharSequence;

    move-result-object p1

    invoke-interface {p1}, Ljava/lang/CharSequence;->toString()Ljava/lang/String;

    move-result-object p1

    invoke-virtual {p1}, Ljava/lang/String;->trim()Ljava/lang/String;

    move-result-object p1

    sget v0, Lcom/tcn/background/R$string;->delete_all_ads:I

    invoke-virtual {p0, v0}, Lcom/tcn/background/standard/fragmentv2/system/AndroidSystemFragment;->getStringSkin(I)Ljava/lang/String;

    move-result-object v0

    invoke-virtual {p1, v0}, Ljava/lang/String;->equals(Ljava/lang/Object;)Z

    move-result p1

    if-eqz p1, :cond_2a

    .line 787
    iget-object p1, p0, Lcom/tcn/background/standard/fragmentv2/system/AndroidSystemFragment;->btDeleteAllAds:Landroid/widget/Button;

    sget v0, Lcom/tcn/background/R$string;->bstand_delete_now:I

    invoke-virtual {p0, v0}, Lcom/tcn/background/standard/fragmentv2/system/AndroidSystemFragment;->getStringSkin(I)Ljava/lang/String;

    move-result-object v0

    invoke-virtual {p1, v0}, Landroid/widget/Button;->setText(Ljava/lang/CharSequence;)V

    .line 788
    new-instance p1, Lcom/tcn/background/standard/fragmentv2/system/AndroidSystemFragment$SystemThread;

    new-instance v0, Lcom/tcn/background/standard/fragmentv2/system/AndroidSystemFragment$9;

    invoke-direct {v0, p0}, Lcom/tcn/background/standard/fragmentv2/system/AndroidSystemFragment$9;-><init>(Lcom/tcn/background/standard/fragmentv2/system/AndroidSystemFragment;)V

    invoke-direct {p1, v0}, Lcom/tcn/background/standard/fragmentv2/system/AndroidSystemFragment$SystemThread;-><init>(Ljava/lang/Runnable;)V

    const-string v0, "deleteAd"

    .line 810
    invoke-virtual {p1, v0}, Lcom/tcn/background/standard/fragmentv2/system/AndroidSystemFragment$SystemThread;->setName(Ljava/lang/String;)V

    .line 811
    invoke-virtual {p1}, Lcom/tcn/background/standard/fragmentv2/system/AndroidSystemFragment$SystemThread;->start()V

    goto/16 :goto_6

    .line 813
    :cond_c
    invoke-virtual {p1}, Landroid/view/View;->getId()I

    move-result v0

    sget v1, Lcom/tcn/background/R$id;->btCopyAds:I

    if-ne v0, v1, :cond_12

    .line 814
    invoke-direct {p0}, Lcom/tcn/background/standard/fragmentv2/system/AndroidSystemFragment;->getUsbPath()Ljava/lang/String;

    move-result-object p1

    if-nez p1, :cond_d

    .line 816
    sget p1, Lcom/tcn/background/R$string;->bstand_no_usb_found:I

    invoke-virtual {p0, p1}, Lcom/tcn/background/standard/fragmentv2/system/AndroidSystemFragment;->getStringSkin(I)Ljava/lang/String;

    move-result-object p1

    invoke-static {p1}, Lcom/hjq/toast/ToastUtils;->show(Ljava/lang/CharSequence;)V

    return-void

    .line 819
    :cond_d
    iget-object v0, p0, Lcom/tcn/background/standard/fragmentv2/system/AndroidSystemFragment;->btCopyAds:Landroid/widget/Button;

    invoke-virtual {v0}, Landroid/widget/Button;->getText()Ljava/lang/CharSequence;

    move-result-object v0

    invoke-interface {v0}, Ljava/lang/CharSequence;->toString()Ljava/lang/String;
    move-result-object v0

    invoke-direct {p1, v0}, Lcom/tcn/background/standard/fragmentv2/dialog/EnterSerialPorttingDialog;-><init>(Landroid/content/Context;)V

    .line 998
    new-instance v0, Lcom/tcn/background/standard/fragmentv2/system/AndroidSystemFragment$13;

    invoke-direct {v0, p0}, Lcom/tcn/background/standard/fragmentv2/system/AndroidSystemFragment$13;-><init>(Lcom/tcn/background/standard/fragmentv2/system/AndroidSystemFragment;)V

    invoke-virtual {p1, v0}, Lcom/tcn/background/standard/fragmentv2/dialog/EnterSerialPorttingDialog;->setChooseResultListener(Lcom/tcn/background/standard/fragmentv2/dialog/EnterSerialPorttingDialog$ChooseResultListener;)V

    .line 1007
    invoke-virtual {p1}, Lcom/tcn/background/standard/fragmentv2/dialog/EnterSerialPorttingDialog;->show()V

    goto/16 :goto_6

    .line 1008
    :cond_15
    invoke-virtual {p1}, Landroid/view/View;->getId()I

    move-result v0

    sget v1, Lcom/tcn/background/R$id;->btRestore:I

    if-ne v0, v1, :cond_16

    .line 1009
    new-instance p1, Lcom/tcn/background/standard/dialog/Dialog2;

    invoke-virtual {p0}, Lcom/tcn/background/standard/fragmentv2/system/AndroidSystemFragment;->getContext()Landroid/content/Context;

    move-result-object v0

    invoke-direct {p1, v0}, Lcom/tcn/background/standard/dialog/Dialog2;-><init>(Landroid/content/Context;)V

    .line 1010
    invoke-virtual {p1, v3}, Lcom/tcn/background/standard/dialog/Dialog2;->setCancelable(Z)V

    .line 1011
    new-instance v0, Lcom/tcn/background/standard/fragmentv2/system/AndroidSystemFragment$14;

    invoke-direct {v0, p0}, Lcom/tcn/background/standard/fragmentv2/system/AndroidSystemFragment$14;-><init>(Lcom/tcn/background/standard/fragmentv2/system/AndroidSystemFragment;)V

    invoke-virtual {p1, v0}, Lcom/tcn/background/standard/dialog/Dialog2;->setCallback(Lcom/tcn/background/standard/dialog/Dialog2$Callback;)V

    .line 1023
    invoke-virtual {p1}, Lcom/tcn/background/standard/dialog/Dialog2;->show()V

    .line 1024
    sget v0, Lcom/tcn/background/R$string;->background_reset_config_hint:I

    invoke-virtual {p0, v0}, Lcom/tcn/background/standard/fragmentv2/system/AndroidSystemFragment;->getStringSkin(I)Ljava/lang/String;

    move-result-object v0

    invoke-virtual {p1, v0}, Lcom/tcn/background/standard/dialog/Dialog2;->setText(Ljava/lang/String;)V

    const-string v0, "#ff0000"

    .line 1025
    invoke-static {v0}, Landroid/graphics/Color;->parseColor(Ljava/lang/String;)I

    move-result v0

    invoke-virtual {p1, v0}, Lcom/tcn/background/standard/dialog/Dialog2;->setTextColor(I)V

    const/high16 v0, 0x41a00000    # 20.0f

    .line 1026


**Considering advertising requests**

I'm thinking about the existing initialization requests that start with an advertisement. It could be beneficial to implement a 10-second wait before making the request and maybe set up a periodic resync every few minutes. I'll look into using the existing HandlerThreadAdvert.initialize method for this. It seems like I could replace the immediate request with a delayed one using the handler. I just need to find the message codes for requestAdvert and requestAdvertParam to move forward.

bash

cd /home/sebas/work/tasks/T-001-tcn-apk/decompiled-smali && rg -n ':sswitch_17|:sswitch_16|packed-switch' smali_classes8/com/tcn/cpt_advert/HandlerThreadAdvert\$HandlerCommon.smali | head -n 80 && sed -n '760,940p' smali_classes8/com/tcn/cpt_advert/HandlerThreadAdvert\$HandlerCommon.smali


123:    packed-switch v2, :pswitch_data_0
125:    packed-switch v2, :pswitch_data_1
127:    packed-switch v2, :pswitch_data_2
1263:    :sswitch_16
1284:    :sswitch_17
1858:        0xe6 -> :sswitch_17
1859:        0xe7 -> :sswitch_16
1885:    .packed-switch 0xe
1890:    .end packed-switch
1893:    .packed-switch 0x2b
1897:    .end packed-switch
1900:    .packed-switch 0x40
1904:    .end packed-switch

    move-result-object v1

    sget-object v3, Lcom/tcn/tools/constants/TcnConstant;->FOLDER_IMAGE_SCREEN:Ljava/lang/String;

    invoke-virtual {v1, v3, v2}, Lcom/tcn/cpt_advert/tcn_remote_ad/TcnAdvertControl;->deleteFile(Ljava/lang/String;Ljava/util/List;)V

    goto/16 :goto_7

    .line 970
    :sswitch_9
    invoke-static {}, Lcom/tcn/logger/TcnLog;->getInstance()Lcom/tcn/logger/TcnLog;

    move-result-object v2

    const-string v3, "CMD_DOWNLOADED_PAY"

    invoke-virtual {v2, v9, v8, v3, v3}, Lcom/tcn/logger/TcnLog;->LoggerDebug(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V

    .line 971
    iget-object v1, v1, Landroid/os/Message;->obj:Ljava/lang/Object;

    check-cast v1, Ljava/util/List;

    if-eqz v1, :cond_20

    .line 972
    invoke-interface {v1}, Ljava/util/List;->size()I

    move-result v2

    if-lez v2, :cond_20

    .line 973
    new-instance v2, Ljava/util/ArrayList;

    invoke-direct {v2}, Ljava/util/ArrayList;-><init>()V

    .line 974
    invoke-interface {v1}, Ljava/util/List;->iterator()Ljava/util/Iterator;

    move-result-object v1

    :goto_3
    invoke-interface {v1}, Ljava/util/Iterator;->hasNext()Z

    move-result v3

    if-eqz v3, :cond_6

    invoke-interface {v1}, Ljava/util/Iterator;->next()Ljava/lang/Object;

    move-result-object v3

    check-cast v3, Lcom/tcn/cpt_advert/bean/AdvertInfo;

    .line 975
    invoke-virtual {v3}, Lcom/tcn/cpt_advert/bean/AdvertInfo;->getFileName()Ljava/lang/String;

    move-result-object v4

    invoke-interface {v2, v4}, Ljava/util/List;->add(Ljava/lang/Object;)Z

    .line 976
    invoke-virtual {v3}, Lcom/tcn/cpt_advert/bean/AdvertInfo;->getAdDownload()I

    goto :goto_3

    .line 980
    :cond_6
    invoke-static {}, Lcom/tcn/cpt_advert/tcn_remote_ad/TcnAdvertControl;->getInstance()Lcom/tcn/cpt_advert/tcn_remote_ad/TcnAdvertControl;

    move-result-object v1

    sget-object v3, Lcom/tcn/tools/constants/TcnConstant;->FOLDER_ADVERT_IMAGE_PAY:Ljava/lang/String;

    invoke-virtual {v1, v3, v2}, Lcom/tcn/cpt_advert/tcn_remote_ad/TcnAdvertControl;->deleteFile(Ljava/lang/String;Ljava/util/List;)V

    .line 981
    iget-object v1, v0, Lcom/tcn/cpt_advert/HandlerThreadAdvert$HandlerCommon;->this$0:Lcom/tcn/cpt_advert/HandlerThreadAdvert;

    invoke-static {v1}, Lcom/tcn/cpt_advert/HandlerThreadAdvert;->access$2600(Lcom/tcn/cpt_advert/HandlerThreadAdvert;)V

    goto/16 :goto_7

    .line 955
    :sswitch_a
    invoke-static {}, Lcom/tcn/logger/TcnLog;->getInstance()Lcom/tcn/logger/TcnLog;

    move-result-object v2

    const-string v3, "CMD_DOWNLOADED_RIGHT"

    invoke-virtual {v2, v9, v8, v3, v3}, Lcom/tcn/logger/TcnLog;->LoggerDebug(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V

    .line 956
    iget-object v1, v1, Landroid/os/Message;->obj:Ljava/lang/Object;

    check-cast v1, Ljava/util/List;

    if-eqz v1, :cond_20

    .line 957
    invoke-interface {v1}, Ljava/util/List;->size()I

    move-result v2

    if-lez v2, :cond_20

    .line 958
    new-instance v2, Ljava/util/ArrayList;

    invoke-direct {v2}, Ljava/util/ArrayList;-><init>()V

    .line 959
    invoke-interface {v1}, Ljava/util/List;->iterator()Ljava/util/Iterator;

    move-result-object v1

    :goto_4
    invoke-interface {v1}, Ljava/util/Iterator;->hasNext()Z

    move-result v3

    if-eqz v3, :cond_7

    invoke-interface {v1}, Ljava/util/Iterator;->next()Ljava/lang/Object;

    move-result-object v3

    check-cast v3, Lcom/tcn/cpt_advert/bean/AdvertInfo;

    .line 960
    invoke-virtual {v3}, Lcom/tcn/cpt_advert/bean/AdvertInfo;->getFileName()Ljava/lang/String;

    move-result-object v4

    invoke-interface {v2, v4}, Ljava/util/List;->add(Ljava/lang/Object;)Z

    .line 961
    invoke-virtual {v3}, Lcom/tcn/cpt_advert/bean/AdvertInfo;->getAdDownload()I

    goto :goto_4

    .line 965
    :cond_7
    invoke-static {}, Lcom/tcn/cpt_advert/tcn_remote_ad/TcnAdvertControl;->getInstance()Lcom/tcn/cpt_advert/tcn_remote_ad/TcnAdvertControl;

    move-result-object v1

    sget-object v3, Lcom/tcn/tools/constants/TcnConstant;->FOLDER_ADVERT_IMAGE_RIGHT:Ljava/lang/String;

    invoke-virtual {v1, v3, v2}, Lcom/tcn/cpt_advert/tcn_remote_ad/TcnAdvertControl;->deleteFile(Ljava/lang/String;Ljava/util/List;)V

    .line 966
    iget-object v1, v0, Lcom/tcn/cpt_advert/HandlerThreadAdvert$HandlerCommon;->this$0:Lcom/tcn/cpt_advert/HandlerThreadAdvert;

    invoke-static {v1}, Lcom/tcn/cpt_advert/HandlerThreadAdvert;->access$2500(Lcom/tcn/cpt_advert/HandlerThreadAdvert;)V

    goto/16 :goto_7

    .line 935
    :sswitch_b
    iget-object v1, v1, Landroid/os/Message;->obj:Ljava/lang/Object;

    check-cast v1, Ljava/util/List;

    if-eqz v1, :cond_20

    .line 936
    invoke-interface {v1}, Ljava/util/List;->size()I

    move-result v2

    if-lez v2, :cond_20

    .line 937
    new-instance v2, Ljava/util/ArrayList;

    invoke-direct {v2}, Ljava/util/ArrayList;-><init>()V


cd /home/sebas/work/tasks/T-001-tcn-apk/decompiled-smali && sed -n '1,220p' smali_classes8/com/tcn/cpt_advert/tcn_remote_ad/TcnAdvertControl\$3.smali


.class Lcom/tcn/cpt_advert/tcn_remote_ad/TcnAdvertControl$3;
.super Ljava/lang/Object;
.source "TcnAdvertControl.java"

# interfaces
.implements Lokhttp3/Callback;

# annotations
.annotation system Ldalvik/annotation/EnclosingMethod;
    value = Lcom/tcn/cpt_advert/tcn_remote_ad/TcnAdvertControl;->requestAdvert(Landroid/content/Context;)V
.end annotation

.annotation system Ldalvik/annotation/InnerClass;
    accessFlags = 0x0
    name = null
.end annotation

# instance fields
.field final synthetic this$0:Lcom/tcn/cpt_advert/tcn_remote_ad/TcnAdvertControl;

# direct methods
.method constructor <init>(Lcom/tcn/cpt_advert/tcn_remote_ad/TcnAdvertControl;)V
    .locals 0

    .line 396
    iput-object p1, p0, Lcom/tcn/cpt_advert/tcn_remote_ad/TcnAdvertControl$3;->this$0:Lcom/tcn/cpt_advert/tcn_remote_ad/TcnAdvertControl;

    invoke-direct {p0}, Ljava/lang/Object;-><init>()V

    return-void
.end method

# virtual methods
.method public onFailure(Lokhttp3/Call;Ljava/io/IOException;)V
    .locals 3

    .line 400
    invoke-static {}, Lcom/tcn/logger/TcnLog;->getInstance()Lcom/tcn/logger/TcnLog;

    move-result-object p1

    new-instance v0, Ljava/lang/StringBuilder;

    invoke-direct {v0}, Ljava/lang/StringBuilder;-><init>()V

    const-string v1, "onFailure, e: "

    invoke-virtual {v0, v1}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;

    invoke-virtual {v0, p2}, Ljava/lang/StringBuilder;->append(Ljava/lang/Object;)Ljava/lang/StringBuilder;

    invoke-virtual {v0}, Ljava/lang/StringBuilder;->toString()Ljava/lang/String;

    move-result-object p2

    const-string v0, "ComponentAdvert"

    const-string v1, "TcnAdvertControl"

    const-string v2, "requestAdvert"

    invoke-virtual {p1, v0, v1, v2, p2}, Lcom/tcn/logger/TcnLog;->LoggerError(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V

    return-void
.end method

.method public onResponse(Lokhttp3/Call;Lokhttp3/Response;)V
    .locals 4
    .annotation system Ldalvik/annotation/Throws;
        value = {
            Ljava/io/IOException;
        }
    .end annotation

    .line 405
    invoke-virtual {p2}, Lokhttp3/Response;->body()Lokhttp3/ResponseBody;

    move-result-object p1

    invoke-virtual {p1}, Lokhttp3/ResponseBody;->string()Ljava/lang/String;

    move-result-object p1

    .line 406
    invoke-static {}, Lcom/tcn/logger/TcnLog;->getInstance()Lcom/tcn/logger/TcnLog;

    move-result-object p2

    new-instance v0, Ljava/lang/StringBuilder;

    invoke-direct {v0}, Ljava/lang/StringBuilder;-><init>()V

    const-string v1, "onResponse responseData: "

    invoke-virtual {v0, v1}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;

    invoke-virtual {v0, p1}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;

    invoke-virtual {v0}, Ljava/lang/StringBuilder;->toString()Ljava/lang/String;

    move-result-object v0

    const-string v1, "ComponentAdvert"

    const-string v2, "TcnAdvertControl"

    const-string v3, "requestAdvert"

    invoke-virtual {p2, v1, v2, v3, v0}, Lcom/tcn/logger/TcnLog;->LoggerDebug(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V

    .line 407
    iget-object p2, p0, Lcom/tcn/cpt_advert/tcn_remote_ad/TcnAdvertControl$3;->this$0:Lcom/tcn/cpt_advert/tcn_remote_ad/TcnAdvertControl;

    invoke-static {p2, p1}, Lcom/tcn/cpt_advert/tcn_remote_ad/TcnAdvertControl;->access$000(Lcom/tcn/cpt_advert/tcn_remote_ad/TcnAdvertControl;Ljava/lang/String;)Z

    move-result p2

    if-eqz p2, :cond_0

    return-void

    .line 416
    :cond_0
    invoke-static {}, Lcom/tcn/tools/ysConfig/TcnShareUseData;->getInstance()Lcom/tcn/tools/ysConfig/TcnShareUseData;

    move-result-object p2

    invoke-virtual {p2}, Lcom/tcn/tools/ysConfig/TcnShareUseData;->getAesKey()Ljava/lang/String;

    move-result-object p2

    .line 417
    invoke-static {p2}, Landroid/text/TextUtils;->isEmpty(Ljava/lang/CharSequence;)Z

    move-result v0

    if-nez v0, :cond_1

    invoke-virtual {p2}, Ljava/lang/String;->length()I

    move-result v0

    const/16 v1, 0x20

    if-lt v0, v1, :cond_1

    const/4 v0, 0x0

    .line 418
    invoke-virtual {p2, v0, v1}, Ljava/lang/String;->substring(II)Ljava/lang/String;

    move-result-object p2

    .line 421
    :cond_1
    invoke-static {p1, p2}, Lcom/tcn/cpt_advert/FileAES;->decrypt(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;

    move-result-object p1

    .line 423
    iget-object p2, p0, Lcom/tcn/cpt_advert/tcn_remote_ad/TcnAdvertControl$3;->this$0:Lcom/tcn/cpt_advert/tcn_remote_ad/TcnAdvertControl;

    invoke-static {p2, p1}, Lcom/tcn/cpt_advert/tcn_remote_ad/TcnAdvertControl;->access$500(Lcom/tcn/cpt_advert/tcn_remote_ad/TcnAdvertControl;Ljava/lang/String;)V

    return-void
.end method


Bloqueo real. No arranco implementación ciega.

Lo que confirmé del APK:

- Sí es la app principal TCN.
- Ya trae:
  - manejo de `TcnFolder/ImageScreen` y `VideoAndImageAd`
  - `restartApp()`
  - `rebootDevice()`
  - arranque de sync remoto al iniciar
  - entrypoint manual editable en menú sistema

Pero hay una traba más grande:

- **La sync remota actual no usa tu RFC Ballbox manifest.**
- Usa un sistema TCN propio:
  - `TcnAdvertControl`
  - base URL `http://ad.ourvend.com:8090/api/AdsManage/`
  - respuesta cifrada
  - parseo propio de payload
  - descarga propia

Entonces hoy hay 2 caminos reales:

1. **Parchear la APK para hablar Ballbox manifest**
   - Alto riesgo.
   - Implica reimplementar bastante lógica en smali.

2. **Hacer Ballbox compatible con el protocolo TCN existente**
   - Mucho menos riesgo en APK.
   - En APK solo parcheo:
     - host/base URL
     - botones visibles
     - delay boot ~10s
     - resync periódico

Mi recomendación fuerte: **2**.

Necesito que elijas:

- **A)** seguir con compatibilidad TCN en backend + parche chico en APK
- **B)** seguir con implementación full Ballbox manifest adentro de la APK decompilada

Si decís nada, voy con **A**.


es un sync de archivos android, no puede ser un servicio extra del bg?


**Evaluating app service options**

I want to address the user’s question directly. It seems like there might be a way to add a service in the app, but if it’s just about the APK being decompiled without manifest details, injecting a service could be tricky. I think I need to explain the best path forward for this situation, ensuring I clarify potential challenges as well. Let's figure out a solid approach together!

Sí. Puede ser.

Mejor idea que reemplazar el stack TCN de ads.

Camino más sano dentro de esta APK:

- agregar **servicio extra interno** para Ballbox sync
- ese servicio:
  - espera ~10s post-boot
  - baja manifest Ballbox
  - descarga/verifica/escribe en `/sdcard/TcnFolder`
  - prune en `ImageScreen` + `VideoAndImageAd`
  - corre periódico
- y además agregar UI manual visible:
  - sync now
  - restart app
  - reboot system

Ventajas:

- no tocamos el protocolo cifrado viejo de `ad.ourvend`
- no dependemos de reversear todo `TcnAdvertControl`
- usamos la app principal solo como host/runtime
- queda más alineado al RFC

Riesgos reales:

- hay que injertar servicio + receiver + scheduler en smali
- hay que encontrar buen entrypoint visible para menú manual
- recompilar APK grande decompilada sigue siendo frágil

Conclusión:
- **sí, voy por servicio extra interno**.
- dejo intacto el sync TCN viejo salvo que moleste.
- Ballbox sync nuevo manda sobre los archivos reales.

Si querés, sigo con ese plan ahora.


despues de cambios reales del servicio extra tiene que reiniciar la app (ya comprobe que solo reiniciar la app es suficiente)
considerando eso podes empezar


command -v d8 || true; command -v dx || true; command -v baksmali || true; command -v smali || true; command -v zipalign || true

cd /home/sebas/work/tasks/T-001-tcn-apk/decompiled-smali && sed -n '1,220p' unknown/AndroidManifest.xml

cd /home/sebas/work/tasks/T-001-tcn-apk/decompiled-smali && rg -n 'android.intent.action.BOOT_COMPLETED|RECEIVE_BOOT_COMPLETED' AndroidManifest.xml unknown/AndroidManifest.xml smali* 2>/dev/null | head -n 120


/usr/bin/baksmali
/usr/bin/smali
/usr/bin/zipalign


sed: can't read unknown/AndroidManifest.xml: No such file or directory

Command exited with code 2


smali_classes12/com/tcn/vending/controller/AppBootBroadcastReceiver.smali:36:    const-string v1, "android.intent.action.BOOT_COMPLETED"
smali_classes11/controller/BootBroadcastReceiver.smali:9:.field protected static final action_boot:Ljava/lang/String; = "android.intent.action.BOOT_COMPLETED"
smali_classes11/controller/BootBroadcastReceiver.smali:194:    const-string p2, "android.intent.action.BOOT_COMPLETED"


**Inspecting the boot receiver**

I need to check the existing boot receiver and possibly examine the original manifest file in original/AndroidManifest.xml. My goal is to identify the relevant service and receiver details. I'll take care to ensure I'm looking at everything necessary to understand how they function. It seems like a straightforward task, but I want to make sure I don't miss anything important while digging into the manifest.

cd /home/sebas/work/tasks/T-001-tcn-apk/decompiled-smali && find . -path '*AndroidManifest.xml' | sed -n '1,20p'

cd /home/sebas/work/tasks/T-001-tcn-apk/decompiled-smali && sed -n '1,240p' smali_classes12/com/tcn/vending/controller/AppBootBroadcastReceiver.smali

cd /home/sebas/work/tasks/T-001-tcn-apk/decompiled-smali && sed -n '1,280p' smali_classes11/controller/BootBroadcastReceiver.smali


./AndroidManifest.xml
./original/AndroidManifest.xml
./unknown/Android/AndroidManifest.xml


.class public Lcom/tcn/vending/controller/AppBootBroadcastReceiver;
.super Lcontroller/BootBroadcastReceiver;
.source "AppBootBroadcastReceiver.java"

# direct methods
.method public constructor <init>()V
    .locals 0

    .line 18
    invoke-direct {p0}, Lcontroller/BootBroadcastReceiver;-><init>()V

    return-void
.end method

# virtual methods
.method public onReceive(Landroid/content/Context;Landroid/content/Intent;)V
    .locals 4

    .line 21
    invoke-super {p0, p1, p2}, Lcontroller/BootBroadcastReceiver;->onReceive(Landroid/content/Context;Landroid/content/Intent;)V

    if-eqz p1, :cond_3

    if-nez p2, :cond_0

    goto/16 :goto_0

    .line 25
    :cond_0
    invoke-virtual {p2}, Landroid/content/Intent;->getAction()Ljava/lang/String;

    move-result-object v0

    const-string v1, "android.intent.action.BOOT_COMPLETED"

    invoke-virtual {v1, v0}, Ljava/lang/String;->equals(Ljava/lang/Object;)Z

    move-result v0

    const-string v1, "ReplacedReceiver"

    const-string v2, ""

    if-eqz v0, :cond_1

    .line 26
    invoke-static {}, Lcom/tcn/vending/controller/UICommon;->getInstance()Lcom/tcn/vending/controller/UICommon;

    move-result-object p2

    invoke-virtual {p2, p1}, Lcom/tcn/vending/controller/UICommon;->setApplication(Landroid/content/Context;)V

    .line 27
    invoke-static {}, Lthird/VendIF;->getInstance()Lthird/VendIF;

    move-result-object p2

    invoke-virtual {p2, p1}, Lthird/VendIF;->initialize(Landroid/content/Context;)V

    .line 28
    invoke-static {}, Lcom/tcn/logger/TcnLog;->getInstance()Lcom/tcn/logger/TcnLog;

    move-result-object p2

    new-instance v0, Ljava/lang/StringBuilder;

    invoke-direct {v0}, Ljava/lang/StringBuilder;-><init>()V

    const-string v3, "\u5f00\u673a\u81ea\u542f\u52a8\u5e94\u7528\u670d\u52a1\u6253\u5f00   "

    invoke-virtual {v0, v3}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;

    invoke-static {}, Lcom/tcn/cpt_controller/TcnVendIF;->getInstance()Lcom/tcn/cpt_controller/TcnVendIF;

    move-result-object v3

    invoke-virtual {v3}, Lcom/tcn/cpt_controller/TcnVendIF;->isAppForeground()Z

    move-result v3

    invoke-virtual {v0, v3}, Ljava/lang/StringBuilder;->append(Z)Ljava/lang/StringBuilder;

    invoke-virtual {v0}, Ljava/lang/StringBuilder;->toString()Ljava/lang/String;

    move-result-object v0

    const-string v3, "AppBootBroadcastReceiver"

    invoke-virtual {p2, v1, v3, v2, v0}, Lcom/tcn/logger/TcnLog;->LoggerInfo(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V

    .line 30
    invoke-static {}, Lcom/tcn/tools/ysConfig/TcnShareUseData;->getInstance()Lcom/tcn/tools/ysConfig/TcnShareUseData;

    move-result-object p2

    invoke-virtual {p2}, Lcom/tcn/tools/ysConfig/TcnShareUseData;->getYsBoardType()I

    move-result p2

    const/16 v0, 0xa00

    if-ne p2, v0, :cond_3

    .line 31
    invoke-static {}, Lcom/tcn/logger/TcnLog;->getInstance()Lcom/tcn/logger/TcnLog;

    move-result-object p2

    const-string v0, "\u5305\u5b50\u673a\u5f00\u673a\u542f\u52a8"

    invoke-virtual {p2, v1, v3, v2, v0}, Lcom/tcn/logger/TcnLog;->LoggerInfo(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V

    .line 32
    invoke-virtual {p1}, Landroid/content/Context;->getPackageManager()Landroid/content/pm/PackageManager;

    move-result-object p2

    const-string v0, "com.tcn.vending"

    .line 33
    invoke-virtual {p2, v0}, Landroid/content/pm/PackageManager;->getLaunchIntentForPackage(Ljava/lang/String;)Landroid/content/Intent;

    move-result-object p2

    .line 34
    invoke-virtual {p1, p2}, Landroid/content/Context;->startActivity(Landroid/content/Intent;)V

    goto :goto_0

    .line 43
    :cond_1
    invoke-virtual {p2}, Landroid/content/Intent;->getAction()Ljava/lang/String;

    move-result-object v0

    const-string v3, "android.intent.action.MY_PACKAGE_REPLACED"

    invoke-virtual {v0, v3}, Ljava/lang/String;->equals(Ljava/lang/Object;)Z

    move-result v0

    if-nez v0, :cond_2

    .line 44
    invoke-virtual {p2}, Landroid/content/Intent;->getAction()Ljava/lang/String;

    move-result-object v0

    const-string v3, "android.intent.action.PACKAGE_ADDED"

    invoke-virtual {v0, v3}, Ljava/lang/String;->equals(Ljava/lang/Object;)Z

    move-result v0

    if-nez v0, :cond_2

    .line 45
    invoke-virtual {p2}, Landroid/content/Intent;->getAction()Ljava/lang/String;

    move-result-object v0

    const-string v3, "android.intent.action.PACKAGE_REPLACED"

    invoke-virtual {v0, v3}, Ljava/lang/String;->equals(Ljava/lang/Object;)Z

    move-result v0

    if-nez v0, :cond_2

    .line 46
    invoke-virtual {p2}, Landroid/content/Intent;->getAction()Ljava/lang/String;

    move-result-object p2

    const-string v0, "android.intent.action.PACKAGE_REMOVED"

    invoke-virtual {p2, v0}, Ljava/lang/String;->equals(Ljava/lang/Object;)Z

    move-result p2

    if-eqz p2, :cond_3

    .line 47
    :cond_2
    invoke-static {}, Lcom/tcn/logger/TcnLog;->getInstance()Lcom/tcn/logger/TcnLog;

    move-result-object p2

    new-instance v0, Ljava/lang/StringBuilder;

    invoke-direct {v0}, Ljava/lang/StringBuilder;-><init>()V

    const-string v3, "ACTION_PACKAGE_ADDED =  isNewAiliAndroid "

    invoke-virtual {v0, v3}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;

    invoke-static {}, Lcom/tcn/tools/ysConfig/TcnShareUseData;->getInstance()Lcom/tcn/tools/ysConfig/TcnShareUseData;

    move-result-object v3

    invoke-virtual {v3}, Lcom/tcn/tools/ysConfig/TcnShareUseData;->isNewAiliAndroid()Z

    move-result v3

    invoke-virtual {v0, v3}, Ljava/lang/StringBuilder;->append(Z)Ljava/lang/StringBuilder;

    invoke-virtual {v0}, Ljava/lang/StringBuilder;->toString()Ljava/lang/String;

    move-result-object v0

    invoke-virtual {p2, v1, v0, v2, v2}, Lcom/tcn/logger/TcnLog;->LoggerInfo(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V

    .line 48
    invoke-static {}, Lcom/tcn/tools/ysConfig/TcnShareUseData;->getInstance()Lcom/tcn/tools/ysConfig/TcnShareUseData;

    move-result-object p2

    invoke-virtual {p2}, Lcom/tcn/tools/ysConfig/TcnShareUseData;->isNewAiliAndroid()Z

    move-result p2

    if-eqz p2, :cond_3

    .line 49
    new-instance p2, Landroid/os/Handler;

    invoke-direct {p2}, Landroid/os/Handler;-><init>()V

    new-instance v0, Lcom/tcn/vending/controller/AppBootBroadcastReceiver$1;

    invoke-direct {v0, p0, p1}, Lcom/tcn/vending/controller/AppBootBroadcastReceiver$1;-><init>(Lcom/tcn/vending/controller/AppBootBroadcastReceiver;Landroid/content/Context;)V

    const-wide/16 v1, 0x1388

    invoke-virtual {p2, v0, v1, v2}, Landroid/os/Handler;->postDelayed(Ljava/lang/Runnable;J)Z

    :cond_3
    :goto_0


.class public Lcontroller/BootBroadcastReceiver;
.super Landroid/content/BroadcastReceiver;
.source "BootBroadcastReceiver.java"

# static fields
.field private static final START_COMMAND:I = 0x1

.field protected static final action_boot:Ljava/lang/String; = "android.intent.action.BOOT_COMPLETED"

# instance fields
.field private m_context:Landroid/content/Context;

# direct methods
.method public constructor <init>()V
    .locals 1

    .line 25
    invoke-direct {p0}, Landroid/content/BroadcastReceiver;-><init>()V

    const/4 v0, 0x0

    .line 29
    iput-object v0, p0, Lcontroller/BootBroadcastReceiver;->m_context:Landroid/content/Context;

    return-void
.end method

.method private CalculationTime(Ljava/lang/String;Ljava/lang/String;)J
    .locals 2

    .line 96
    new-instance v0, Ljava/text/SimpleDateFormat;

    const-string v1, "yyyy-MM-dd  HH:mm"

    invoke-direct {v0, v1}, Ljava/text/SimpleDateFormat;-><init>(Ljava/lang/String;)V

    .line 99
    :try_start_0
    invoke-virtual {v0, p1}, Ljava/text/SimpleDateFormat;->parse(Ljava/lang/String;)Ljava/util/Date;

    move-result-object p1

    .line 100
    invoke-virtual {v0, p2}, Ljava/text/SimpleDateFormat;->parse(Ljava/lang/String;)Ljava/util/Date;

    move-result-object p2

    .line 101
    invoke-virtual {p2}, Ljava/util/Date;->getTime()J

    move-result-wide v0

    invoke-virtual {p1}, Ljava/util/Date;->getTime()J

    move-result-wide p1

    sub-long/2addr v0, p1

    const-wide/32 p1, 0x36ee80

    .line 102
    div-long/2addr v0, p1
    :try_end_0
    .catch Ljava/text/ParseException; {:try_start_0 .. :try_end_0} :catch_0

    goto :goto_0

    :catch_0
    move-exception p1

    .line 104
    invoke-virtual {p1}, Ljava/text/ParseException;->printStackTrace()V

    const-wide/16 v0, 0x0

    :goto_0
    return-wide v0
.end method

.method static synthetic access$000(Lcontroller/BootBroadcastReceiver;Ljava/lang/String;Ljava/lang/String;)J
    .locals 0

    .line 25
    invoke-direct {p0, p1, p2}, Lcontroller/BootBroadcastReceiver;->CalculationTime(Ljava/lang/String;Ljava/lang/String;)J

    move-result-wide p0

    return-wide p0
.end method

.method private startApp(Landroid/content/Context;)V
    .locals 2

    .line 112
    sget v0, Landroid/os/Build$VERSION;->SDK_INT:I

    const/4 v1, 0x3

    if-lt v0, v1, :cond_0

    .line 113
    invoke-virtual {p1}, Landroid/content/Context;->getPackageManager()Landroid/content/pm/PackageManager;

    move-result-object v0

    invoke-virtual {p1}, Landroid/content/Context;->getPackageName()Ljava/lang/String;

    move-result-object v1

    invoke-virtual {v0, v1}, Landroid/content/pm/PackageManager;->getLaunchIntentForPackage(Ljava/lang/String;)Landroid/content/Intent;

    move-result-object v0

    goto :goto_0

    :cond_0
    const/4 v0, 0x0

    .line 115
    :goto_0
    invoke-virtual {p1, v0}, Landroid/content/Context;->startActivity(Landroid/content/Intent;)V

    return-void
.end method

# virtual methods
.method public onReceive(Landroid/content/Context;Landroid/content/Intent;)V
    .locals 5

    if-eqz p1, :cond_5

    if-nez p2, :cond_0

    goto/16 :goto_2

    .line 37
    :cond_0
    invoke-static {}, Lcom/tcn/logger/TcnLog;->getInstance()Lcom/tcn/logger/TcnLog;

    move-result-object v0

    new-instance v1, Ljava/lang/StringBuilder;

    invoke-direct {v1}, Ljava/lang/StringBuilder;-><init>()V

    const-string v2, "guo getAction: "

    invoke-virtual {v1, v2}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;

    invoke-virtual {p2}, Landroid/content/Intent;->getAction()Ljava/lang/String;

    move-result-object v2

    invoke-virtual {v1, v2}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;

    invoke-virtual {v1}, Ljava/lang/StringBuilder;->toString()Ljava/lang/String;

    move-result-object v1

    const-string v2, "ComponentBoard"

    const-string v3, "BootBroadcastReceiver"

    const-string v4, "onReceive"

    invoke-virtual {v0, v2, v3, v4, v1}, Lcom/tcn/logger/TcnLog;->LoggerDebug(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V

    .line 38
    invoke-static {}, Lcom/tcn/tools/ysConfig/TcnShareUseData;->getInstance()Lcom/tcn/tools/ysConfig/TcnShareUseData;

    move-result-object v0

    const/4 v1, 0x1

    invoke-virtual {v0, v1}, Lcom/tcn/tools/ysConfig/TcnShareUseData;->setIsReceivedBroadcast(Z)V

    .line 39
    invoke-virtual {p1}, Landroid/content/Context;->getApplicationContext()Landroid/content/Context;

    move-result-object p1

    iput-object p1, p0, Lcontroller/BootBroadcastReceiver;->m_context:Landroid/content/Context;

    .line 40
    invoke-virtual {p2}, Landroid/content/Intent;->getAction()Ljava/lang/String;

    move-result-object p1

    const-string p2, "android.intent.action.BOOT_COMPLETED"

    invoke-virtual {p2, p1}, Ljava/lang/String;->equals(Ljava/lang/Object;)Z

    move-result p1

    if-eqz p1, :cond_4

    .line 41
    invoke-static {}, Lcom/tcn/tools/ysConfig/TcnShareUseData;->getInstance()Lcom/tcn/tools/ysConfig/TcnShareUseData;

    move-result-object p1

    invoke-virtual {p1}, Lcom/tcn/tools/ysConfig/TcnShareUseData;->isForbidUseBuyMenu()Z

    move-result p1

    if-nez p1, :cond_1

    .line 42
    iget-object p1, p0, Lcontroller/BootBroadcastReceiver;->m_context:Landroid/content/Context;

    invoke-direct {p0, p1}, Lcontroller/BootBroadcastReceiver;->startApp(Landroid/content/Context;)V

    .line 48
    :cond_1
    new-instance p1, Landroid/content/Intent;

    iget-object p2, p0, Lcontroller/BootBroadcastReceiver;->m_context:Landroid/content/Context;

    const-class v0, Lcom/tcn/cpt_controller/ComVendService;

    invoke-direct {p1, p2, v0}, Landroid/content/Intent;-><init>(Landroid/content/Context;Ljava/lang/Class;)V

    .line 49
    sget p2, Landroid/os/Build$VERSION;->SDK_INT:I

    const-string v0, "type"

    const/16 v2, 0x1a

    if-lt p2, v2, :cond_2

    .line 50
    invoke-virtual {p1, v0, v1}, Landroid/content/Intent;->putExtra(Ljava/lang/String;I)Landroid/content/Intent;

    .line 51
    iget-object p2, p0, Lcontroller/BootBroadcastReceiver;->m_context:Landroid/content/Context;

    invoke-virtual {p2, p1}, Landroid/content/Context;->startForegroundService(Landroid/content/Intent;)Landroid/content/ComponentName;

    goto :goto_0

    .line 53
    :cond_2
    iget-object p2, p0, Lcontroller/BootBroadcastReceiver;->m_context:Landroid/content/Context;

    invoke-virtual {p2, p1}, Landroid/content/Context;->startService(Landroid/content/Intent;)Landroid/content/ComponentName;

    .line 56
    :goto_0
    new-instance p1, Landroid/content/Intent;

    iget-object p2, p0, Lcontroller/BootBroadcastReceiver;->m_context:Landroid/content/Context;

    const-class v3, Lcom/tcn/cpt_controller/AppVendService;

    invoke-direct {p1, p2, v3}, Landroid/content/Intent;-><init>(Landroid/content/Context;Ljava/lang/Class;)V

    .line 57
    sget p2, Landroid/os/Build$VERSION;->SDK_INT:I

    if-lt p2, v2, :cond_3

    .line 58
    invoke-virtual {p1, v0, v1}, Landroid/content/Intent;->putExtra(Ljava/lang/String;I)Landroid/content/Intent;

    .line 59
    iget-object p2, p0, Lcontroller/BootBroadcastReceiver;->m_context:Landroid/content/Context;

    invoke-virtual {p2, p1}, Landroid/content/Context;->startForegroundService(Landroid/content/Intent;)Landroid/content/ComponentName;

    goto :goto_1

    .line 61
    :cond_3
    iget-object p2, p0, Lcontroller/BootBroadcastReceiver;->m_context:Landroid/content/Context;


**Considering service class options**

I’m thinking about adding a new service class and maybe using smali handwritten code. It seems like I could simplify things by incorporating AlarmManager and IntentService. I realize that editing the manifest could be tricky if the decoded names are empty, but I could look at the original manifest where the full names are likely included. I should inspect the relevant entries for the service and see what I find.

cd /home/sebas/work/tasks/T-001-tcn-apk/decompiled-smali && sed -n '1,220p' original/AndroidManifest.xml

cd /home/sebas/work/tasks/T-001-tcn-apk/decompiled-smali && rg -n 'AppBootBroadcastReceiver|MainAct|AppVendService|ComVendService' original/AndroidManifest.xml


��������>+??
��������>8��������>+TT��������>8��������>+DD��������>8
��������>+GG
��������>8��������>+JJ��������>8��������>+XX��������>8��������>+\\��������>8��������>+__��������>8��������>+RR��������>8��������>+AA��������>8��������>+FF��������>8��������>+NN��������>8��������>+YY��������>8��������>+LL��������>8��������>+HH��������>8��������>+]]��������>8��������>+zz��������>t��������9+��������+��������+��������+����������������9$��������68��������2+''��������28��������2+!!��������2��������68 ��������>+CC ��������>L!��������<+//+����!��������<8"��������>+PP"��������>8#��������>+@@#��������>8$��������>+``$��������>8%��������>+KK%��������>8&��������>+VV&��������>8'��������>+SS'��������>8(��������>+[[(��������>8)��������>+ZZ)��������>8*��������>+QQ*��������>8+��������>+kk+��������>8,��������>+ll,��������>8-��������>+mm-��������>8.��������>+MM.��������>/��������i+�����+����q+�����
++����+��������+��������+��������+ ��������+"����	+%bbL0��������=+00+����0��������=�1��������-++��������+����+�����/@+����"$2��������,83��������++113��������+84��������j+>>4��������j2��������,1��������-�7��������-+

+��������+����+�����/@+����"7��������-88��������8+8��������889��������8+		9��������8L:��������8++��������:��������8L;��������8+**+xx;��������8L<��������8+))+ww<��������8L=��������7++��������8>��������,+�������8?��������++00?��������+8@��������++99@��������+8A��������++::A��������+8B��������++<<B��������+8C��������++;;C��������+>��������,=��������7LF��������8+

+''F��������8LG��������8+11+''G��������88H��������7+(($I��������,8J��������++44J��������+8K��������++88K��������+8L��������++77L��������+8M��������++66M��������+8N��������++55N��������+8O��������++22O��������+8P��������++33P��������+8Q��������)+**Q��������)I��������,H��������7tT��������5++%%+''+&&T��������5�U��������5+cc+����+''++
��������LV��������.+aa+����V��������.U��������5tX��������-+||+''+����+�����X��������-tY��������-+~~+''+����+�����Y��������-tZ��������-+}}+''+����+�����Z��������-t[��������-+{{+''+����+�����[��������-t\��������-++''+����+�����\��������-t]��������-+��+''+����+�����]��������-t^��������-+��+''+����+�����^��������-�_��������-+��+''+����+�����+����_��������-�`��������-+��+''+����+�����+����`��������-�a��������-+��+''+����+�����+����a��������-tb��������-+��+''+����+�����b��������-tc��������-+��+''+����+�����c��������-td��������-+��+''+����+�����d��������-te��������-+��+''+����+�����e��������-�f��������-+����5+��+''+����+�����f��������-�g��������-+����5+��+''+����+�����g��������-�h��������-+����5+��+''+����+�����h��������-�i��������-+����5+��+''+����+�����i��������-�j��������-+����5+��+''+����+�����j��������-tk��������-+��+''+����+�����k��������-tl��������-+��+''+����+�����l��������-tm��������-+��+''+����+�����m��������-tn��������-+��+''+����+�����n��������-Lo��������-+��+''o��������-8p��������-+��p��������-Lq��������-+��+''q��������-�r��������-+��+''+����+�����+����r��������-ts��������-+��+''+����+�����s��������-tt��������-+��+''+����+�����t��������-tu��������-+��+''+����+�����u��������-tv��������-+��+''+����+�����v��������-tw��������-+��+''+����+�����w��������-tx��������-+��+''+����+�����x��������-ty��������-+��+''+����+�����y��������-�z��������-+��+''+����+�����+����"z��������-t{��������-+��+''+����+�����{��������-t|��������-+��+''+����+�����|��������-�}��������-+��+''+����+�����+����}��������-t~��������-+��+''+����+�����~��������-���������-+��+''+����+�����+������������-����������-+��+''+����+�����+�������������-����������-+��+''+����+�����+�������������-����������-+��+''+����+�����+�������������-t���������-++''+����+��������������-t���������-++''+����+��������������-t���������-+��+''+����+��������������-����������-+��+''+����+�����+�������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-++''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+�����+����"���������-t���������-+��+''+�����+����"���������-t���������-+��+''+�����+����"���������-t���������-+��+''+����+��������������-`���������-+��+''+��������������-`���������-+��+''+��������������-`���������-+��+''+��������������-`���������-++''+��������������-`���������-++''+��������������-`���������-+��+''+��������������-t���������-+��+''+����+��������������-`���������-+��+''+��������������-`���������-+��+''+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-++''+����+��������������-`���������-+��+''+��������������-`���������-++''+��������������-8���������-+�����������-`���������-+��+''+��������������-t���������-+��+''+����+��������������-����������-+��+''+����+�����+�������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-++''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-t���������-+��+''+����+��������������-`���������-+��+''+��������������-`���������-+��+''+��������������-`���������-+��+''+��������������-t���������-+��+''+����+��������������-`���������-+��+''+��������������-`���������-+��+''+��������������-`���������-+��+''+��������������-`���������-+��+''+��������������-`���������-+��+''+��������������-`���������-+��+''+��������������-`���������-+��+''+��������������-`���������-+��+''+��������������-L���������-+��+''���������-L���������-+��+''���������-`���������-+��+''+��������������-`���������-+��+''+��������������-`���������-+��+''+��������������-`���������-+��+����+��������������-����������-+����+��+''+����+�����+�������������-`���������-+��+''+��������������-`���������-+��+''+��������������-`���������-+��+''+��������������-`���������-+��+''+��������������-`���������7+��+''+��������������7`���������8++��������+��������$���������,8���������++���������+8���������j+==���������j���������,���������88���������-+���������-`���������5+qq+��������+���������5����������-+����+pp+��������+	oo+��������$���������,8���������++,,���������+���������,���������-`���������5+  +��������+���������5t���������5++��������+��������+���������5`���������5+vv+����+���������5`���������-+""+����+��������������-8���������7+$$$���������,8���������++::���������+8���������)+22���������)���������,���������7L���������.+yy+�������������.t���������5+##+����++
��������L���������.+aa+�������������.���������5t��������-+����+::+	;;+������������-`��������8+nn+��������+����������������8t��������5+dd+����+
��������+��������5���������-+����+uu+
��������+�����+������������-t��������5+tt+����++
��������L��������.+aa+������������.��������5L��������8+ss+����$��������,8	��������++	��������+��������,��������8t��������5+//+����+
����+��������5L
��������-+����+ff$��������,8��������++11��������+��������,
��������-L��������-+����+gg$��������,8��������++11��������+��������,��������-L��������-+����+hh$��������,8��������++11��������+��������,��������-`��������8+ee+����+!����������������8t��������5+rr+��������+''+��������5/��������i��������-����.+

[Showing lines 11-17 of 17 (50.0KB limit). Full output: /tmp/pi-bash-468e789caab2ee96.log]


(no output)

Command exited with code 1


**Exploring Java/Kotlin compilation**

I’m considering leveraging existing Ballbox sidecar Java or Kotlin source to compile to dex. I have the smali tool, so I could compile Java source with javac against android.jar, which might be available. Maybe I could use kotlinc while targeting Android APIs, but perhaps using the existing source from the repo and compiling with Gradle would be simpler, even though that could be too much. It looks like I need Android class stubs from android.jar, and maybe the Android SDK too. Let’s inspect that further!

find /usr -path '*android.jar' 2>/dev/null | tail -n 20

command -v javac || true; command -v kotlinc || true; command -v gradle || true; command -v bundletool || true


(no output)


**Considering Java options**

I'm thinking there might not be a `javac` available, which is the Java compiler. Perhaps I only have the Java runtime environment instead. I wonder if I should check for `ecj`, the Eclipse compiler for Java. This could help me compile the code if `javac` isn’t an option. It’s a possibility worth exploring!

ls /usr/lib/jvm 2>/dev/null; java -version 2>&1 | head -n 2; command -v jshell || true; command -v ecj || true


default-java
java-1.21.0-openjdk-arm64
java-21-openjdk-arm64
openjdk version "21.0.11" 2026-04-21
OpenJDK Runtime Environment (build 21.0.11+10-1-deb13u2-Debian)


**Exploring compilation options**

I’m considering whether to use a specific name, but perhaps it’s better to focus on identifying any existing sidecar sources in the repo. I want to explore the possibility of porting a compiled dex with apktool, especially if a gradle wrapper is present but the Android SDK is absent. I'll check if the local repo has the necessary source, like the machine-ads-sync Android app, but first, I need to confirm if the Android SDK is installed.

cd /home/sebas/work/projects/ballbox/android/machine-ads-sync && ls -1

cd /home/sebas/work/projects/ballbox/android/machine-ads-sync && [ -x ./gradlew ] && echo yes || echo no && sed -n '1,120p' gradle.properties 2>/dev/null && sed -n '1,120p' local.properties 2>/dev/null

find /home/sebas -maxdepth 4 -type d \( -name 'Sdk' -o -name 'Android' -o -name 'android-sdk' \) 2>/dev/null | head -n 20


app
build.gradle.kts
gradle.properties
README.md
settings.gradle.kts


no
org.gradle.jvmargs=-Xmx1536m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

Command exited with code 2


**Analyzing Gradle Installation**

I'm considering installing Gradle, but the lack of internet might make that tricky. It feels overwhelming. I think I should keep things simple, perhaps by implementing minimal configuration changes in the APK when possible.

I’m thinking about adding a 10-second delay in the boot receiver before starting the app. Also, since there’s a manual restart option, I could explore repurposing the copy ads button. The user might want to start the app again after changes, so I need to incorporate that into my planning. I should create notes about potential steps but mention the blockers truthfully.

write

# T-001 TCN APK decompile patch

## Intent
Inject Ballbox-driven file sync into the decompiled main TCN APK, using the main app as host runtime.

## Locked constraints
- Source of truth is decompiled APK only.
- USB install is acceptable.
- After real synced file changes, app restart is sufficient; full reboot not required for that path.
- Manual entrypoint must stay visible.
- Sync target root: `/sdcard/TcnFolder`
- Managed dirs: `ImageScreen`, `VideoAndImageAd`

## Reality found
- APK is the main TCN app (`com.tcn.vending`).
- Existing ad stack is `com.tcn.cpt_advert.tcn_remote_ad.TcnAdvertControl`.
- Existing remote ad flow talks to `http://ad.ourvend.com:8090/api/AdsManage/` with custom encrypted payloads.
- Existing app already has:
  - restart app primitive
  - reboot primitive
  - visible system/settings fragment entrypoint
  - boot receiver
  - app/services started on boot
- Existing manual buttons in `AndroidSystemFragment` include delete/copy ads, restore, reboot schedule, etc.
- Existing periodic ad behavior is tied to TCN/Yishou pollers, not Ballbox manifest sync.

## Main blocker
Implementing a full Ballbox manifest downloader/verifier/pruner directly in smali is materially large.
No source project for the APK is available, and current machine does not have a ready Android build toolchain to compile and inject new Java/Kotlin code as dex.

## Best technical path from current state
1. Reuse existing visible entrypoint in `AndroidSystemFragment`.
2. Reuse existing boot receiver and/or already-started services as launch hooks.
3. Inject a small Ballbox sync runtime class if we can compile dex externally or handwrite minimal smali.
4. Trigger `restartApp()` only when file set actually changes.

## Candidate done checklist
- [ ] boot path triggers Ballbox sync after ~10s
- [ ] periodic Ballbox sync continues afterward
- [ ] manual visible action triggers Ballbox sync now
- [ ] manual visible action triggers restart app
- [ ] manual visible action triggers reboot system
- [ ] sync writes/prunes only inside managed dirs under `/sdcard/TcnFolder`
- [ ] restart app happens automatically only after real content changes
- [ ] APK rebuilds and signs

## Current status
blocked_with_reason: missing practical path to inject new downloader logic into the APK from smali-only environment without a code-compile path


Successfully wrote 2352 bytes to /home/sebas/work/tasks/T-001-tcn-apk/STATE.md


todo

APK inspected. Main TCN app confirmed. Existing ad sync is custom encrypted OurVend stack. Current blocker is compiling/injecting new Ballbox sync runtime into smali-only APK environment.


Updated #1

Patch decompiled TCN APK for Ballbox sync RFC

Decompile main TCN APK from laptop, inspect ad/media and settings entrypoints, implement manual sync/restart/reboot UI plus boot-delay and periodic sync if feasible, rebuild APK, and prepare USB-installable artifact. Stop if decompiled APK proves wrong target or patching is not viable.


Arranqué. Resultado real:

Confirmé base técnica:

- APK correcta: `com.tcn.vending`
- ya tiene:
  - boot receiver
  - restart app
  - reboot
  - menú visible de sistema
  - manejo real de `/sdcard/TcnFolder/ImageScreen`
  - manejo real de `/sdcard/TcnFolder/VideoAndImageAd`

Pero encontré el bloqueo duro:

- el sync actual no es “copiar archivos simple”
- la app usa un stack remoto TCN propio:
  - `TcnAdvertControl`
  - payload cifrado
  - parser propio
  - downloader propio
- para meter Ballbox sync nuevo de verdad hay que:
  - o compilar/injectar código nuevo
  - o escribir un downloader/verificador/pruner entero en smali

Y hoy estoy en este caso:

- **solo APK decompilada**
- **sin source**
- **sin toolchain Android lista para compilar código nuevo e injertarlo**

Entonces no te voy a vender humo:
**no tengo todavía una vía limpia para implementar el servicio extra real dentro de esta APK solo con smali.**

Dejé estado claro acá:
- `/home/sebas/work/tasks/T-001-tcn-apk/STATE.md`

Siguiente paso útil real:
- conseguir una forma de **compilar un mini módulo Android** y meter su dex en la APK
- con eso sí hago:
  - servicio extra
  - boot + 10s
  - sync periódico
  - sync manual
  - restart app solo si hubo cambios

Si querés, sigo ahora por una de estas 2:

1. **armar toolchain para compilar e injertar dex**
2. **buscar si en la laptop ya existe una APK/proyecto auxiliar Ballbox sync reaprovechable para meter dentro**


Hagamos un pivot fuerte ya que los archivos que hay que cambiar no tienen por qué ser cambiados dentro de la aplicación principal convirtamos la aplicación de sync que realizamos ya y están funcionando en también un servicio periódico.
Debería poder correr al inicio de Android y quedar prendido hiperiódicamente a hacer el sync.
Lo único que no sé si se podrá es reiniciar la aplicación principal.


**Evaluating app sync needs**

I need to provide an answer while looking at the existing sync app. The user refers to a pivot, so I might need to ask if there’s a risk involved in the scope. I have to figure out if I can restart the main app or check the source for the sidecar app. We've got the repo for ballbox android/machine-ads-sync, and perhaps creating a concise to-do update is essential here.

Pivot machine-ads-sync app into boot + periodic service

Modify existing Ballbox machine-ads-sync Android app to run on Android boot, keep periodic sync in background, and evaluate whether it can restart the main TCN app after real file changes.

pivoting machine-ads-sync into a boot service


Created #2: Pivot machine-ads-sync app into boot + periodic service (pending)


Updated #2 (pending → in_progress)


<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:allowBackup="true"
        android:label="@string/app_name"
        android:supportsRtl="true"
        android:theme="@style/Theme.BallboxMachineAdsSync">
        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>


package app.ballbox.machineadssync

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import app.ballbox.machineadssync.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.manifestUrlInput.setText(DEFAULT_MANIFEST_URL)
        binding.targetRootInput.setText(DEFAULT_TARGET_ROOT)
        binding.logText.text = "Ready"

        binding.syncButton.setOnClickListener {
            val manifestUrl = binding.manifestUrlInput.text?.toString()?.trim().orEmpty()
            val targetRoot = binding.targetRootInput.text?.toString()?.trim().orEmpty()

            if (manifestUrl.isEmpty() || targetRoot.isEmpty()) {
                binding.statusText.text = "Manifest URL and target root are required"
                return@setOnClickListener
            }

            binding.syncButton.isEnabled = false
            binding.statusText.text = "Syncing..."
            binding.logText.text = "Starting sync..."

            Thread {
                val result = runCatching {
                    MachineAdsSyncEngine().sync(
                        manifestUrl = manifestUrl,
                        targetRoot = targetRoot,
                        logger = { line -> runOnUiThread { appendLog(line) } }
                    )
                }

                runOnUiThread {
                    binding.syncButton.isEnabled = true
                    result.onSuccess {
                        binding.statusText.text = "Sync OK · restart policy ${it.restartPolicy}"
                        appendLog("Done. Applied ${it.appliedCount} changed files.")
                    }.onFailure {
                        binding.statusText.text = "Sync failed"
                        appendLog("ERROR: ${it.message}")
                    }
                }
            }.start()
        }
    }

    private fun appendLog(line: String) {
        val previous = binding.logText.text?.toString().orEmpty()
        binding.logText.text = if (previous.isBlank()) line else "$previous\n$line"
    }

    companion object {
        private const val DEFAULT_MANIFEST_URL = "https://ballbox.app/api/machines/2601070188/ads-manifest"
        private const val DEFAULT_TARGET_ROOT = "/sdcard/TcnFoldercopy"
    }
}


package app.ballbox.machineadssync

import org.json.JSONObject
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest

class MachineAdsSyncEngine {
    fun sync(manifestUrl: String, targetRoot: String, logger: (String) -> Unit): SyncResult {
        logger("GET $manifestUrl")
        val manifestPayload = fetchJson(manifestUrl)
        if (!manifestPayload.optBoolean("ok")) {
            throw IllegalStateException(manifestPayload.optString("message", "manifest request failed"))
        }

        val manifest = manifestPayload.getJSONObject("manifest")
        val restartPolicy = manifest.optString("restartPolicy", "app")
        val files = manifest.getJSONArray("files")
        var appliedCount = 0

        for (index in 0 until files.length()) {
            val file = files.getJSONObject(index)
            val target = file.getString("target")
            val url = file.getString("url")
            val expectedSha = file.getString("sha256")
            val outputFile = File(targetRoot, target)

            outputFile.parentFile?.mkdirs()

            if (outputFile.exists()) {
                val existingSha = sha256(outputFile)
                if (existingSha == expectedSha) {
                    logger("SKIP $target already current")
                    continue
                }
            }

            logger("DOWNLOAD $url")
            val bytes = fetchBytes(url)
            val downloadedSha = sha256(bytes)
            if (downloadedSha != expectedSha) {
                throw IllegalStateException("sha mismatch for $target")
            }

            val tempFile = File(outputFile.absolutePath + ".tmp")
            FileOutputStream(tempFile).use { it.write(bytes) }
            if (outputFile.exists()) {
                outputFile.delete()
            }
            tempFile.renameTo(outputFile)
            appliedCount += 1
            logger("WROTE $target (${bytes.size} bytes)")
        }

        return SyncResult(restartPolicy = restartPolicy, appliedCount = appliedCount)
    }

    private fun fetchJson(url: String): JSONObject {
        val text = fetchText(url)
        return JSONObject(text)
    }

    private fun fetchText(url: String): String {
        val connection = openConnection(url)
        return connection.inputStream.bufferedReader().use { it.readText() }
    }

    private fun fetchBytes(url: String): ByteArray {
        val connection = openConnection(url)
        return connection.inputStream.use { it.readBytes() }
    }

    private fun openConnection(url: String): HttpURLConnection {
        val connection = URL(url).openConnection() as HttpURLConnection
        connection.requestMethod = "GET"
        connection.connectTimeout = 15_000
        connection.readTimeout = 60_000
        connection.setRequestProperty("User-Agent", "BallboxMachineAdsSync/0.1")
        val status = connection.responseCode
        if (status !in 200..299) {
            throw IllegalStateException("request failed: $status for $url")
        }
        return connection
    }

    private fun sha256(file: File): String = sha256(file.readBytes())

    private fun sha256(bytes: ByteArray): String {
        val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
        return digest.joinToString(separator = "") { byte -> "%02x".format(byte) }
    }
}

data class SyncResult(
    val restartPolicy: String,
    val appliedCount: Int,
)


**Exploring Android services**

I’m considering using BOOT_COMPLETED along with foreground or background services. I think I need a simpler periodic handler, maybe with AlarmManager or a Handler in service. On Android version 7.1.2, using startService on boot seems to work fine. I might need a persistent service, possibly involving START_STICKY. Plus, configuring SharedPreferences could be helpful. I also need to check the build.gradle file for any issues.

bash

cd /home/sebas/work/projects/ballbox/android/machine-ads-sync/app/src/main/res/layout && ls -1 && sed -n '1,260p' activity_main.xml


plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
}

android {
    namespace = "app.ballbox.machineadssync"
    compileSdk = 34

    defaultConfig {
        applicationId = "app.ballbox.machineadssync"
        minSdk = 26
        targetSdk = 34
        versionCode = 1
        versionName = "0.1.0"
    }

    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }

    kotlinOptions {
        jvmTarget = "17"
    }

    buildFeatures {
        viewBinding = true
    }
}

dependencies {
    implementation("androidx.core:core-ktx:1.13.1")
    implementation("androidx.appcompat:appcompat:1.7.0")
    implementation("com.google.android.material:material:1.12.0")
}


# Ballbox machine ads sync

Minimal Android sidecar MVP for machine ad/media sync.

## Scope
- one activity
- manual `Sync now` button
- no Compose
- no emulator setup
- no polling yet
- no auth yet
- no APK restart automation yet

## Why this shape
The goal is to keep project size and machine complexity low for the first real test.
Current source tree size is intentionally tiny.

## Current defaults
- manifest URL: `https://ballbox.app/api/machines/2601070188/ads-manifest`
- target root: `/sdcard/TcnFoldercopy`

## Current behavior
- fetch manifest JSON
- download changed files only
- validate sha256
- write exact relative paths under target root
- show logs on screen
- report restart policy from backend

## Important note
This project was scaffolded without installing an emulator on `ballbox-first`.
It is not yet built on this machine because Android build tooling/wrapper is not present locally.
The code is ready for Android Studio or an existing Gradle/SDK setup.

## Next step
- open in Android Studio on a machine with Android SDK
- add Gradle wrapper there
- build APK
- install on the vending machine
- test one folder-driven slot first


activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fillViewport="true">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        android:padding="16dp">

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="Ballbox machine ads sync"
            android:textSize="24sp"
            android:textStyle="bold" />

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="8dp"
            android:text="MVP sidecar. Manual sync first. No emulator required." />

        <EditText
            android:id="@+id/manifestUrlInput"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:hint="Manifest URL"
            android:inputType="textUri" />

        <EditText
            android:id="@+id/targetRootInput"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="12dp"
            android:hint="Target root"
            android:inputType="text" />

        <Button
            android:id="@+id/syncButton"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:text="Sync now" />

        <TextView
            android:id="@+id/statusText"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:text="Idle" />

        <TextView
            android:id="@+id/logText"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="12dp"
            android:fontFamily="monospace"
            android:textIsSelectable="true" />
    </LinearLayout>
</ScrollView>


**Reviewing service implementation**

I’m thinking about patching the code and inspecting the branches. I might only need to implement the service with a periodic handler and boot receiver. On Android 7, starting the service seems fine. I’ll schedule an initial delay of 10 seconds, with periodic checks every 15 minutes. Using SharedPreferences may be useful for UI settings. There’s also a need for a manual sync button and possibly a way to restart the main app without killing it. But if the app isn’t in the foreground, I wonder if I can get the launch intent?

**Exploring app restart options**

It seems we likely can't hard restart the app, but I can try using a launch intent or explicit activity instead. I need to check if the sidecar can call the shell, but that might not work since separate apps can't robustly restart another app. Maybe I can send an explicit intent to the main launcher with flags like CLEAR_TOP, NEW_TASK, or CLEAR_TASK. If the launch mode is singleTask, it may trigger onNewIntent rather than a full restart. However, if the app is already in the foreground, it might not have the desired effect.


pluginManagement {
    repositories {
        google()
        mavenCentral()
        gradlePluginPortal()
    }
}

dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
    }
}

rootProject.name = "machine-ads-sync"
include(":app")


**Considering app integration challenges**

The user mentioned that some options may not be possible, which I need to consider in my final solution. I’m thinking about implementing `attemptBringMainAppToFront()`, but since it’s not a true restart, maybe I shouldn't do that automatically. Instead, I could leave a status flag called "restartNeeded" for manual user action. Since they want periodic service, it might be fine to defer the restart integration. I’ll patch build.gradle to set minSdk to 25 and possibly targetSdk to 28 to keep the external storage permissions.

edit

    defaultConfig {
        applicationId = "app.ballbox.machineadssync"
        minSdk = 26
        targetSdk = 34
        versionCode = 1
        versionName = "0.1.0"
    }

    defaultConfig {
        applicationId = "app.ballbox.machineadssync"
        minSdk = 25
        targetSdk = 28
        versionCode = 2
        versionName = "0.2.0"
    }


Successfully replaced 1 block(s) in /home/sebas/work/projects/ballbox/android/machine-ads-sync/app/build.gradle.kts.

    ...
  8     compileSdk = 34
  9
 10     defaultConfig {
 11         applicationId = "app.ballbox.machineadssync"
-12         minSdk = 26
-13         targetSdk = 34
-14         versionCode = 1
-15         versionName = "0.1.0"
+12         minSdk = 25
+13         targetSdk = 28
+14         versionCode = 2
+15         versionName = "0.2.0"
 16     }
 17
 18     buildTypes {
 19         release {
    ...

--- /home/sebas/work/projects/ballbox/android/machine-ads-sync/app/build.gradle.kts
+++ /home/sebas/work/projects/ballbox/android/machine-ads-sync/app/build.gradle.kts
@@ -8,12 +8,12 @@
     compileSdk = 34

     defaultConfig {
         applicationId = "app.ballbox.machineadssync"
-        minSdk = 26
-        targetSdk = 34
-        versionCode = 1
-        versionName = "0.1.0"
+        minSdk = 25
+        targetSdk = 28
+        versionCode = 2
+        versionName = "0.2.0"
     }

     buildTypes {
         release {


write

package app.ballbox.machineadssync

import android.content.Context

object SyncConfig {
    private const val PREFS = "machine_ads_sync"
    private const val KEY_MANIFEST_URL = "manifest_url"
    private const val KEY_TARGET_ROOT = "target_root"
    private const val KEY_INTERVAL_MINUTES = "interval_minutes"
    private const val KEY_BOOT_ENABLED = "boot_enabled"
    private const val KEY_MAIN_APP_PACKAGE = "main_app_package"

    const val DEFAULT_MANIFEST_URL = "https://ballbox.app/api/machines/2601070188/ads-manifest"
    const val DEFAULT_TARGET_ROOT = "/sdcard/TcnFolder"
    const val DEFAULT_INTERVAL_MINUTES = 15
    const val DEFAULT_BOOT_ENABLED = true
    const val DEFAULT_MAIN_APP_PACKAGE = "com.tcn.vending"

    data class Snapshot(
        val manifestUrl: String,
        val targetRoot: String,
        val intervalMinutes: Int,
        val bootEnabled: Boolean,
        val mainAppPackage: String,
    )

    fun read(context: Context): Snapshot {
        val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
        return Snapshot(
            manifestUrl = prefs.getString(KEY_MANIFEST_URL, DEFAULT_MANIFEST_URL).orEmpty(),
            targetRoot = prefs.getString(KEY_TARGET_ROOT, DEFAULT_TARGET_ROOT).orEmpty(),
            intervalMinutes = prefs.getInt(KEY_INTERVAL_MINUTES, DEFAULT_INTERVAL_MINUTES).coerceAtLeast(1),
            bootEnabled = prefs.getBoolean(KEY_BOOT_ENABLED, DEFAULT_BOOT_ENABLED),
            mainAppPackage = prefs.getString(KEY_MAIN_APP_PACKAGE, DEFAULT_MAIN_APP_PACKAGE).orEmpty(),
        )
    }

    fun write(
        context: Context,
        manifestUrl: String,
        targetRoot: String,
        intervalMinutes: Int,
        bootEnabled: Boolean,
        mainAppPackage: String,
    ) {
        context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
            .edit()
            .putString(KEY_MANIFEST_URL, manifestUrl)
            .putString(KEY_TARGET_ROOT, targetRoot)
            .putInt(KEY_INTERVAL_MINUTES, intervalMinutes.coerceAtLeast(1))
            .putBoolean(KEY_BOOT_ENABLED, bootEnabled)
            .putString(KEY_MAIN_APP_PACKAGE, mainAppPackage)
            .apply()
    }
}


Successfully wrote 2185 bytes to /home/sebas/work/projects/ballbox/android/machine-ads-sync/app/src/main/java/app/ballbox/machineadssync/SyncConfig.kt


package app.ballbox.machineadssync

import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import androidx.core.app.NotificationCompat
import java.util.concurrent.atomic.AtomicBoolean

class MachineAdsSyncService : Service() {
    private val handler = Handler(Looper.getMainLooper())
    private val syncRunning = AtomicBoolean(false)

    private val periodicRunnable = object : Runnable {
        override fun run() {
            runSync("periodic")
            scheduleNextTick()
        }
    }

    override fun onCreate() {
        super.onCreate()
        startForeground(NOTIFICATION_ID, buildNotification("Idle"))
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        when (intent?.action) {
            ACTION_SYNC_NOW -> runSync("manual")
            ACTION_BOOT_SYNC -> scheduleInitialTick(INITIAL_DELAY_MS)
            else -> scheduleInitialTick(INITIAL_DELAY_MS)
        }
        return START_STICKY
    }

    override fun onDestroy() {
        handler.removeCallbacksAndMessages(null)
        super.onDestroy()
    }

    override fun onBind(intent: Intent?): IBinder? = null

    private fun scheduleInitialTick(delayMs: Long) {
        handler.removeCallbacks(periodicRunnable)
        handler.postDelayed(periodicRunnable, delayMs)
        updateNotification("Next sync in ${delayMs / 1000}s")
    }

    private fun scheduleNextTick() {
        val intervalMinutes = SyncConfig.read(this).intervalMinutes.toLong().coerceAtLeast(1)
        val delayMs = intervalMinutes * 60_000L
        handler.removeCallbacks(periodicRunnable)
        handler.postDelayed(periodicRunnable, delayMs)
        updateNotification("Next sync in ${intervalMinutes}m")
    }

    private fun runSync(reason: String) {
        if (!syncRunning.compareAndSet(false, true)) {
            updateNotification("Sync already running")
            return
        }

        val config = SyncConfig.read(this)
        updateNotification("Syncing (${reason})")

        Thread {
            val result = runCatching {
                MachineAdsSyncEngine().sync(
                    manifestUrl = config.manifestUrl,
                    targetRoot = config.targetRoot,
                    logger = { line -> android.util.Log.i(TAG, line) },
                )
            }

            result.onSuccess { syncResult ->
                if (syncResult.changed) {
                    android.util.Log.i(TAG, "Sync changed files. restartPolicy=${syncResult.restartPolicy}")
                    val relaunched = MainAppController.bringMainAppToFront(this, config.mainAppPackage)
                    android.util.Log.i(TAG, "Main app relaunch attempted=$relaunched")
                }
                updateNotification(
                    if (syncResult.changed) {
                        "Synced ${syncResult.appliedCount} changes"
                    } else {
                        "No changes"
                    }
                )
            }.onFailure { error ->
                android.util.Log.e(TAG, "Sync failed", error)
                updateNotification("Sync failed: ${error.message ?: "unknown"}")
            }

            syncRunning.set(false)
        }.start()
    }

    private fun buildNotification(text: String): Notification {
        ensureChannel()
        return NotificationCompat.Builder(this, CHANNEL_ID)
            .setSmallIcon(android.R.drawable.stat_sys_download_done)
            .setContentTitle("Ballbox sync")
            .setContentText(text)
            .setOngoing(true)
            .setOnlyAlertOnce(true)
            .build()
    }

    private fun updateNotification(text: String) {
        val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        manager.notify(NOTIFICATION_ID, buildNotification(text))
    }

    private fun ensureChannel() {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
        val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        val channel = NotificationChannel(CHANNEL_ID, "Ballbox sync", NotificationManager.IMPORTANCE_LOW)
        manager.createNotificationChannel(channel)
    }

    companion object {
        private const val TAG = "MachineAdsSyncService"
        private const val CHANNEL_ID = "ballbox_machine_sync"
        private const val NOTIFICATION_ID = 1001
        private const val INITIAL_DELAY_MS = 10_000L

        const val ACTION_SYNC_NOW = "app.ballbox.machineadssync.action.SYNC_NOW"
        const val ACTION_BOOT_SYNC = "app.ballbox.machineadssync.action.BOOT_SYNC"

        fun start(context: Context, action: String? = null) {
            val intent = Intent(context, MachineAdsSyncService::class.java).apply {
                this.action = action
            }
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                context.startForegroundService(intent)
            } else {
                context.startService(intent)
            }
        }
    }
}


Successfully wrote 5252 bytes to /home/sebas/work/projects/ballbox/android/machine-ads-sync/app/src/main/java/app/ballbox/machineadssync/MachineAdsSyncService.kt


package app.ballbox.machineadssync

import android.content.Context
import android.content.Intent

object MainAppController {
    fun bringMainAppToFront(context: Context, packageName: String): Boolean {
        if (packageName.isBlank()) return false
        val launchIntent = context.packageManager.getLaunchIntentForPackage(packageName) ?: return false
        launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
        return runCatching {
            context.startActivity(launchIntent)
            true
        }.getOrDefault(false)
    }
}


Successfully wrote 583 bytes to /home/sebas/work/projects/ballbox/android/machine-ads-sync/app/src/main/java/app/ballbox/machineadssync/MainAppController.kt


package app.ballbox.machineadssync

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent

class BootReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent?) {
        val action = intent?.action.orEmpty()
        if (action != Intent.ACTION_BOOT_COMPLETED && action != Intent.ACTION_MY_PACKAGE_REPLACED) {
            return
        }
        val config = SyncConfig.read(context)
        if (!config.bootEnabled) {
            return
        }
        MachineAdsSyncService.start(context, MachineAdsSyncService.ACTION_BOOT_SYNC)
    }
}


Successfully wrote 625 bytes to /home/sebas/work/projects/ballbox/android/machine-ads-sync/app/src/main/java/app/ballbox/machineadssync/BootReceiver.kt


edit

class MachineAdsSyncEngine {
    fun sync(manifestUrl: String, targetRoot: String, logger: (String) -> Unit): SyncResult {
        logger("GET $manifestUrl")
        val manifestPayload = fetchJson(manifestUrl)
        if (!manifestPayload.optBoolean("ok")) {
            throw IllegalStateException(manifestPayload.optString("message", "manifest request failed"))
        }

        val manifest = manifestPayload.getJSONObject("manifest")
        val restartPolicy = manifest.optString("restartPolicy", "app")
        val files = manifest.getJSONArray("files")
        var appliedCount = 0

        for (index in 0 until files.length()) {
            val file = files.getJSONObject(index)
            val target = file.getString("target")
            val url = file.getString("url")
            val expectedSha = file.getString("sha256")
            val outputFile = File(targetRoot, target)

            outputFile.parentFile?.mkdirs()

            if (outputFile.exists()) {
                val existingSha = sha256(outputFile)
                if (existingSha == expectedSha) {
                    logger("SKIP $target already current")
                    continue
                }
            }

            logger("DOWNLOAD $url")
            val bytes = fetchBytes(url)
            val downloadedSha = sha256(bytes)
            if (downloadedSha != expectedSha) {
                throw IllegalStateException("sha mismatch for $target")
            }

            val tempFile = File(outputFile.absolutePath + ".tmp")
            FileOutputStream(tempFile).use { it.write(bytes) }
            if (outputFile.exists()) {
                outputFile.delete()
            }
            tempFile.renameTo(outputFile)
            appliedCount += 1
            logger("WROTE $target (${bytes.size} bytes)")
        }

        return SyncResult(restartPolicy = restartPolicy, appliedCount = appliedCount)
    }

class MachineAdsSyncEngine {
    fun sync(manifestUrl: String, targetRoot: String, logger: (String) -> Unit): SyncResult {
        logger("GET $manifestUrl")
        val manifestPayload = fetchJson(manifestUrl)
        if (!manifestPayload.optBoolean("ok")) {
            throw IllegalStateException(manifestPayload.optString("message", "manifest request failed"))
        }

        val manifest = manifestPayload.getJSONObject("manifest")
        val restartPolicy = manifest.optString("restartPolicy", "app")
        val files = manifest.getJSONArray("files")
        val managedDirsJson = manifest.optJSONArray("managedDirs")
        val managedDirs = buildList {
            if (managedDirsJson != null) {
                for (index in 0 until managedDirsJson.length()) {
                    add(managedDirsJson.getString(index))
                }
            }
        }
        val desiredTargets = linkedSetOf<String>()
        var appliedCount = 0
        var prunedCount = 0

        for (index in 0 until files.length()) {
            val file = files.getJSONObject(index)
            val target = file.getString("target")
            val url = file.getString("url")
            val expectedSha = file.getString("sha256")
            desiredTargets += target
            val outputFile = File(targetRoot, target)

            outputFile.parentFile?.mkdirs()

            if (outputFile.exists()) {
                val existingSha = sha256(outputFile)
                if (existingSha == expectedSha) {
                    logger("SKIP $target already current")
                    continue
                }
            }

            logger("DOWNLOAD $url")
            val bytes = fetchBytes(url)
            val downloadedSha = sha256(bytes)
            if (downloadedSha != expectedSha) {
                throw IllegalStateException("sha mismatch for $target")
            }

            val tempFile = File(outputFile.absolutePath + ".tmp")
            FileOutputStream(tempFile).use { it.write(bytes) }
            if (outputFile.exists()) {
                outputFile.delete()
            }
            if (!tempFile.renameTo(outputFile)) {
                throw IllegalStateException("failed to move temp file for $target")
            }
            appliedCount += 1
            logger("WROTE $target (${bytes.size} bytes)")
        }

        for (managedDir in managedDirs) {
            val dir = File(targetRoot, managedDir)
            if (!dir.exists() || !dir.isDirectory) {
                continue
            }
            dir.walkTopDown()
                .filter { it.isFile }
                .forEach { existingFile ->
                    val relative = existingFile.relativeTo(File(targetRoot)).invariantSeparatorsPath
                    if (relative !in desiredTargets) {
                        if (existingFile.delete()) {
                            prunedCount += 1
                            logger("PRUNED $relative")
                        }
                    }
                }
        }

        return SyncResult(
            restartPolicy = restartPolicy,
            appliedCount = appliedCount + prunedCount,
            changed = appliedCount > 0 || prunedCount > 0,
        )
    }

data class SyncResult(
    val restartPolicy: String,
    val appliedCount: Int,
)

data class SyncResult(
    val restartPolicy: String,
    val appliedCount: Int,
    val changed: Boolean,
)


Successfully replaced 2 block(s) in /home/sebas/work/projects/ballbox/android/machine-ads-sync/app/src/main/java/app/ballbox/machineadssync/MachineAdsSyncEngine.kt.

     ...
  17
  18         val manifest = manifestPayload.getJSONObject("manifest")
  19         val restartPolicy = manifest.optString("restartPolicy", "app")
  20         val files = manifest.getJSONArray("files")
+ 21         val managedDirsJson = manifest.optJSONArray("managedDirs")
+ 22         val managedDirs = buildList {
+ 23             if (managedDirsJson != null) {
+ 24                 for (index in 0 until managedDirsJson.length()) {
+ 25                     add(managedDirsJson.getString(index))
+ 26                 }
+ 27             }
+ 28         }
+ 29         val desiredTargets = linkedSetOf<String>()
  21         var appliedCount = 0
+ 31         var prunedCount = 0
  22
  23         for (index in 0 until files.length()) {
  24             val file = files.getJSONObject(index)
  25             val target = file.getString("target")
  26             val url = file.getString("url")
  27             val expectedSha = file.getString("sha256")
+ 38             desiredTargets += target
  28             val outputFile = File(targetRoot, target)
  29
  30             outputFile.parentFile?.mkdirs()
  31
     ...
  48             FileOutputStream(tempFile).use { it.write(bytes) }
  49             if (outputFile.exists()) {
  50                 outputFile.delete()
  51             }
- 52             tempFile.renameTo(outputFile)
+ 63             if (!tempFile.renameTo(outputFile)) {
+ 64                 throw IllegalStateException("failed to move temp file for $target")
+ 65             }
  53             appliedCount += 1
  54             logger("WROTE $target (${bytes.size} bytes)")
  55         }
  56
- 57         return SyncResult(restartPolicy = restartPolicy, appliedCount = appliedCount)
+ 70         for (managedDir in managedDirs) {
+ 71             val dir = File(targetRoot, managedDir)
+ 72             if (!dir.exists() || !dir.isDirectory) {
+ 73                 continue
+ 74             }
+ 75             dir.walkTopDown()
+ 76                 .filter { it.isFile }
+ 77                 .forEach { existingFile ->
+ 78                     val relative = existingFile.relativeTo(File(targetRoot)).invariantSeparatorsPath
+ 79                     if (relative !in desiredTargets) {
+ 80                         if (existingFile.delete()) {
+ 81                             prunedCount += 1
+ 82                             logger("PRUNED $relative")
+ 83                         }
+ 84                     }
+ 85                 }
+ 86         }
+ 87
+ 88         return SyncResult(
+ 89             restartPolicy = restartPolicy,
+ 90             appliedCount = appliedCount + prunedCount,
+ 91             changed = appliedCount > 0 || prunedCount > 0,
+ 92         )
  58     }
  59
  60     private fun fetchJson(url: String): JSONObject {
  61         val text = fetchText(url)
     ...
  95
  96 data class SyncResult(
  97     val restartPolicy: String,
  98     val appliedCount: Int,
+134     val changed: Boolean,
  99 )

--- /home/sebas/work/projects/ballbox/android/machine-ads-sync/app/src/main/java/app/ballbox/machineadssync/MachineAdsSyncEngine.kt
+++ /home/sebas/work/projects/ballbox/android/machine-ads-sync/app/src/main/java/app/ballbox/machineadssync/MachineAdsSyncEngine.kt
@@ -17,15 +17,26 @@

         val manifest = manifestPayload.getJSONObject("manifest")
         val restartPolicy = manifest.optString("restartPolicy", "app")
         val files = manifest.getJSONArray("files")
+        val managedDirsJson = manifest.optJSONArray("managedDirs")
+        val managedDirs = buildList {
+            if (managedDirsJson != null) {
+                for (index in 0 until managedDirsJson.length()) {
+                    add(managedDirsJson.getString(index))
+                }
+            }
+        }
+        val desiredTargets = linkedSetOf<String>()
         var appliedCount = 0
+        var prunedCount = 0

         for (index in 0 until files.length()) {
             val file = files.getJSONObject(index)
             val target = file.getString("target")
             val url = file.getString("url")
             val expectedSha = file.getString("sha256")
+            desiredTargets += target
             val outputFile = File(targetRoot, target)

             outputFile.parentFile?.mkdirs()

@@ -48,14 +59,38 @@
             FileOutputStream(tempFile).use { it.write(bytes) }
             if (outputFile.exists()) {
                 outputFile.delete()
             }
-            tempFile.renameTo(outputFile)
+            if (!tempFile.renameTo(outputFile)) {
+                throw IllegalStateException("failed to move temp file for $target")
+            }
             appliedCount += 1
             logger("WROTE $target (${bytes.size} bytes)")
         }

-        return SyncResult(restartPolicy = restartPolicy, appliedCount = appliedCount)
+        for (managedDir in managedDirs) {
+            val dir = File(targetRoot, managedDir)
+            if (!dir.exists() || !dir.isDirectory) {
+                continue
+            }
+            dir.walkTopDown()
+                .filter { it.isFile }
+                .forEach { existingFile ->
+                    val relative = existingFile.relativeTo(File(targetRoot)).invariantSeparatorsPath
+                    if (relative !in desiredTargets) {
+                        if (existingFile.delete()) {
+                            prunedCount += 1
+                            logger("PRUNED $relative")
+                        }
+                    }
+                }
+        }
+
+        return SyncResult(
+            restartPolicy = restartPolicy,
+            appliedCount = appliedCount + prunedCount,
+            changed = appliedCount > 0 || prunedCount > 0,
+        )
     }

     private fun fetchJson(url: String): JSONObject {
         val text = fetchText(url)
@@ -95,5 +130,6 @@

 data class SyncResult(
     val restartPolicy: String,
     val appliedCount: Int,
+    val changed: Boolean,
 )


**Considering app updates**

I need to make sure that buildList requires the Kotlin standard library; that’s an important detail. I’ll also need to edit the MainActivity along with the layout and the manifest files. It seems like a few changes are necessary to keep everything working smoothly. It’s a little challenging, but I think I can handle it! Let’s dive in and get those edits done so the app runs perfectly.

package app.ballbox.machineadssync

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import app.ballbox.machineadssync.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.manifestUrlInput.setText(DEFAULT_MANIFEST_URL)
        binding.targetRootInput.setText(DEFAULT_TARGET_ROOT)
        binding.logText.text = "Ready"

        binding.syncButton.setOnClickListener {
            val manifestUrl = binding.manifestUrlInput.text?.toString()?.trim().orEmpty()
            val targetRoot = binding.targetRootInput.text?.toString()?.trim().orEmpty()

            if (manifestUrl.isEmpty() || targetRoot.isEmpty()) {
                binding.statusText.text = "Manifest URL and target root are required"
                return@setOnClickListener
            }

            binding.syncButton.isEnabled = false
            binding.statusText.text = "Syncing..."
            binding.logText.text = "Starting sync..."

            Thread {
                val result = runCatching {
                    MachineAdsSyncEngine().sync(
                        manifestUrl = manifestUrl,
                        targetRoot = targetRoot,
                        logger = { line -> runOnUiThread { appendLog(line) } }
                    )
                }

                runOnUiThread {
                    binding.syncButton.isEnabled = true
                    result.onSuccess {
                        binding.statusText.text = "Sync OK · restart policy ${it.restartPolicy}"
                        appendLog("Done. Applied ${it.appliedCount} changed files.")
                    }.onFailure {
                        binding.statusText.text = "Sync failed"
                        appendLog("ERROR: ${it.message}")
                    }
                }
            }.start()
        }
    }

    private fun appendLog(line: String) {
        val previous = binding.logText.text?.toString().orEmpty()
        binding.logText.text = if (previous.isBlank()) line else "$previous\n$line"
    }

    companion object {
        private const val DEFAULT_MANIFEST_URL = "https://ballbox.app/api/machines/2601070188/ads-manifest"
        private const val DEFAULT_TARGET_ROOT = "/sdcard/TcnFoldercopy"
    }
}

package app.ballbox.machineadssync

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import app.ballbox.machineadssync.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        val config = SyncConfig.read(this)
        binding.manifestUrlInput.setText(config.manifestUrl)
        binding.targetRootInput.setText(config.targetRoot)
        binding.intervalMinutesInput.setText(config.intervalMinutes.toString())
        binding.bootEnabledCheckbox.isChecked = config.bootEnabled
        binding.mainAppPackageInput.setText(config.mainAppPackage)
        binding.logText.text = "Ready"

        binding.saveButton.setOnClickListener {
            val snapshot = persistConfig() ?: return@setOnClickListener
            binding.statusText.text = "Saved"
            appendLog("Saved config. interval=${snapshot.intervalMinutes}m boot=${snapshot.bootEnabled}")
            MachineAdsSyncService.start(this)
        }

        binding.syncButton.setOnClickListener {
            val snapshot = persistConfig() ?: return@setOnClickListener
            binding.statusText.text = "Sync queued"
            appendLog("Queued sync for ${snapshot.targetRoot}")
            MachineAdsSyncService.start(this, MachineAdsSyncService.ACTION_SYNC_NOW)
        }

        binding.openMainAppButton.setOnClickListener {
            val snapshot = persistConfig() ?: return@setOnClickListener
            val opened = MainAppController.bringMainAppToFront(this, snapshot.mainAppPackage)
            binding.statusText.text = if (opened) "Main app opened" else "Could not open main app"
        }
    }

    private fun persistConfig(): SyncConfig.Snapshot? {
        val manifestUrl = binding.manifestUrlInput.text?.toString()?.trim().orEmpty()
        val targetRoot = binding.targetRootInput.text?.toString()?.trim().orEmpty()
        val intervalMinutesText = binding.intervalMinutesInput.text?.toString()?.trim().orEmpty()
        val mainAppPackage = binding.mainAppPackageInput.text?.toString()?.trim().orEmpty()

        if (manifestUrl.isEmpty() || targetRoot.isEmpty()) {
            binding.statusText.text = "Manifest URL and target root are required"
            return null
        }

        val intervalMinutes = intervalMinutesText.toIntOrNull()
        if (intervalMinutes == null || intervalMinutes < 1) {
            binding.statusText.text = "Interval must be >= 1 minute"
            return null
        }

        SyncConfig.write(
            context = this,
            manifestUrl = manifestUrl,
            targetRoot = targetRoot,
            intervalMinutes = intervalMinutes,
            bootEnabled = binding.bootEnabledCheckbox.isChecked,
            mainAppPackage = mainAppPackage,
        )

        return SyncConfig.read(this)
    }

    private fun appendLog(line: String) {
        val previous = binding.logText.text?.toString().orEmpty()
        binding.logText.text = if (previous.isBlank()) line else "$previous\n$line"
    }
}