[B4A] Criando serviço de dados sendo sincronização de 5 em 5 minutos

Lucas Siqueira

Active Member
Licensed User
Longtime User
Essa semana eu tive que implementar um serviço que fica rodando mesmo com o aplicativo android esteja fechado, foi muito trabalhoso achar o conteudo no forum e utilizando o GTP que muitas vezes responde errado e eu tive que ficar deduzindo com o conhecimento que eu tinha e estudos que realizei, mas como no fim tudo da certo, consegui criar e testar o serviço que será responsavel por enviar os dados do aparelho para o servidor.


links úteis e que foram utilizados:


Nesse exemplo que está funcionando estou utilizando PHP + MYSQL + B4A:


código para criar a TABELA no MYSQL:
SQL:
CREATE TABLE teste_data (
    id INT AUTO_INCREMENT PRIMARY KEY,
    data DATETIME
);



código da API em PHP:
PHP:
<?php
define('DB_HOST', 'localhost');
define('DB_NAME', 'nomeBanco');
define('DB_USER', 'usuarioBanco');
define('DB_PASS', 'senhaUsuarioBanco');
define('DB_PORT', '3306');

function getDb() {
    static $conn = null;
    if ($conn === null) {
        $conn = new PDO(
            'mysql:host='.DB_HOST.';port='.DB_PORT.';dbname='.DB_NAME.';charset=utf8',
            DB_USER,
            DB_PASS,
            [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
        );
    }
    return $conn;
}

$conn = getDb();

$data = $_GET['data'];

$sql = " INSERT INTO teste_data (data) VALUES (?)";
$stmt = $conn->prepare($sql);
$stmt->execute([$data]);

$lastId = $conn->lastInsertId();

header('Content-Type: application/json');
echo json_encode([
    'success' => true,
    'id' => $lastId
]);
?>


código do Manifest Editor:
B4X:
'This code will be applied to the manifest file during compilation.
'You do not need to modify it in most cases.
'See this link for for more information: https://www.b4x.com/forum/showthread.php?p=78136
AddManifestText(
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34"/>
<supports-screens android:largeScreens="true"
    android:normalScreens="true"
    android:smallScreens="true"
    android:anyDensity="true"/>)
SetApplicationAttribute(android:icon, "@drawable/icon")
SetApplicationAttribute(android:label, "$LABEL$")
CreateResourceFromFile(Macro, Themes.LightTheme)
'End of default text.


' Permite conexões HTTP não seguras (sem HTTPS) em Android 9+ (API 28+)
' Útil para testes ou se seu servidor não suporta HTTPS
' Porém, para publicação na Play Store, recomenda-se fortemente usar HTTPS e remover esta linha
CreateResourceFromFile(Macro, Core.NetworkClearText)


' Permissão geral para executar serviços em foreground (requerida para todos os apps com foreground service)
AddPermission(android.permission.FOREGROUND_SERVICE)

' Permissão específica para serviços do tipo dataSync (obrigatória no Android 14+ para foregroundServiceType="dataSync")
AddPermission(android.permission.FOREGROUND_SERVICE_DATA_SYNC)

' Define no AndroidManifest.xml o atributo android:foregroundServiceType="dataSync" na tag <service> do módulo ServiceSync
' Isso informa ao sistema que seu serviço em foreground é do tipo sincronização de dados
SetServiceAttribute(ServiceSync, android:foregroundServiceType, "dataSync")



código do Main
B4X:
#Region  Project Attributes
    #ApplicationLabel: servicoSincronizacaoDados
    #VersionCode: 1
    #VersionName:
    'SupportedOrientations possible values: unspecified, landscape or portrait.
    #SupportedOrientations: unspecified
    #CanInstallToExternalStorage: False
#End Region

#Region  Activity Attributes
    #FullScreen: False
    #IncludeTitle: True
#End Region

Sub Process_Globals
    Private xui As XUI
End Sub

Sub Globals
    Private CustomListView1 As CustomListView
    Private Label1 As B4XView
End Sub

Sub Activity_Create(FirstTime As Boolean)
    Activity.LoadLayout("Layout")
    StartService(ServiceSync)
End Sub

Sub Activity_Resume
    #if B4A
    Wait For (CheckAndRequestNotificationPermission) Complete (HasPermission As Boolean)
    If HasPermission = False Then
        Log("no permission")
        ToastMessageShow("no permission", True)
    End If
    #End If
 
    carregarDadosNaoEnviados
End Sub

Sub Activity_Pause (UserClosed As Boolean)
End Sub

Sub Button1_Click
    Dim data As String = Starter.obterData
    Starter.vSQL.ExecNonQuery($"insert into teste_data (data) values ('${data}')"$)
    Log("inserido: "& data)
    ToastMessageShow("Data inserida com sucesso: " & data, False)
 
    carregarDadosNaoEnviados
End Sub

Sub carregarDadosNaoEnviados
    CustomListView1.Clear
 
    Dim query As String = "SELECT * FROM teste_data WHERE sincronizado = 'N' ORDER BY data DESC"
    Dim rs As ResultSet = Starter.vSQL.ExecQuery(query)
 
    Dim total As Int = 0
    Do While rs.NextRow
        Dim id As String = rs.GetString("id")
        Dim data As String = rs.GetString("data")
        CustomListView1.AddTextItem(data, id)
        total = total + 1
    Loop
 
    Label1.Text = $"Total pendente: ${total}"$
    Log($"Total pendente: ${total}"$)
End Sub

#if B4A
Private Sub CheckAndRequestNotificationPermission As ResumableSub
    Dim p As Phone
    If p.SdkVersion < 33 Then Return True
    Dim ctxt As JavaObject
    ctxt.InitializeContext
    Dim targetSdkVersion As Int = ctxt.RunMethodJO("getApplicationInfo", Null).GetField("targetSdkVersion")
    If targetSdkVersion < 33 Then Return True
    Dim NotificationsManager As JavaObject = ctxt.RunMethod("getSystemService", Array("notification"))
    Dim NotificationsEnabled As Boolean = NotificationsManager.RunMethod("areNotificationsEnabled", Null)
    If NotificationsEnabled Then Return True
    Dim rp As RuntimePermissions
    rp.CheckAndRequest(rp.PERMISSION_POST_NOTIFICATIONS)
    Wait For Activity_PermissionResult (Permission As String, Result As Boolean) 'change to Activity_PermissionResult if non-B4XPages.
    Log(Permission & ": " & Result)
    Return Result
End Sub
#End If



código do Starter
B4X:
#Region  Service Attributes
    #StartAtBoot: False
    #ExcludeFromLibrary: True
#End Region

Sub Process_Globals
    'These global variables will be declared once when the application starts.
    'These variables can be accessed from all modules.
    Public xui As XUI
    Public vSQL As SQL
End Sub

Sub Service_Create
    'This is the program entry point.
    'This is a good place to load resources that are not specific to a single activity.
    iniciarBancoDados
End Sub

Sub Service_Start (StartingIntent As Intent)
    Service.StopAutomaticForeground 'Starter service can start in the foreground state in some edge cases.
End Sub

Sub Service_TaskRemoved
    'This event will be raised when the user removes the app from the recent apps list.
End Sub

'Return true to allow the OS default exceptions handler to handle the uncaught exception.
Sub Application_Error (Error As Exception, StackTrace As String) As Boolean
    Return True
End Sub

Sub Service_Destroy
End Sub

Sub iniciarBancoDados
    vSQL.Initialize(xui.DefaultFolder, "banco.db", True)
 
    Try
        vSQL.ExecNonQuery($"CREATE TABLE IF NOT EXISTS teste_data (
        id integer primary key autoincrement,
        data text,
        sincronizado text default 'N',
        data_sincronizado text default '');"$)
    Catch
        Log("Erro ao criar banco: " & LastException.Message)
    End Try
End Sub

Sub obterData As String
    Dim dia As String = NumberFormat(DateTime.GetDayOfMonth(DateTime.Now), 2, 0)
    Dim mes As String = NumberFormat(DateTime.GetMonth(DateTime.Now), 2, 0)
    Dim ano As String = DateTime.GetYear(DateTime.Now)
    Dim hora As String = NumberFormat(DateTime.GetHour(DateTime.Now), 2, 0)
    Dim minuto As String = NumberFormat(DateTime.GetMinute(DateTime.Now), 2, 0)
    Dim segundo As String = NumberFormat(DateTime.GetSecond(DateTime.Now), 2, 0)
    Return $"${ano}-${mes}-${dia} ${hora}:${minuto}:${segundo}"$
End Sub



código do ServiceSync [CÓDIGO MAIS IMPORTANTE]
B4X:
#Region  Service Attributes
    ' Inicia o serviço automaticamente quando o dispositivo liga
    #StartAtBoot: True
#End Region

Sub Process_Globals
    Private nid As Int = 1 ' ID da notificação do serviço em foreground (único identificador)
End Sub

Sub Service_Create
    ' Aqui você pode colocar código que será executado apenas uma vez quando o serviço for criado
    ' No momento, está vazio porque nada é necessário na criação
End Sub

Sub Service_Start (StartingIntent As Intent)
    ' Essa rotina é chamada sempre que o serviço é iniciado ou reiniciado

    ' Desliga o modo automático de foreground para controlar manualmente no Android 12+
    Service.AutomaticForegroundMode = Service.AUTOMATIC_FOREGROUND_NEVER

    ' Inicia o serviço em modo foreground (prioritário) mostrando uma notificação persistente
    Service.StartForeground(nid, CreateNotification("Sincronizando..."))

    ' Aguarda a execução completa da sincronização de dados antes de continuar
    Wait for (SincronizarDadosEFinalizarServico) Complete (naoUtilizado As Object)

    ' Agenda uma nova execução do serviço para daqui a 5 minutos
    StartServiceAt(Me, DateTime.Now + 5 * DateTime.TicksPerMinute, True)
End Sub

Sub Service_Destroy
    ' Chamado quando o serviço é destruído
    ' Aqui poderia liberar recursos se tivesse algo em uso (não necessário neste caso)
End Sub

' Função principal que sincroniza dados e encerra o serviço ao terminar
Sub SincronizarDadosEFinalizarServico As ResumableSub
    ' Prepara a consulta SQL para buscar teste_data não sincronizadas ('N')
    Dim query As String = "SELECT * FROM teste_data WHERE sincronizado = 'N'"
   
    ' Executa a consulta no banco de dados SQLite
    Dim rs As ResultSet = Starter.vSQL.ExecQuery(query)
   
    ' Percorre todas as linhas retornadas pela consulta
    Do While rs.NextRow
        ' Pega os dados da linha atual: id e data da coordenada
        Dim id As String = rs.GetString("id")
        Dim data As String = rs.GetString("data")
       
        ' Cria um novo objeto para fazer a requisição HTTP (enviar dados)
        Dim job As HttpJob
        job.Initialize("", Me)
       
        ' Envia os dados para o servidor via POST, passando "data" como parâmetro
        job.Download2("https://seuservidor.com.br/api.php", Array As String("data", data))
       
        ' Aguarda a finalização do envio (resposta do servidor)
        Wait For (job) JobDone (job As HttpJob)
       
        ' Se o envio foi bem sucedido
        If job.Success Then
           
            ' Converte a resposta JSON em mapa para pegar valores facilmente
            Dim response As Map = job.GetString.As(JSON).ToMap
           
            ' Log no console o id enviado para confirmar o sucesso
            Log("enviado: " & response.GetDefault("id",""))
           
            Dim dataSync As String = Starter.obterData
           
            ' Atualiza a tabela para marcar essa coordenada como sincronizada ('S')
            Starter.vSQL.ExecNonQuery($"UPDATE teste_data SET sincronizado = 'S', data_sincronizado = '${dataSync}' WHERE id= '${id}'"$)
        Else
           
            ' Se houve erro, log da mensagem para diagnóstico
            Log("Erro ao sincronizar: " & job.ErrorMessage)
        End If
       
        ' Libera os recursos usados pela requisição HTTP
        job.Release
    Loop
   
    ' Fecha o ResultSet para liberar recursos
    rs.Close
   
    ' Remove a notificação da barra de status, pois o serviço vai terminar
    Service.StopForeground(nid)
   
    ' Encerra o próprio serviço para liberar memória e recursos do sistema
    StopService(Me)            
   
    ' Finaliza a ResumableSub, retornando null (obrigatório em B4A)
    Return Null
End Sub

' Cria e configura a notificação usada no serviço em foreground
Private Sub CreateNotification(Texto As String) As Notification
    Dim n As Notification
   
    ' Inicializa a notificação com importância padrão (nível de visibilidade)
    n.Initialize2(n.IMPORTANCE_DEFAULT)
   
    ' Define o ícone que aparecerá na notificação (deve estar no projeto)
    n.Icon = "icon"
   
    ' A notificação não pode ser removida pelo usuário pois indica serviço ativo
    n.AutoCancel = False
   
    ' Marca como evento contínuo (indica serviço rodando em background)
    n.OnGoingEvent = True
   
    ' Define o título, texto e a ação ao clicar na notificação (abre Main)
    n.SetInfo("Sincronização", Texto, Main)
   
    ' Retorna a notificação pronta para uso
    Return n
End Sub
 

Attachments

  • servicoSincronizacaoDados5em5minutos.zip
    14.7 KB · Views: 45
Last edited:

Lucas Siqueira

Active Member
Licensed User
Longtime User
[B4A] Criando serviço de dados sendo sincronização sempre que haver dados para enviar

Esse é um outro modelo, só iniciamos o serviço de sincronização se houver dados para serem enviados.

Toda vez que inserirmos um registro no banco de dados, agendamos o serviço para executar daqui 5 minutos.

Não se preocupe em acabar agendando dois serviços de sincronização, pois sempre que utilizamos o comando StartServiceAt, se existir agendamento anterior, ele será cancelado e somente o agendamento que acabou de ser agendado será mantido como ativo.

Nesse exemplo que está funcionando estou utilizando PHP + MYSQL + B4A:

Ocódigo para criar a TABELA no MYSQL, o código da API em PHP e o código do Manifest Editor são exatamente os mesmos, então vou focar somente no código B4A

código do Main
B4X:
#Region  Project Attributes
    #ApplicationLabel: servicoSincronizacaoDados
    #VersionCode: 1
    #VersionName:
    'SupportedOrientations possible values: unspecified, landscape or portrait.
    #SupportedOrientations: unspecified
    #CanInstallToExternalStorage: False
#End Region

#Region  Activity Attributes
    #FullScreen: False
    #IncludeTitle: True
#End Region

Sub Process_Globals
    Private xui As XUI
End Sub

Sub Globals
    Private CustomListView1 As CustomListView
    Private Label1 As B4XView
End Sub

Sub Activity_Create(FirstTime As Boolean)
    Activity.LoadLayout("Layout")
   
    Starter.AgendarServicoSincronizacaoSeNecessario
End Sub

Sub Activity_Resume
    #if B4A
    Wait For (CheckAndRequestNotificationPermission) Complete (HasPermission As Boolean)
    If HasPermission = False Then
        Log("no permission")
        ToastMessageShow("no permission", True)
    End If
    #End If
   
    carregarDadosNaoEnviados
End Sub

Sub Activity_Pause (UserClosed As Boolean)

End Sub

Sub Button1_Click
    Try
        Dim data As String = Starter.obterData
        Starter.vSQL.ExecNonQuery($"insert into teste_data (data) values ('${data}')"$)
        Log("inserido: "& data)
        ToastMessageShow("Data inserida com sucesso: " & data, False)
    Catch
        Log(LastException)
    End Try
   
    Starter.AgendarServicoSincronizacaoSeNecessario
   
    carregarDadosNaoEnviados
End Sub

Sub carregarDadosNaoEnviados
    CustomListView1.Clear
    Dim total As Int = 0
   
    Try
       
        Dim query As String = "SELECT * FROM teste_data WHERE sincronizado = 'N' ORDER BY data DESC"
        Dim rs As ResultSet = Starter.vSQL.ExecQuery(query)
   
        Do While rs.NextRow
            Dim id As String = rs.GetString("id")
            Dim data As String = rs.GetString("data")
            CustomListView1.AddTextItem(data, id)
            total = total + 1
        Loop
   
    Catch
        Log(LastException)
    End Try
   
    Label1.Text = $"Total pendente: ${total}"$
    Log($"Total pendente: ${total}"$)
End Sub

#if B4A
Private Sub CheckAndRequestNotificationPermission As ResumableSub
    Dim p As Phone
    If p.SdkVersion < 33 Then Return True
    Dim ctxt As JavaObject
    ctxt.InitializeContext
    Dim targetSdkVersion As Int = ctxt.RunMethodJO("getApplicationInfo", Null).GetField("targetSdkVersion")
    If targetSdkVersion < 33 Then Return True
    Dim NotificationsManager As JavaObject = ctxt.RunMethod("getSystemService", Array("notification"))
    Dim NotificationsEnabled As Boolean = NotificationsManager.RunMethod("areNotificationsEnabled", Null)
    If NotificationsEnabled Then Return True
    Dim rp As RuntimePermissions
    rp.CheckAndRequest(rp.PERMISSION_POST_NOTIFICATIONS)
    Wait For Activity_PermissionResult (Permission As String, Result As Boolean) 'change to Activity_PermissionResult if non-B4XPages.
    Log(Permission & ": " & Result)
    Return Result
End Sub
#End If



código do Starter
B4X:
#Region  Service Attributes
    #StartAtBoot: False
    #ExcludeFromLibrary: True
#End Region

Sub Process_Globals
    'These global variables will be declared once when the application starts.
    'These variables can be accessed from all modules.
    Public xui As XUI
    Public vSQL As SQL
End Sub

Sub Service_Create
    'This is the program entry point.
    'This is a good place to load resources that are not specific to a single activity.
    iniciarBancoDados
End Sub

Sub Service_Start (StartingIntent As Intent)
    Service.StopAutomaticForeground 'Starter service can start in the foreground state in some edge cases.
End Sub

Sub Service_TaskRemoved
    'This event will be raised when the user removes the app from the recent apps list.
End Sub

'Return true to allow the OS default exceptions handler to handle the uncaught exception.
Sub Application_Error (Error As Exception, StackTrace As String) As Boolean
    Return True
End Sub

Sub Service_Destroy
End Sub

Sub iniciarBancoDados
    vSQL.Initialize(xui.DefaultFolder, "banco.db", True)
   
    Try
        vSQL.ExecNonQuery($"CREATE TABLE IF NOT EXISTS teste_data (
        id integer primary key autoincrement,
        data text,
        sincronizado text default 'N',
        data_sincronizado text default '');"$)
    Catch
        Log("Erro ao criar banco: " & LastException.Message)
    End Try
End Sub

Sub obterData As String
    Dim dia As String = NumberFormat(DateTime.GetDayOfMonth(DateTime.Now), 2, 0)
    Dim mes As String = NumberFormat(DateTime.GetMonth(DateTime.Now), 2, 0)
    Dim ano As String = DateTime.GetYear(DateTime.Now)
    Dim hora As String = NumberFormat(DateTime.GetHour(DateTime.Now), 2, 0)
    Dim minuto As String = NumberFormat(DateTime.GetMinute(DateTime.Now), 2, 0)
    Dim segundo As String = NumberFormat(DateTime.GetSecond(DateTime.Now), 2, 0)
    Return $"${ano}-${mes}-${dia} ${hora}:${minuto}:${segundo}"$
End Sub

Sub HaDadosParaSincronizar As Boolean
    Dim temDados As Boolean = False
    Try
        Dim rs As ResultSet = vSQL.ExecQuery("SELECT COUNT(*) AS total FROM teste_data WHERE sincronizado = 'N'")
        rs.NextRow
        temDados = rs.GetInt("total") > 0
        rs.Close
    Catch
        Log(LastException)
    End Try
    Return temDados
End Sub

Sub AgendarServicoSincronizacaoSeNecessario
    If HaDadosParaSincronizar Then
        StartServiceAt(ServiceSync, DateTime.Now + 5 * DateTime.TicksPerMinute, True)
    Else
        Log("Nenhum dado pendente. Serviço não agendado.")
    End If
End Sub



código do ServiceSync [CÓDIGO MAIS IMPORTANTE]
B4X:
#Region  Service Attributes
    ' Inicia o serviço automaticamente quando o dispositivo liga
    #StartAtBoot: True
#End Region

Sub Process_Globals
    Private Const NID_SYNC As Int = 101 ' ID da notificação do serviço em foreground (único identificador)
    Private Const URL_SINCRONIZACAO As String = "https://seuservidor.com.br/api.php"
End Sub

Sub Service_Create
    ' Aqui você pode colocar código que será executado apenas uma vez quando o serviço for criado
    ' No momento, está vazio porque nada é necessário na criação
End Sub

Sub Service_Start (StartingIntent As Intent)
    ' Essa rotina é chamada sempre que o serviço é iniciado ou reiniciado

    ' Desliga o modo automático de foreground para controlar manualmente no Android 12+
    Service.AutomaticForegroundMode = Service.AUTOMATIC_FOREGROUND_NEVER

    ' Inicia o serviço em modo foreground (prioritário) mostrando uma notificação persistente
    Service.StartForeground(NID_SYNC, CreateNotification("Sincronizando..."))

    ' Aguarda a execução completa da sincronização de dados antes de continuar
    Wait for (SincronizarDadosEFinalizarServico) Complete (naoUtilizado As Object)
End Sub

Sub Service_Destroy
    ' Chamado quando o serviço é destruído
    ' Aqui poderia liberar recursos se tivesse algo em uso (não necessário neste caso)
End Sub

' Função principal que sincroniza dados e encerra o serviço ao terminar
Sub SincronizarDadosEFinalizarServico As ResumableSub
    Try
       
        ' Prepara a consulta SQL para buscar teste_data não sincronizadas ('N')
        Dim query As String = "SELECT * FROM teste_data WHERE sincronizado = 'N'"
   
        ' Executa a consulta no banco de dados SQLite
        Dim rs As ResultSet = Starter.vSQL.ExecQuery(query)
   
        ' Percorre todas as linhas retornadas pela consulta
        Do While rs.NextRow
            ' Pega os dados da linha atual: id e data da coordenada
            Dim id As String = rs.GetString("id")
            Dim data As String = rs.GetString("data")
       
            ' Cria um novo objeto para fazer a requisição HTTP (enviar dados)
            Dim job As HttpJob
            job.Initialize("", Me)
       
            ' Envia os dados para o servidor via POST, passando "data" como parâmetro
            job.Download2(URL_SINCRONIZACAO, Array As String("data", data))
       
            ' Aguarda a finalização do envio (resposta do servidor)
            Wait For (job) JobDone (job As HttpJob)
       
            ' Se o envio foi bem sucedido
            If job.Success Then
           
                ' Converte a resposta JSON em mapa para pegar valores facilmente
                Dim response As Map = job.GetString.As(JSON).ToMap
           
                ' Log no console o id enviado para confirmar o sucesso
                Log("enviado: " & response.GetDefault("id",""))
           
                Dim dataSync As String = Starter.obterData
               
                ' Atualiza a tabela para marcar essa coordenada como sincronizada ('S')
                Starter.vSQL.ExecNonQuery($"UPDATE teste_data SET sincronizado = 'S', data_sincronizado = '${dataSync}' WHERE id= '${id}'"$)
            Else
           
                ' Se houve erro, log da mensagem para diagnóstico
                Log("Erro ao sincronizar: " & job.ErrorMessage)
            End If
       
            ' Libera os recursos usados pela requisição HTTP
            job.Release
        Loop
   
        ' Fecha o ResultSet para liberar recursos
        rs.Close
   
    Catch
        Log(LastException)
    End Try
   
    ' Remove a notificação da barra de status, pois o serviço vai terminar
    Service.StopForeground(NID_SYNC)
   
    ' Encerra o próprio serviço para liberar memória e recursos do sistema
    StopService(Me)
   
    ' Finaliza a ResumableSub, retornando null (obrigatório em B4A)
    Return Null
End Sub

' Cria e configura a notificação usada no serviço em foreground
Private Sub CreateNotification(Texto As String) As Notification
    Dim n As Notification
   
    ' Inicializa a notificação com importância padrão (nível de visibilidade)
    n.Initialize2(n.IMPORTANCE_DEFAULT)
   
    ' Define o ícone que aparecerá na notificação (deve estar no projeto)
    n.Icon = "icon"
   
    ' A notificação não pode ser removida pelo usuário pois indica serviço ativo
    n.AutoCancel = False
   
    ' Marca como evento contínuo (indica serviço rodando em background)
    n.OnGoingEvent = True
   
    ' Define o título, texto e a ação ao clicar na notificação (abre Main)
    n.SetInfo("Sincronização", Texto, Main)
   
    ' Retorna a notificação pronta para uso
    Return n
End Sub
 

Attachments

  • servicoSincronizacaoDadosSeTiverDados.zip
    15 KB · Views: 49
Last edited:
Cookies are required to use this site. You must accept them to continue using the site. Learn more…