[WebView] implementación de custom webview; manejo de proxy

página de inicio local (index)

botones personalizados: recargar página recarga el cache (maps)

administración de URLs externos se abren fuera

otras funciones
This commit is contained in:
Luis Guzmán 2026-03-12 18:27:38 -06:00
parent e784d515dc
commit 055116675d
11 changed files with 443 additions and 7 deletions

View File

@ -78,4 +78,5 @@ dependencies {
implementation 'androidx.appcompat:appcompat:1.4.1'
implementation 'androidx.biometric:biometric:1.1.0'
implementation 'com.google.android.material:material:1.11.0'
implementation 'androidx.webkit:webkit:1.12.0'
}

View File

@ -11,7 +11,8 @@
<application android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/Theme.IIABController">
android:theme="@style/Theme.IIABController"
android:usesCleartextTraffic="true">
<!-- VPN Service (Network Layer) -->
<service android:name=".TProxyService" android:process=":native"
@ -66,6 +67,9 @@
</intent-filter>
</activity>
<activity android:name=".AppListActivity" android:label="@string/app_name"/>
<activity
android:name=".PortalActivity"
android:theme="@style/Theme.AppCompat.NoActionBar" />
</application>
<uses-permission android:name="android.permission.INTERNET"/>

View File

@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Safe Pocket Web</title>
<style>
/* Reseteo básico y fondo */
body {
margin: 0; padding: 0;
background-color: #F4F7F6;
font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
min-height: 100vh;
display: flex; flex-direction: column;
justify-content: center; align-items: center;
}
/* Contenedor de botones */
.menu-container {
width: 80%; display: flex;
flex-direction: column; gap: 20px;
}
/* Estilo de los botones */
.btn {
display: flex; align-items: center; justify-content: center;
padding: 18px; border-radius: 14px; text-decoration: none;
font-size: 20px; font-weight: bold;
box-shadow: 0 4px 10px rgba(0,0,0,0.15);
transition: transform 0.1s, box-shadow 0.1s;
}
.btn:active { transform: scale(0.96); box-shadow: 0 2px 5px rgba(0,0,0,0.2); }
/* Colores */
.btn-books { background-color: #00BCD4; color: #FFFFFF; }
.btn-kiwix { background-color: #FF9800; color: #FFFFFF; }
.btn-kolibri { background-color: #FFD54F; color: #333333; }
.btn-maps { background-color: #4CAF50; color: #FFFFFF; }
.btn-matomo { background-color: #1976D2; color: #FFFFFF; }
.btn span { margin-right: 12px; font-size: 26px; }
/* --- NUEVO: ESTILOS DEL SPINNER Y OVERLAY --- */
#loadingOverlay {
display: none; /* Oculto por defecto */
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background-color: rgba(244, 247, 246, 0.85); /* Fondo difuminado */
z-index: 9999; /* Por encima de todo */
flex-direction: column; justify-content: center; align-items: center;
}
.spinner {
width: 50px; height: 50px;
border: 6px solid #E0E0E0;
border-top: 6px solid #1976D2; /* Azul elegante */
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-text {
margin-top: 15px; font-size: 18px; font-weight: bold; color: #333;
}
</style>
</head>
<body>
<div id="loadingOverlay">
<div class="spinner"></div>
<div class="loading-text" id="loadingText">Cargando...</div>
</div>
<div class="menu-container">
<a href="http://box/books" class="btn btn-books"><span>📖</span> Books</a>
<a href="http://box/kiwix" class="btn btn-kiwix"><span>🥝</span> Kiwix</a>
<a href="http://box/kolibri" class="btn btn-kolibri"><span>📚</span> Kolibri</a>
<a href="http://box/maps" class="btn btn-maps"><span>🗺️</span> Maps</a>
<a href="http://box/matomo" class="btn btn-matomo"><span>📊</span> Matomo</a>
</div>
<script>
const overlay = document.getElementById('loadingOverlay');
const textLabel = document.getElementById('loadingText');
const buttons = document.querySelectorAll('.btn');
// 1. Mostrar el spinner al hacer clic
buttons.forEach(btn => {
btn.addEventListener('click', function(e) {
// Extraemos el texto del botón (ej. "Kolibri") para hacerlo personalizado
const appName = this.innerText.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]|\uD83C[\uDF00-\uDFFF]|\uD83D[\uDC00-\uDE4F]/g, '').trim();
textLabel.innerText = 'Abriendo ' + appName + '...';
// Mostramos la pantalla de carga
overlay.style.display = 'flex';
});
});
// 2. Ocultar el spinner si el usuario regresa con el botón "Atrás"
window.addEventListener('pageshow', function(event) {
overlay.style.display = 'none';
});
</script>
</body>
</html>

View File

@ -0,0 +1,43 @@
document.addEventListener("DOMContentLoaded", () => {
// 1. Detect device language
let userLang = (navigator.language || navigator.userLanguage).substring(0, 2).toLowerCase();
// Function to detect the user's language
const applyTranslations = () => {
if (!window.i18n) return; // If something went wrong, we return
// Search for all elements with the data-i18n attribute
const elements = document.querySelectorAll("[data-i18n]");
elements.forEach(el => {
const key = el.getAttribute("data-i18n");
// Si la clave existe en el diccionario, reemplaza el texto
if (window.i18n[key]) {
el.innerText = window.i18n[key];
}
});
};
// Function to load the .js file of the language
const loadScript = (langCode, isFallback = false) => {
const script = document.createElement("script");
script.src = `lang/${langCode}.js`;
// If the file exists, we apply the translations
script.onload = () => applyTranslations();
// If the file does NOT exist
script.onerror = () => {
if (!isFallback) {
console.log(`Idioma ${langCode} no encontrado. Cargando inglés...`);
loadScript("en", true); // Intentamos con el idioma por defecto
}
};
document.head.appendChild(script);
};
// 2. Start the script loading
loadScript(userLang);
});

View File

@ -0,0 +1,7 @@
window.i18n = {
"title": "Choose Content",
"maps": "🗺️ Maps",
"kolibri": "📚 Kolibri",
"kiwix": "📖 Kiwix",
"books": "📕 Books"
};

View File

@ -0,0 +1,7 @@
window.i18n = {
"title": "Elige el Contenido",
"maps": "🗺️ Mapas",
"kolibri": "📚 Kolibri",
"kiwix": "📖 Kiwix",
"books": "📕 Libros"
};

View File

@ -83,7 +83,7 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe
private Button button_apps;
private Button button_save;
private Button button_control;
private Button button_browse_content;
private Button watchdogControl;
private TextView connectionLog;
private LinearLayout logActions;
@ -138,7 +138,7 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe
batteryOptLauncher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
Log.d(TAG, "Regresamos de la pantalla de ajustes de batería");
Log.d(TAG, "Returned from the battery settings screen");
checkBatteryOptimizations();
}
);
@ -173,6 +173,7 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe
button_apps = findViewById(R.id.apps);
button_save = findViewById(R.id.save);
button_control = findViewById(R.id.control);
button_browse_content = findViewById(R.id.btnBrowseContent);
watchdogControl = findViewById(R.id.watchdog_control);
logActions = findViewById(R.id.log_actions);
@ -205,6 +206,12 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe
button_save.setOnClickListener(this);
button_control.setOnClickListener(this);
// Logic to open the WebView (PortalActivity)
button_browse_content.setOnClickListener(v -> {
Intent intent = new Intent(MainActivity.this, PortalActivity.class);
startActivity(intent);
});
connectionLog.setMovementMethod(new ScrollingMovementMethod());
connectionLog.setTextIsSelectable(true);
connectionLog.setOnTouchListener((v, event) -> {
@ -373,12 +380,11 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe
@Override
protected void onResume() {
super.onResume();
// Comprobamos batería siempre que volvemos a la app
// Check battery status whenever returning to the app
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE);
if (pm != null && !pm.isIgnoringBatteryOptimizations(getPackageName())) {
// Si no está ignorado, mostramos el aviso (o Snackbar si ya se lanzó el diálogo)
Log.d(TAG, "onResume: Batería aún optimizada, mostrando aviso");
Log.d(TAG, "onResume: Battery still optimized, showing warning");
showBatterySnackbar();
}
}
@ -625,7 +631,7 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe
private void showWatchdogBiometricPrompt() {
Executor ex = ContextCompat.getMainExecutor(this);
BiometricPrompt bp = new BiometricPrompt(this, ex, new BiometricPrompt.AuthenticationCallback() {
BiometricPrompt bp = new BiometricPrompt(this, new BiometricPrompt.AuthenticationCallback() {
@Override
public void onAuthenticationSucceeded(@NonNull BiometricPrompt.AuthenticationResult result) {
super.onAuthenticationSucceeded(result);
@ -655,6 +661,11 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe
button_control.setText(R.string.control_enable);
button_control.setBackgroundTintList(ContextCompat.getColorStateList(this, R.color.btn_vpn_off));
}
if (vpnActive) {
button_browse_content.setVisibility(View.VISIBLE);
} else {
button_browse_content.setVisibility(View.GONE);
}
if (watchdogActive) {
watchdogControl.setText(R.string.watchdog_disable);
watchdogControl.setBackgroundTintList(ContextCompat.getColorStateList(this, R.color.btn_watchdog_on));

View File

@ -0,0 +1,191 @@
package org.iiab.controller;
import android.os.Bundle;
import android.util.Log;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat;
import androidx.webkit.ProxyConfig;
import androidx.webkit.ProxyController;
import androidx.webkit.WebViewFeature;
import java.util.concurrent.Executor;
import android.graphics.Bitmap;
import android.view.View;
import android.widget.Button;
import android.widget.LinearLayout;
import android.content.Intent;
import android.os.Handler;
import android.os.Looper;
public class PortalActivity extends AppCompatActivity {
private static final String TAG = "IIAB-Portal";
private WebView webView;
private boolean isPageLoading = false;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_portal);
// 1. Basic WebView configuration
webView = findViewById(R.id.myWebView);
LinearLayout bottomNav = findViewById(R.id.bottomNav);
Button btnHandle = findViewById(R.id.btnHandle); // The new handle
Button btnHideNav = findViewById(R.id.btnHideNav); // Button to close
Button btnBack = findViewById(R.id.btnBack);
Button btnHome = findViewById(R.id.btnHome);
Button btnReload = findViewById(R.id.btnReload);
Button btnForward = findViewById(R.id.btnForward);
// Button actions
btnBack.setOnClickListener(v -> { if (webView.canGoBack()) webView.goBack(); });
btnForward.setOnClickListener(v -> { if (webView.canGoForward()) webView.goForward(); });
btnHome.setOnClickListener(v -> webView.loadUrl("file:///android_asset/index.html"));
// Dual logic: Forced reload or Stop
btnReload.setOnClickListener(v -> {
if (isPageLoading) {
webView.stopLoading();
} else {
// 1. Disable cache temporarily
webView.getSettings().setCacheMode(android.webkit.WebSettings.LOAD_NO_CACHE);
// 2. Force download from scratch
webView.reload();
}
});
// --- NEW: DETECT LOADING TO CHANGE BUTTON TO 'X' ---
webView.setWebViewClient(new WebViewClient() {
@Override
public boolean shouldOverrideUrlLoading(WebView view, android.webkit.WebResourceRequest request) {
String url = request.getUrl().toString();
String host = request.getUrl().getHost();
// 1. Local main menu
if (url.startsWith("file://")) {
return false; // return false means: "WebView, handle it yourself"
}
// 2. Internal server link (Box)
if (host != null && (host.equals("box") || host.equals("127.0.0.1") || host.equals("localhost"))) {
return false; // Remains in our app and travels through the proxy
}
// 3. External link (Real Internet)
try {
// Tell Android to find the correct app to open this (Chrome, YouTube, etc.)
Intent intent = new Intent(Intent.ACTION_VIEW, request.getUrl());
startActivity(intent);
} catch (Exception e) {
Log.e(TAG, "No app installed to open: " + url);
}
return true; // return true means: "WebView, I'll handle it, you ignore this click"
}
@Override
public void onPageStarted(WebView view, String url, Bitmap favicon) {
super.onPageStarted(view, url, favicon);
isPageLoading = true;
btnReload.setText(""); // Change to Stop
}
@Override
public void onPageFinished(WebView view, String url) {
super.onPageFinished(view, url);
isPageLoading = false;
btnReload.setText(""); // Back to Reload
// Restore cache for normal browsing speed
view.getSettings().setCacheMode(android.webkit.WebSettings.LOAD_DEFAULT);
}
});
// --- PREPARE HIDDEN BAR ---
// Wait for Android to draw the screen to determine bar height
// and hide it exactly below the bottom edge.
bottomNav.post(() -> {
bottomNav.setTranslationY(bottomNav.getHeight()); // Move outside the screen
bottomNav.setVisibility(View.VISIBLE); // Remove invisibility
});
// --- TEMPORIZADOR PARA AUTO-OCULTAR ---
Handler hideHandler = new Handler(Looper.getMainLooper());
// Esta es la acción de ocultar empaquetada para usarla luego
Runnable hideRunnable = () -> {
bottomNav.animate().translationY(bottomNav.getHeight()).setDuration(250);
btnHandle.setVisibility(View.VISIBLE);
btnHandle.animate().alpha(1f).setDuration(150);
};
// --- LÓGICA DEL TIRADOR (Mostrar Barra) ---
btnHandle.setOnClickListener(v -> {
// 1. Animamos la entrada
btnHandle.animate().alpha(0f).setDuration(150).withEndAction(() -> btnHandle.setVisibility(View.GONE));
bottomNav.animate().translationY(0).setDuration(250);
// 2. Iniciamos la cuenta regresiva de 5 segundos (5000 ms)
hideHandler.removeCallbacks(hideRunnable); // Cancelamos si había una cuenta anterior
hideHandler.postDelayed(hideRunnable, 5000); // <- Cambia el 5000 por 7000 si prefieres 7 segundos
});
// --- LÓGICA DE CERRAR BARRA MANUALMENTE ---
btnHideNav.setOnClickListener(v -> {
hideHandler.removeCallbacks(hideRunnable); // Cancelamos el temporizador para que no choque
hideRunnable.run(); // Ejecutamos la acción de ocultar inmediatamente
});
webView.getSettings().setJavaScriptEnabled(true);
webView.getSettings().setDomStorageEnabled(true);
// 2. Port and Mirror logic
Preferences prefs = new Preferences(this);
int tempPort = prefs.getSocksPort();
if (tempPort <= 0) tempPort = 1080;
// Variable safe to read in lambda
final int finalProxyPort = tempPort;
// 3. Proxy block
if (WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) {
ProxyConfig proxyConfig = new ProxyConfig.Builder()
.addProxyRule("socks5://127.0.0.1:" + finalProxyPort)
.build();
Executor executor = ContextCompat.getMainExecutor(this);
ProxyController.getInstance().setProxyOverride(proxyConfig, executor, () -> {
Log.d(TAG, "Proxy configured on port: " + finalProxyPort);
// Load HTML only when proxy is ready
webView.loadUrl("file:///android_asset/index.html");
});
} else {
// Fallback for older devices
Log.w(TAG, "Proxy Override not supported");
}
webView.loadUrl("file:///android_asset/index.html");
}
// 4. Cleanup (Important to not leave the proxy active)
@Override
protected void onDestroy() {
super.onDestroy();
if (WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) {
ProxyController.getInstance().clearProxyOverride(Runnable::run, () -> {
Log.d(TAG, "WebView proxy released");
});
}
}
@Override
public void onBackPressed() {
if (webView.canGoBack()) {
webView.goBack();
} else {
super.onBackPressed();
}
}
}

View File

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<WebView
android:id="@+id/myWebView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<Button
android:id="@+id/btnHandle"
android:layout_width="60dp"
android:layout_height="30dp"
android:layout_alignParentBottom="true"
android:layout_centerHorizontal="true"
android:background="#AA000000"
android:text="⌃"
android:textColor="#FFFFFF"
android:textSize="18sp"
android:padding="0dp"
android:stateListAnimator="@null" />
<LinearLayout
android:id="@+id/bottomNav"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:background="#EEF2F2F2"
android:orientation="vertical"
android:visibility="invisible"
android:elevation="8dp">
<Button
android:id="@+id/btnHideNav"
android:layout_width="match_parent"
android:layout_height="30dp"
android:background="?android:attr/selectableItemBackground"
android:text="⌄"
android:textSize="20sp"
android:textColor="#555555"
android:stateListAnimator="@null" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingBottom="8dp">
<Button android:id="@+id/btnBack" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:text="◀" android:textSize="20sp" style="?android:attr/buttonBarButtonStyle"/>
<Button android:id="@+id/btnHome" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:text="🏠" android:textSize="20sp" style="?android:attr/buttonBarButtonStyle"/>
<Button android:id="@+id/btnReload" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:text="↻" android:textSize="20sp" style="?android:attr/buttonBarButtonStyle"/>
<Button android:id="@+id/btnForward" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:text="▶" android:textSize="20sp" style="?android:attr/buttonBarButtonStyle"/>
</LinearLayout>
</LinearLayout>
</RelativeLayout>

View File

@ -108,6 +108,14 @@
android:layout_marginTop="8dp"
android:layout_marginBottom="12dp"/>
<!-- Local WebView -->
<Button
android:id="@+id/btnBrowseContent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Browse Content"
android:visibility="gone" />
<Button
android:id="@+id/apps"
android:layout_width="match_parent"