diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 6813d6dd0..d76d31a61 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -17,13 +17,17 @@ on: paths: - 'external/**' +permissions: + contents: write # Required to git push compiled binaries and create GitHub Releases + actions: read + jobs: build-busybox: name: Build Busybox runs-on: ubuntu-latest strategy: matrix: - arch: [arm, arm64, mips, x86] + arch: [arm, arm64, x86] include: - arch: arm target: arm-linux-gnueabihf @@ -33,10 +37,6 @@ jobs: target: aarch64-linux-gnu cc: aarch64-linux-gnu-gcc strip: aarch64-linux-gnu-strip - - arch: mips - target: mips-linux-gnu - cc: mips-linux-gnu-gcc - strip: mips-linux-gnu-strip - arch: x86 target: i686-linux-gnu cc: i686-linux-gnu-gcc @@ -52,9 +52,66 @@ jobs: sudo apt-get install -y \ build-essential \ wget \ - gcc-${{ matrix.target }} \ + unzip \ file + - name: Set up Android NDK + run: | + NDK_VERSION="r26d" + echo "Downloading Android NDK ${NDK_VERSION}..." + + # Download with retries and better error handling + for i in {1..3}; do + if wget -q "https://dl.google.com/android/repository/android-ndk-${NDK_VERSION}-linux.zip"; then + echo "✓ Downloaded android-ndk-${NDK_VERSION}-linux.zip (attempt $i)" + break + fi + echo "Download attempt $i failed, retrying..." + sleep 5 + done + + # Verify download + if [ ! -f "android-ndk-${NDK_VERSION}-linux.zip" ]; then + echo "❌ Failed to download NDK after 3 attempts" + exit 1 + fi + + echo "Extracting NDK..." + unzip -q android-ndk-${NDK_VERSION}-linux.zip + + # Verify extraction + if [ ! -d "android-ndk-${NDK_VERSION}" ]; then + echo "❌ NDK extraction failed - directory not found" + ls -la + exit 1 + fi + + export ANDROID_NDK_ROOT="$(pwd)/android-ndk-${NDK_VERSION}" + echo "✓ NDK setup complete: ${ANDROID_NDK_ROOT}" + echo "ANDROID_NDK_ROOT=${ANDROID_NDK_ROOT}" >> $GITHUB_ENV + + # Set up NDK toolchain paths (including NDK_AR for llvm-ar) + case "${{ matrix.arch }}" in + arm64) + echo "NDK_CC=${ANDROID_NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android28-clang" >> $GITHUB_ENV + echo "NDK_STRIP=${ANDROID_NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip" >> $GITHUB_ENV + echo "NDK_AR=${ANDROID_NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar" >> $GITHUB_ENV + echo "NDK_TARGET=aarch64-linux-android" >> $GITHUB_ENV + ;; + arm) + echo "NDK_CC=${ANDROID_NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi28-clang" >> $GITHUB_ENV + echo "NDK_STRIP=${ANDROID_NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip" >> $GITHUB_ENV + echo "NDK_AR=${ANDROID_NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar" >> $GITHUB_ENV + echo "NDK_TARGET=armv7a-linux-androideabi" >> $GITHUB_ENV + ;; + x86) + echo "NDK_CC=${ANDROID_NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64/bin/i686-linux-android28-clang" >> $GITHUB_ENV + echo "NDK_STRIP=${ANDROID_NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip" >> $GITHUB_ENV + echo "NDK_AR=${ANDROID_NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar" >> $GITHUB_ENV + echo "NDK_TARGET=i686-linux-android" >> $GITHUB_ENV + ;; + esac + - name: Download Busybox source run: | BUSYBOX_VERSION="${{ github.event.inputs.busybox_version || '1.36.1' }}" @@ -77,66 +134,200 @@ jobs: run: | cd "$BUSYBOX_DIR" - # Start with minimal config to avoid cross-compilation issues - make ARCH=${{ matrix.arch }} CROSS_COMPILE=${{ matrix.target }}- allnoconfig - - # Enable only essential features for AFWall+ - cat >> .config << EOF - CONFIG_STATIC=y - CONFIG_INSTALL_NO_USR=y - CONFIG_BUSYBOX=y - CONFIG_BUSYBOX_EXEC_PATH="/system/xbin/busybox" - CONFIG_ASH=y - CONFIG_SH_IS_ASH=y - CONFIG_BASH_IS_NONE=y - CONFIG_CAT=y - CONFIG_CHMOD=y - CONFIG_CP=y - CONFIG_ECHO=y - CONFIG_GREP=y - CONFIG_KILL=y - CONFIG_KILLALL=y - CONFIG_LS=y - CONFIG_MKDIR=y - CONFIG_MV=y - CONFIG_PS=y - CONFIG_RM=y - CONFIG_TEST=y - CONFIG_WHICH=y - CONFIG_IPTABLES=y - CONFIG_IP6TABLES=y - CONFIG_FEATURE_PREFER_APPLETS=n - CONFIG_FEATURE_SH_STANDALONE=y - EOF + # Use the basic Android default config (more stable than android_ndk_defconfig) + cp configs/android_defconfig .config + + # Fix the cross-compiler prefix for NDK + sed -i 's/CONFIG_CROSS_COMPILER_PREFIX=.*/CONFIG_CROSS_COMPILER_PREFIX=""/' .config + + # Process the initial config + yes '' | make ARCH=${{ matrix.arch }} oldconfig + + # Disable problematic applets for Android bionic libc compatibility + echo "Disabling problematic applets for Android..." + sed -i 's/CONFIG_SWAPON=y/# CONFIG_SWAPON is not set/g' .config + sed -i 's/CONFIG_SWAPOFF=y/# CONFIG_SWAPOFF is not set/g' .config + sed -i 's/CONFIG_FEATURE_SWAPON_DISCARD=y/# CONFIG_FEATURE_SWAPON_DISCARD is not set/g' .config + sed -i 's/CONFIG_FEATURE_SWAPON_PRI=y/# CONFIG_FEATURE_SWAPON_PRI is not set/g' .config + + # Disable problematic archival features + sed -i 's/CONFIG_TAR=y/# CONFIG_TAR is not set/g' .config + sed -i 's/CONFIG_FEATURE_TAR_TO_COMMAND=y/# CONFIG_FEATURE_TAR_TO_COMMAND is not set/g' .config + sed -i 's/CONFIG_FEATURE_TAR_FROM=y/# CONFIG_FEATURE_TAR_FROM is not set/g' .config + sed -i 's/CONFIG_AR=y/# CONFIG_AR is not set/g' .config + sed -i 's/CONFIG_DPKG=y/# CONFIG_DPKG is not set/g' .config + sed -i 's/CONFIG_DPKG_DEB=y/# CONFIG_DPKG_DEB is not set/g' .config + sed -i 's/CONFIG_RPM=y/# CONFIG_RPM is not set/g' .config + sed -i 's/CONFIG_RPM2CPIO=y/# CONFIG_RPM2CPIO is not set/g' .config + + # Disable features that require crypt.h (not available in Android bionic) + sed -i 's/CONFIG_LOGIN=y/# CONFIG_LOGIN is not set/g' .config + sed -i 's/CONFIG_PASSWD=y/# CONFIG_PASSWD is not set/g' .config + sed -i 's/CONFIG_SU=y/# CONFIG_SU is not set/g' .config + sed -i 's/CONFIG_SULOGIN=y/# CONFIG_SULOGIN is not set/g' .config + sed -i 's/CONFIG_VLOCK=y/# CONFIG_VLOCK is not set/g' .config + sed -i 's/CONFIG_CRYPTPW=y/# CONFIG_CRYPTPW is not set/g' .config + sed -i 's/CONFIG_CHPASSWD=y/# CONFIG_CHPASSWD is not set/g' .config + sed -i 's/CONFIG_FEATURE_SHADOWPASSWDS=y/# CONFIG_FEATURE_SHADOWPASSWDS is not set/g' .config + + # Disable other bionic-incompatible features + sed -i 's/CONFIG_FEATURE_UTMP=y/# CONFIG_FEATURE_UTMP is not set/g' .config + sed -i 's/CONFIG_FEATURE_WTMP=y/# CONFIG_FEATURE_WTMP is not set/g' .config + sed -i 's/CONFIG_LAST=y/# CONFIG_LAST is not set/g' .config + sed -i 's/CONFIG_USERS=y/# CONFIG_USERS is not set/g' .config + sed -i 's/CONFIG_WHO=y/# CONFIG_WHO is not set/g' .config + sed -i 's/CONFIG_W=y/# CONFIG_W is not set/g' .config + + # Disable networking features that require crypt.h + sed -i 's/CONFIG_FTPD=y/# CONFIG_FTPD is not set/g' .config + sed -i 's/CONFIG_FEATURE_FTP_AUTHENTICATION=y/# CONFIG_FEATURE_FTP_AUTHENTICATION is not set/g' .config + sed -i 's/CONFIG_FEATURE_HTTPD_AUTH_MD5=y/# CONFIG_FEATURE_HTTPD_AUTH_MD5 is not set/g' .config + sed -i 's/CONFIG_FEATURE_HTTPD_BASIC_AUTH=y/# CONFIG_FEATURE_HTTPD_BASIC_AUTH is not set/g' .config + sed -i 's/CONFIG_MKPASSWD=y/# CONFIG_MKPASSWD is not set/g' .config + + # Reprocess config + yes '' | make ARCH=${{ matrix.arch }} oldconfig + + # Fix x86 register pressure issue in TLS code + if [ "${{ matrix.arch }}" = "x86" ]; then + echo "Disabling ALL TLS and crypto features for x86 due to register pressure" + + # Disable all TLS/SSL-related features comprehensively + sed -i 's/CONFIG_TLS=y/# CONFIG_TLS is not set/g' .config + sed -i 's/CONFIG_FEATURE_TLS_SHA1=y/# CONFIG_FEATURE_TLS_SHA1 is not set/g' .config + sed -i 's/CONFIG_FEATURE_WGET_HTTPS=y/# CONFIG_FEATURE_WGET_HTTPS is not set/g' .config + sed -i 's/CONFIG_FTPS=y/# CONFIG_FTPS is not set/g' .config + sed -i 's/CONFIG_SHA1SUM=y/# CONFIG_SHA1SUM is not set/g' .config + sed -i 's/CONFIG_SHA256SUM=y/# CONFIG_SHA256SUM is not set/g' .config + sed -i 's/CONFIG_SHA512SUM=y/# CONFIG_SHA512SUM is not set/g' .config + sed -i 's/CONFIG_MD5SUM=y/# CONFIG_MD5SUM is not set/g' .config + + # Disable SSL client utilities that depend on TLS + sed -i 's/CONFIG_SSL_CLIENT=y/# CONFIG_SSL_CLIENT is not set/g' .config + sed -i 's/CONFIG_FEATURE_SSL_CLIENT=y/# CONFIG_FEATURE_SSL_CLIENT is not set/g' .config + + # Disable wget entirely if it depends on TLS (safer approach) + sed -i 's/CONFIG_WGET=y/# CONFIG_WGET is not set/g' .config + sed -i 's/CONFIG_FEATURE_WGET_STATUSBAR=y/# CONFIG_FEATURE_WGET_STATUSBAR is not set/g' .config + sed -i 's/CONFIG_FEATURE_WGET_AUTHENTICATION=y/# CONFIG_FEATURE_WGET_AUTHENTICATION is not set/g' .config + sed -i 's/CONFIG_FEATURE_WGET_LONG_OPTIONS=y/# CONFIG_FEATURE_WGET_LONG_OPTIONS is not set/g' .config + sed -i 's/CONFIG_FEATURE_WGET_TIMEOUT=y/# CONFIG_FEATURE_WGET_TIMEOUT is not set/g' .config + + # Disable any crypto/hash utilities that might link to TLS code + sed -i 's/CONFIG_BASE64=y/# CONFIG_BASE64 is not set/g' .config + sed -i 's/CONFIG_UUENCODE=y/# CONFIG_UUENCODE is not set/g' .config + sed -i 's/CONFIG_UUDECODE=y/# CONFIG_UUDECODE is not set/g' .config + + # Multiple reprocessing to ensure all dependencies are resolved + yes '' | make ARCH=${{ matrix.arch }} oldconfig + yes '' | make ARCH=${{ matrix.arch }} oldconfig + + # Remove ALL TLS/SSL-related source files to prevent any compilation + echo "Removing all TLS/SSL source files for x86..." + for pattern in "tls*.c" "ssl_client.c"; do + find networking -name "$pattern" -type f | while read tls_file; do + if [ -f "$tls_file" ]; then + echo "Removing $tls_file" + rm "$tls_file" + touch "$tls_file" + fi + done + done + fi + + # Show what's enabled for debugging + echo "=== Essential applets for AFWall+ ===" + grep -E "CONFIG_(PING|IFCONFIG|LS|ECHO|CAT|GREP|FEATURE_INSTALLER)=" .config || true - # Apply minimal config (use yes to answer all prompts with default) - yes '' | make ARCH=${{ matrix.arch }} CROSS_COMPILE=${{ matrix.target }}- oldconfig + echo "=== Problematic features (should be disabled) ===" + grep -E "CONFIG_(SWAPON|TAR_TO_COMMAND|DPKG|RPM|TLS).*=" .config || true - # Fix cross-compilation issues for all architectures - # Replace the problematic off_t size check that fails during cross-compilation + # Fix cross-compilation and duplicate symbol issues + echo "Applying Android compatibility fixes..." + + # Fix off_t size check if grep -q "struct BUG_off_t_size_is_misdetected" include/libbb.h; then sed -i '/struct BUG_off_t_size_is_misdetected/,/};/c\ - /* Cross-compilation fix: off_t size check disabled for all architectures */' include/libbb.h - echo "Applied cross-compilation fix for ${{ matrix.arch }}" + /* Cross-compilation fix: off_t size check disabled */' include/libbb.h + fi + + # Fix FAST_FUNC calling convention + sed -i 's/__attribute__((regparm(3),stdcall))/__attribute__((regparm(3)))/g' include/platform.h + + # Remove strchrnul conflicts + sed -i '/extern char \*strchrnul.*FAST_FUNC;/d' include/platform.h + sed -i '/char\* FAST_FUNC strchrnul/,/^}/d' libbb/platform.c + + # Create stub for data_extract_to_command to fix linking + if [ ! -f "archival/libarchive/data_extract_to_command.c.bak" ]; then + cp archival/libarchive/data_extract_to_command.c archival/libarchive/data_extract_to_command.c.bak + cat > archival/libarchive/data_extract_to_command.c << 'EOF' + /* Stub for data_extract_to_command when FEATURE_TAR_TO_COMMAND is disabled */ + #include "libbb.h" + #include "bb_archive.h" + + void FAST_FUNC data_extract_to_command(archive_handle_t *archive_handle) + { + bb_simple_error_msg_and_die("--to-command feature not compiled"); + } + EOF + # Force compile the stub by enabling it in Kbuild temporarily + sed -i 's/lib-\$(CONFIG_FEATURE_TAR_TO_COMMAND)/lib-y/' archival/libarchive/Kbuild + fi + + # Create stub for pw_encrypt to fix linking issues when password features are disabled + if [ ! -f "libbb/pw_encrypt.c.bak" ]; then + cp libbb/pw_encrypt.c libbb/pw_encrypt.c.bak + cat > libbb/pw_encrypt.c << 'EOF' + /* Stub for pw_encrypt when crypt.h features are disabled */ + #include "libbb.h" + + char* FAST_FUNC pw_encrypt(const char *clear, const char *salt, int cleanup) + { + bb_simple_error_msg_and_die("password encryption not supported"); + return NULL; + } + EOF + fi + + # Fix duplicate syscall symbols + if [ -f "libbb/missing_syscalls.c" ]; then + echo "Fixing duplicate syscall symbols..." + sed -i '/pid_t.*getsid/,/^}/d' libbb/missing_syscalls.c + sed -i '/int.*adjtimex/,/^}/d' libbb/missing_syscalls.c + sed -i '/int.*sethostname/,/^}/d' libbb/missing_syscalls.c + sed -i '/getsid.*(/d' libbb/missing_syscalls.c + sed -i '/adjtimex.*(/d' libbb/missing_syscalls.c + sed -i '/sethostname.*(/d' libbb/missing_syscalls.c + fi + + if [ -f "include/libbb.h" ]; then + sed -i '/extern.*getsid.*(/d' include/libbb.h + sed -i '/extern.*adjtimex.*(/d' include/libbb.h + sed -i '/extern.*sethostname.*(/d' include/libbb.h fi - name: Build Busybox run: | cd "$BUSYBOX_DIR" - # Build with explicit compiler settings + # Build with Android NDK make ARCH=${{ matrix.arch }} \ - CROSS_COMPILE=${{ matrix.target }}- \ - CC=${{ matrix.cc }} \ + CC="${NDK_CC}" \ + AR="${NDK_AR}" \ HOSTCC=gcc \ HOSTCXX=g++ \ + STRIP="${NDK_STRIP}" \ + CFLAGS="-static -DHAVE_STRCHRNUL" \ + EXTRA_CFLAGS="-DHAVE_STRCHRNUL" \ + LDFLAGS="-static" \ -j$(nproc) # Verify binary file busybox # Strip and copy - ${{ matrix.strip }} busybox + ${NDK_STRIP} busybox cp busybox ../busybox_${{ matrix.arch }} - name: Upload Busybox artifact @@ -150,7 +341,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - arch: [arm, arm64, mips, x86] + arch: [arm, arm64, x86] include: - arch: arm target: arm-linux-gnueabihf @@ -162,11 +353,6 @@ jobs: cc: aarch64-linux-gnu-gcc strip: aarch64-linux-gnu-strip configure_host: aarch64-linux-gnu - - arch: mips - target: mips-linux-gnu - cc: mips-linux-gnu-gcc - strip: mips-linux-gnu-strip - configure_host: mips-linux-gnu - arch: x86 target: i686-linux-gnu cc: i686-linux-gnu-gcc @@ -183,13 +369,67 @@ jobs: sudo apt-get install -y \ build-essential \ wget \ - gcc-${{ matrix.target }} \ + unzip \ pkg-config \ autoconf \ automake \ libtool \ file + - name: Set up Android NDK + run: | + NDK_VERSION="r26d" + echo "Downloading Android NDK ${NDK_VERSION}..." + + # Download with retries and better error handling + for i in {1..3}; do + if wget -q "https://dl.google.com/android/repository/android-ndk-${NDK_VERSION}-linux.zip"; then + echo "✓ Downloaded android-ndk-${NDK_VERSION}-linux.zip (attempt $i)" + break + fi + echo "Download attempt $i failed, retrying..." + sleep 5 + done + + # Verify download + if [ ! -f "android-ndk-${NDK_VERSION}-linux.zip" ]; then + echo "❌ Failed to download NDK after 3 attempts" + exit 1 + fi + + echo "Extracting NDK..." + unzip -q android-ndk-${NDK_VERSION}-linux.zip + + # Verify extraction + if [ ! -d "android-ndk-${NDK_VERSION}" ]; then + echo "❌ NDK extraction failed - directory not found" + ls -la + exit 1 + fi + + export ANDROID_NDK_ROOT="$(pwd)/android-ndk-${NDK_VERSION}" + echo "✓ NDK setup complete: ${ANDROID_NDK_ROOT}" + echo "ANDROID_NDK_ROOT=${ANDROID_NDK_ROOT}" >> $GITHUB_ENV + + # Set up NDK toolchain paths + case "${{ matrix.arch }}" in + arm64) + echo "NDK_CC=${ANDROID_NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android28-clang" >> $GITHUB_ENV + echo "NDK_STRIP=${ANDROID_NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip" >> $GITHUB_ENV + echo "NDK_TARGET=aarch64-linux-android" >> $GITHUB_ENV + ;; + arm) + echo "NDK_CC=${ANDROID_NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi28-clang" >> $GITHUB_ENV + echo "NDK_STRIP=${ANDROID_NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip" >> $GITHUB_ENV + echo "NDK_TARGET=armv7a-linux-androideabi" >> $GITHUB_ENV + ;; + x86) + echo "NDK_CC=${ANDROID_NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64/bin/i686-linux-android28-clang" >> $GITHUB_ENV + echo "NDK_STRIP=${ANDROID_NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip" >> $GITHUB_ENV + echo "NDK_TARGET=i686-linux-android" >> $GITHUB_ENV + ;; + esac + - name: Download iptables source run: | IPTABLES_VERSION="${{ github.event.inputs.iptables_version || '1.8.10' }}" @@ -212,33 +452,67 @@ jobs: run: | cd "$IPTABLES_DIR" - # Set cross-compilation environment - export CC=${{ matrix.cc }} - export STRIP=${{ matrix.strip }} - export CFLAGS="-static -Os -DANDROID" + # Set Android NDK cross-compilation environment + export CC="${NDK_CC}" + export STRIP="${NDK_STRIP}" + + # Set architecture-specific TLS flags and fix lock path + case "${{ matrix.arch }}" in + arm64) + export CFLAGS="-static -Os -DANDROID -D_GNU_SOURCE -Wno-logical-op -Wno-unknown-warning-option -fno-emulated-tls -DXTABLES_LOCK_DIR='\"/data/local/tmp\"'" + ;; + *) + export CFLAGS="-static -Os -DANDROID -D_GNU_SOURCE -Wno-logical-op -Wno-unknown-warning-option -DXTABLES_LOCK_DIR='\"/data/local/tmp\"'" + ;; + esac + export LDFLAGS="-static" export PKG_CONFIG_PATH="" - # Configure for cross-compilation + # Configure for Android cross-compilation ./configure \ - --host=${{ matrix.configure_host }} \ + --host=${NDK_TARGET} \ --enable-static \ --disable-shared \ --disable-nftables \ --disable-bpf-compiler \ --disable-connlabel \ --disable-devel \ - --with-kernel=/usr/include + --with-kernel=/usr/include \ + --disable-libipq \ + --with-xtlockdir=/data/local/tmp - name: Build iptables run: | cd "$IPTABLES_DIR" - export CC=${{ matrix.cc }} - export STRIP=${{ matrix.strip }} - export CFLAGS="-static -Os -DANDROID" + export CC="${NDK_CC}" + export STRIP="${NDK_STRIP}" + + # Set architecture-specific TLS flags and fix lock path + case "${{ matrix.arch }}" in + arm64) + export CFLAGS="-static -Os -DANDROID -D_GNU_SOURCE -Wno-logical-op -Wno-unknown-warning-option -fno-emulated-tls -DXTABLES_LOCK_DIR='\"/data/local/tmp\"'" + ;; + *) + export CFLAGS="-static -Os -DANDROID -D_GNU_SOURCE -Wno-logical-op -Wno-unknown-warning-option -DXTABLES_LOCK_DIR='\"/data/local/tmp\"'" + ;; + esac + export LDFLAGS="-static" + # Remove only the cgroup extension (has O_PATH macro conflict) + rm -f extensions/libxt_cgroup.c + + # Fix prioritynames issue in LOG extension for Android compatibility + sed -i 's/prioritynames\[i\]\.c_name/0/g; s/prioritynames\[i\]\.c_val/0/g' extensions/libxt_LOG.c + + # Fix hardcoded lock path for Android - replace /run/xtables.lock with /data/local/tmp/xtables.lock + find . -name "*.c" -o -name "*.h" | xargs grep -l "/run/xtables.lock" | while read file; do + sed -i 's|/run/xtables\.lock|/data/local/tmp/xtables.lock|g' "$file" + echo "Fixed lock path in $file" + done + # Build make -j$(nproc) V=1 @@ -257,8 +531,8 @@ jobs: file "../iptables_${{ matrix.arch }}" file "../ip6tables_${{ matrix.arch }}" - ${{ matrix.strip }} "../iptables_${{ matrix.arch }}" - ${{ matrix.strip }} "../ip6tables_${{ matrix.arch }}" + ${NDK_STRIP} "../iptables_${{ matrix.arch }}" + ${NDK_STRIP} "../ip6tables_${{ matrix.arch }}" - name: Upload iptables artifacts uses: actions/upload-artifact@v4 @@ -273,24 +547,21 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - arch: [arm, arm64, mips, x86] + arch: [arm, arm64, x86] + # nflog targets API 29: from API 29 on, the NDK's crt objects give the executable's TLS + # segment the alignment Bionic requires (64 bytes on arm64, 32 on arm). Android 16 + # refuses to start the API-28 build ("executable's TLS segment is underaligned"). + # busybox/iptables have no TLS segment and keep API 28. include: - arch: arm - target: arm-linux-gnueabihf - cc: arm-linux-gnueabihf-gcc - strip: arm-linux-gnueabihf-strip + ndk_cc_suffix: armv7a-linux-androideabi29-clang + min_tls_align: 32 - arch: arm64 - target: aarch64-linux-gnu - cc: aarch64-linux-gnu-gcc - strip: aarch64-linux-gnu-strip - - arch: mips - target: mips-linux-gnu - cc: mips-linux-gnu-gcc - strip: mips-linux-gnu-strip + ndk_cc_suffix: aarch64-linux-android29-clang + min_tls_align: 64 - arch: x86 - target: i686-linux-gnu - cc: i686-linux-gnu-gcc - strip: i686-linux-gnu-strip + ndk_cc_suffix: i686-linux-android29-clang + min_tls_align: 0 steps: - name: Checkout repository @@ -302,89 +573,82 @@ jobs: sudo apt-get install -y \ build-essential \ wget \ - gcc-${{ matrix.target }} \ - pkg-config \ - file \ - libnetfilter-log-dev + unzip \ + file + + - name: Set up Android NDK + run: | + NDK_VERSION="r26d" + echo "Downloading Android NDK ${NDK_VERSION}..." + + for i in {1..3}; do + if wget -q "https://dl.google.com/android/repository/android-ndk-${NDK_VERSION}-linux.zip"; then + echo "✓ Downloaded android-ndk-${NDK_VERSION}-linux.zip (attempt $i)" + break + fi + echo "Download attempt $i failed, retrying..." + sleep 5 + done + + if [ ! -f "android-ndk-${NDK_VERSION}-linux.zip" ]; then + echo "❌ Failed to download NDK after 3 attempts" + exit 1 + fi + + echo "Extracting NDK..." + unzip -q android-ndk-${NDK_VERSION}-linux.zip + + export ANDROID_NDK_ROOT="$(pwd)/android-ndk-${NDK_VERSION}" + echo "ANDROID_NDK_ROOT=${ANDROID_NDK_ROOT}" >> $GITHUB_ENV + echo "NDK_CC=${ANDROID_NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64/bin/${{ matrix.ndk_cc_suffix }}" >> $GITHUB_ENV + echo "NDK_STRIP=${ANDROID_NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip" >> $GITHUB_ENV + echo "✓ NDK setup complete" - name: Build nflog from AFWall+ source run: | cd external/nflog - # Compile nflog using AFWall+ source code - ${{ matrix.cc }} -static \ + # Use Android NDK clang — produces Android-native binaries that avoid + # AV false-positive heuristics triggered by GNU cross-compiled ELFs + ${NDK_CC} -static \ -I. -Ilibmnl \ + -D_GNU_SOURCE -DANDROID \ + -Os \ -o nflog_${{ matrix.arch }} \ nflog.c attr.c callback.c nlmsg.c socket.c - # Verify and strip + # Verify the binary file nflog_${{ matrix.arch }} - ${{ matrix.strip }} nflog_${{ matrix.arch }} + ${NDK_STRIP} nflog_${{ matrix.arch }} ls -la nflog_${{ matrix.arch }} + - name: Check TLS segment alignment + run: | + # Bionic refuses to run executables whose TLS segment is aligned below this + # (issues #1505/#1506: nflog built for API 28 had 8 on arm64). + min=${{ matrix.min_tls_align }} + readelf="${ANDROID_NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-readelf" + align=$("$readelf" -lW external/nflog/nflog_${{ matrix.arch }} | awk '/ TLS /{print $NF}') + if [ -z "$align" ]; then + echo "✓ no TLS segment" + elif [ $((align)) -lt "$min" ]; then + echo "❌ TLS segment alignment is $align, needs at least $min" + exit 1 + else + echo "✓ TLS segment alignment $align (minimum $min)" + fi + - name: Upload nflog artifact uses: actions/upload-artifact@v4 with: name: nflog-${{ matrix.arch }} path: external/nflog/nflog_${{ matrix.arch }} - build-run-pie: - name: Build run_pie (${{ matrix.arch }}) - runs-on: ubuntu-latest - strategy: - matrix: - include: - - arch: arm - target: arm-linux-gnueabihf - cc: arm-linux-gnueabihf-gcc - strip: arm-linux-gnueabihf-strip - - arch: arm64 - target: aarch64-linux-gnu - cc: aarch64-linux-gnu-gcc - strip: aarch64-linux-gnu-strip - - arch: mips - target: mips-linux-gnu - cc: mips-linux-gnu-gcc - strip: mips-linux-gnu-strip - - arch: x86 - target: i686-linux-gnu - cc: i686-linux-gnu-gcc - strip: i686-linux-gnu-strip - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up build environment - run: | - sudo apt-get update - sudo apt-get install -y \ - build-essential \ - gcc-${{ matrix.target }} \ - file - - - name: Build run_pie from AFWall+ source - run: | - cd external/run_pie - - # Compile run_pie using AFWall+ source code - ${{ matrix.cc }} -static -ldl -o run_pie_${{ matrix.arch }} run_pie.c - - # Verify the binary - file run_pie_${{ matrix.arch }} - ${{ matrix.strip }} run_pie_${{ matrix.arch }} - ls -la run_pie_${{ matrix.arch }} - - - name: Upload run_pie artifact - uses: actions/upload-artifact@v4 - with: - name: run-pie-${{ matrix.arch }} - path: external/run_pie/run_pie_${{ matrix.arch }} collect-binaries: name: Collect and Update Binaries runs-on: ubuntu-latest - needs: [build-busybox, build-iptables, build-nflog, build-run-pie] + needs: [build-busybox, build-iptables, build-nflog] steps: - name: Checkout repository @@ -400,7 +664,7 @@ jobs: # Create binaries directory for testing (separate from res/raw) mkdir -p binaries - for arch in arm arm64 mips x86; do + for arch in arm arm64 x86; do # Busybox if [ -f "artifacts/busybox-${arch}/busybox_${arch}" ]; then cp "artifacts/busybox-${arch}/busybox_${arch}" "binaries/" @@ -425,11 +689,6 @@ jobs: echo "✓ nflog_${arch} -> binaries/" fi - # run_pie - if [ -f "artifacts/run-pie-${arch}/run_pie_${arch}" ]; then - cp "artifacts/run-pie-${arch}/run_pie_${arch}" "binaries/" - echo "✓ run_pie_${arch} -> binaries/" - fi done echo "Binary inventory:" @@ -453,7 +712,7 @@ jobs: echo "No new binaries to commit" else # Create commit message - git commit -m "Update cross-compiled binaries - busybox v${{ github.event.inputs.busybox_version || '1.36.1' }}, iptables v${{ github.event.inputs.iptables_version || '1.8.10' }} for arm/arm64/mips/x86. Ready for testing in /binaries directory." + git commit -m "Update cross-compiled binaries - busybox v${{ github.event.inputs.busybox_version || '1.36.1' }}, iptables v${{ github.event.inputs.iptables_version || '1.8.10' }} for arm/arm64/x86. Ready for testing in /binaries directory." # Push changes back to repository git push @@ -480,11 +739,10 @@ jobs: name: AFWall+ Cross-compiled Binaries ${{ github.event.inputs.release_tag }} body: | Cross-compiled binaries for AFWall+: - - busybox (arm, arm64, mips, x86) - - iptables (arm, arm64, mips, x86) - - ip6tables (arm, arm64, mips, x86) - - nflog (arm, arm64, mips, x86) - - run_pie (arm, arm64, mips, x86) + - busybox (arm, arm64, x86) + - iptables (arm, arm64, x86) + - ip6tables (arm, arm64, x86) + - nflog (arm, arm64, x86) Built from commit: ${{ github.sha }} files: binaries/* diff --git a/.gitignore b/.gitignore index 8392555bf..032aeaa53 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ .DS_Store +make-donate.sh + # Files for the dex VM *.dex @@ -17,15 +19,8 @@ out/ build/ build.xml -# Gradle wrapper -gradlew -gradlew.bat -gradle-wrapper.jar -gradle-wrapper.properties - # Gradle .gradle/ -gradle/ # Log Files *.log diff --git a/Changelog.md b/Changelog.md index fa1d929a9..1afedd289 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,6 +1,117 @@ AFWall+ Changelog ================== +AFWall+ v4.1.0 + + Added: Custom rules at the application level - direct per-app allow/block rules + Added: Custom theme maker with pre-built themes (Amber, Ocean, Forest, Slate, Plum, Black) plus a high-contrast light theme + Added: Multi-user / work-profile app detection and improved app search + Added: Option to show package name alongside app name in the list + Improved: Firewall rule generation - IPv6 control traffic (ICMPv6 RS/RA/NS/NA), loopback routing, LAN discovery (multicast/broadcast/mDNS/SSDP), tethered DHCP replies, Tor redirect ordering, reject-chain logging + Improved: Root apply reliability - consistent failure handling; success now waits for both IPv4 and IPv6 rule application to complete + Improved: Log service reliability - watchdog restarts a dead log watcher, batched log writes + Improved: Multiple LAN subnets now route correctly to WAN (Issue #1362) + Fixed: Missing system apps caused by removal of QUERY_ALL_PACKAGES + Fixed: Import/export - removed filter that hid valid AFWall+ backup files + Fixed: Widget bugs - repeated toggle callback reuse, activity reference leak + Reverted: Per-app localhost blocking (Issue #1421) - reverted pending a more reliable approach + Updated: Target SDK 36 (Android 16) support + +AFWall+ v4.0.1 + + Fixed boot rules not being applied (#1438) + Fixed app search not working (#1445) + Fixed pull to refresh when list is empty (#1439) + Updated Magisk binary location (#1437) + Relaxed sanitize rule to allow existing custom rules + Added back button to PreferencesActivity + Optimized pattern handling and memory management + Added build scripts for F-Droid (#1441) + +AFWall+ v4.0.0 + +🚀 Major Features & Enhancements + +🎯 Rule Management & Stability + +- Fixed critical rule application issues - Resolved iptables command failures and cascade errors +- Improved error handling - Smart selective error handling prevents unnecessary fallbacks +- Enhanced chain management - Better synchronization prevents race conditions +- Owner module compatibility - Automatic detection and fallback for devices without owner + iptables module + +🎨 Material Design Overhaul + +- Modernized UI - Material Design enhancements for rules, help, and custom scripts views +- Revamped help section - Complete redesign with better organization and moved legends +- Visual widget indicators - Added pulse animations and visual feedback for toggle widgets +- Improved layouts - Fixed layout issues for devices with merged status bars + +📊 Enhanced Logging System + +- Better log details view - Enhanced display with allow/deny address information +- Improved UID detection - Better handling of special UIDs (including uid -100) and bug fixes +- Seamless log target switching - Dynamic switching between LOG and NFLOG in preferences +- NFLOG improvements - Better NFLOG and LOG handling with updated binaries + +🔒 Security Enhancements + +- Upgraded encryption - Migrated from DES to AES for better security +- Enhanced security utilities - New SecureCrypto and SecurityUtil classes +- Input validation improvements - Better validation and security checks + +🛠 Platform & Compatibility + +📱 Android Support + +- Android 16 preparation - Updated build configuration for future Android support +- Binary updates - Cross-compiled binaries: busybox v1.36.1, iptables v1.8.10 +- Architecture support - Added ARM64 binaries and improved architecture detection +- GitHub Actions CI - Automated binary builds and improved CI/CD pipeline + +🔧 Bug Fixes & Stability + +- Export/Import fixes - Resolved bugs when handling large numbers of files (#1399, #1401) +- Build error fixes - Fixed app:tint and other build-related issues +- Service leak fixes - Improved thread safety and fixed service connection leaks +- USB tethering support - Added auto-detection and support for USB tethering +- DNS forwarding - Improved DNS handling for tethering scenarios + +🔧 Technical Improvements + +⚡ Performance & Threading + +- Thread safety - Improved synchronization and thread-safe operations +- Better exception handling - More robust error handling and recovery +- Optimized rule processing - Faster and more reliable rule application + +🛠 Developer Experience + +- Code cleanup - Extensive refactoring and code organization improvements +- CI/CD enhancements - Updated GitHub Actions and automated workflows +- Binary management - Automated cross-compilation and binary distribution + +📋 Specific Issue Fixes + +- #1386 - Default chain rules only applied when necessary (with smart revert) +- #1410 - Fallback on default commands when needed +- #1423, #1382 - Various stability and functionality fixes +- #1400 - Layout fixes for merged status bar screens +- #1399 - Export only enabled rule types +- #1169 - Export/import rule improvements + +⚠️ Breaking Changes + +- Security upgrade - DES encryption deprecated in favor of AES +- Removed legacy views - Old unsupported view components removed +- Binary updates - Requires newer binaries for optimal performance + +🙏 Contributors + +- @getgo-nobugs - Syntax fixes and improvements +- @NeroProtagonist - CI/CD updates (upload-artifact@v4) +- @Fry-kun - Multiple fixes: typos, layout improvements, export fixes, ARM64 NFLOG binary (initial version) + Version 3.6.0 * Updated libraries and SDK (33) diff --git a/README.md b/README.md index 29f35494e..99d691991 100644 --- a/README.md +++ b/README.md @@ -2,35 +2,74 @@ [![Android CI](https://github.com/ukanth/afwall/workflows/Android%20CI/badge.svg?branch=beta)](https://github.com/ukanth/afwall/actions) [![Crowdin](https://d322cqt584bo4o.cloudfront.net/afwall/localized.png)](https://crowdin.net/project/afwall) ![License](https://img.shields.io/github/license/ukanth/afwall) ![F-Droid](https://img.shields.io/f-droid/v/dev.ukanth.ufirewall) ![Downloads](https://img.shields.io/github/downloads/ukanth/afwall/total) ![Repo Size](https://img.shields.io/github/repo-size/ukanth/afwall) +[![Build AFWall+ Binaries](https://github.com/ukanth/afwall/actions/workflows/build-binaries.yml/badge.svg)](https://github.com/ukanth/afwall/actions/workflows/build-binaries.yml) + > **Your Privacy, Your Control** - AFWall+ gives you complete control over which apps can access the internet on your Android device. +--- + + +## Support AFWall+ Development + +AFWall+ is developed and maintained by volunteers. If you find it useful, please consider supporting the project: + +### How to Donate + +**Why donate?** AFWall+ is free and open-source. Your support helps: +- Continue development and add new features +- Fix bugs and keep the app stable +- Support more Android versions and devices +- Maintain documentation and help the community + +**Donation options:** +- **PayPal**: [![Donate](https://www.paypalobjects.com/en_US/i/btn/btn_donate_SM.gif)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=6E4VZTULRB8GU) +- **Google Play**: Purchase the [unlocker key](https://play.google.com/store/apps/details?id=dev.ukanth.ufirewall.donate) for extra features +- **Amazon Gift Cards**: `cumakt+amazon at gmail.com` (not preferred) +- **Bitcoin**: `bc1q54nf3y9zmdcpasxx9sywkprd6309rfhav3mape` +- **Ethereum**: `0x5e65649C2B26eD816fCeD25a8E507C90D4b1D697` + +After donating, please send your receipt to contact@portgenix.com to receive an unlocker. Please allow 1-2 days for a response. + +### Other Ways to Help +- Star this repository +- Report bugs and test new features +- Contribute translations on [Crowdin](http://crowdin.net/project/afwall) +- Improve documentation +- Help other users in forums + +--- +

AFWall+ Screenshot

-## 🔥 What is AFWall+? + +## What is AFWall+? **AFWall+ (Android Firewall+)** is a powerful, open-source firewall application for rooted Android devices. Built on Linux's robust `iptables` framework, AFWall+ provides **granular network control** at the system level - something impossible with standard Android permissions. -### 🎯 **Core Purpose** -- **Block unwanted network access** by apps, even when they have internet permission -- **Prevent data leaks** and unauthorized background connections -- **Monitor network activity** with comprehensive logging -- **Save battery and data** by controlling which apps can connect when -- **Enhance privacy** by blocking tracking and analytics -### 🛡️ **How It Works** -AFWall+ operates at the **Linux kernel level** using `iptables` rules to: -1. **Intercept all network requests** before they leave your device -2. **Apply custom firewall rules** based on your preferences -3. **Allow or block connections** per app, per network type (WiFi, mobile, VPN) -4. **Log blocked attempts** for monitoring and analysis +### Core Purpose +- Block unwanted network access by apps, even if they have internet permission +- Prevent data leaks and unauthorized background connections +- Monitor network activity with comprehensive logging +- Save battery and data by controlling which apps can connect +- Enhance privacy by blocking tracking and analytics -This approach is **far more powerful** than app-level solutions because it works regardless of how apps try to connect to the internet. + +### How It Works +AFWall+ operates at the Linux kernel level using `iptables` rules to: +1. Intercept all network requests before they leave your device +2. Apply custom firewall rules based on your preferences +3. Allow or block connections per app, per network type (WiFi, mobile, VPN) +4. Log blocked attempts for monitoring and analysis + +This approach is much more powerful than app-level solutions because it works regardless of how apps try to connect to the internet. --- -## 📥 Download + +## Download

@@ -44,102 +83,117 @@ This approach is **far more powerful** than app-level solutions because it works

-📋 **Release Notes**: Check the [changelog](https://github.com/ukanth/afwall/blob/beta/Changelog.md) for what's new in each version. + +**Release Notes**: Check the [changelog](https://github.com/ukanth/afwall/blob/beta/Changelog.md) for what's new in each version. --- -## 🌟 Key Features - -### 🔐 **Granular Control** -- **Per-app network rules** - Allow/block individual apps -- **Network type filtering** - Different rules for WiFi, mobile data, VPN, tethering -- **IPv4 & IPv6 support** - Complete protocol coverage -- **Custom rule scripting** - Advanced users can write custom iptables rules - -### 🎛️ **User Experience** -- **Clean, intuitive interface** - Easy to understand app list with clear allow/block controls -- **Quick search & filtering** - Find apps instantly, sort by name, install date, or permissions -- **Bulk operations** - Enable/disable rules for multiple apps at once -- **Profile management** - Switch between different rule sets (home, work, travel) - -### 📊 **Monitoring & Logging** -- **Real-time network monitoring** - See which apps are trying to connect -- **Detailed connection logs** - Track blocked attempts with timestamps and destinations -- **Notification system** - Get alerts for blocked connection attempts -- **Export/import rules** - Backup your configuration or share with others - -### 🔧 **Advanced Features** -- **Boot protection** - Apply rules before apps start (prevents data leaks during startup) -- **Startup delay management** - Robust boot rule application with network change handling -- **Multi-user support** - Different profiles for different Android users -- **Tasker/Locale integration** - Automate firewall based on conditions -- **Password protection** - Secure your firewall settings -- **Tor and VPN detection** - Special handling for privacy networks - -### 🌐 **Network Types Supported** -- 📶 **Mobile Data** (3G/4G/5G) - including roaming detection -- 📡 **WiFi** - home, work, public hotspots -- 🔗 **VPN** - all VPN types and providers -- 🔄 **Tethering** - WiFi hotspot, USB, Bluetooth -- 🧅 **Tor** - onion routing support -- 🏠 **LAN** - local network access + +## Key Features + + +### Granular Control +- Per-app network rules: Allow or block individual apps +- Network type filtering: Different rules for WiFi, mobile data, VPN, tethering +- IPv4 & IPv6 support +- Custom rule scripting for advanced users + + +### User Experience +- Clean, intuitive interface +- Quick search and filtering +- Bulk operations for multiple apps +- Profile management for different rule sets + + +### Monitoring & Logging +- Real-time network monitoring +- Detailed connection logs +- Notification system for blocked attempts +- Export/import rules for backup or sharing + + +### Advanced Features +- Boot protection: Apply rules before apps start +- Startup delay management +- Multi-user support +- Tasker/Locale integration for automation +- Password protection +- Tor and VPN detection + + +### Network Types Supported +- Mobile Data (3G/4G/5G), including roaming detection +- WiFi (home, work, public hotspots) +- VPN (all types and providers) +- Tethering (WiFi hotspot, USB, Bluetooth) +- Tor (onion routing support) +- LAN (local network access) --- -## 📋 System Requirements -### ✅ **Compatibility** -- **Android versions**: 5.0 (API 21) to 14+ (actively maintained) +## System Requirements + + +### Compatibility +- Android versions: 5.0 (API 21) to 14+ (actively maintained) - Legacy support: Android 4.x (version 2.9.9), Android 2.x (version 1.3.4.1) -- **Root access**: Required (Magisk, SuperSU, LineageOS su) -- **Architectures**: ARM, ARM64, x86, x86_64, MIPS -- **Storage**: ~15MB app + ~5MB for binaries - -### 🔧 **Root Methods Supported** -- ✅ **Magisk** (recommended) -- ✅ **LineageOS built-in su** -- ✅ **SuperSU** (legacy) -- ✅ **KingRoot** (not recommended) - -### 🚫 **Limitations** -- **Requires root access** - No root = no functionality -- **Not an antivirus** - Doesn't scan files for malware -- **Not an ad-blocker** - Blocks network access, not ads within allowed connections -- **VPN conflicts** - Some VPN apps may interfere with firewall rules -- **System-level apps** - Some system processes may bypass rules if they have root access +- Root access: Required (Magisk, SuperSU, LineageOS su) +- Architectures: ARM, ARM64, x86, x86_64 +- Storage: ~15MB app + ~5MB for binaries + + +### Root Methods Supported +- Magisk (recommended) +- LineageOS built-in su +- SuperSU (legacy) +- KingRoot (not recommended) + + +### Limitations +- Requires root access (no root = no functionality) +- Not an antivirus (doesn't scan files for malware) +- Not an ad-blocker (blocks network access, not ads within allowed connections) +- VPN conflicts: Some VPN apps may interfere with firewall rules +- System-level apps: Some system processes may bypass rules if they have root access --- -## 🚀 Quick Start Guide -### 1. **Pre-Installation** +## Quick Start Guide + + +### 1. Pre-Installation ```bash # Verify root access su -c "id" # Should return: uid=0(root) gid=0(root) ``` -### 2. **Installation** +### 2. Installation - Install AFWall+ from your preferred source - Grant root permission when prompted -- Enable firewall in main screen +- Enable the firewall on the main screen -### 3. **Basic Configuration** -1. **Enable the firewall** - Toggle the main switch -2. **Configure apps** - Tap apps to allow WiFi (green) or mobile data (orange) -3. **Apply rules** - Tap the apply button (firewall icon) -4. **Test connectivity** - Verify apps work as expected +### 3. Basic Configuration +1. Enable the firewall (toggle the main switch) +2. Configure apps (tap apps to allow WiFi or mobile data) +3. Apply rules (tap the apply button) +4. Test connectivity (verify apps work as expected) -### 4. **Essential Settings** -- **Boot startup delay**: Prevents rule conflicts during boot -- **Notification settings**: Control alert behavior -- **Log settings**: Enable if you want connection monitoring +### 4. Essential Settings +- Boot startup delay: Prevents rule conflicts during boot +- Notification settings: Control alert behavior +- Log settings: Enable if you want connection monitoring --- -## 🔧 Advanced Configuration -### 📝 **Custom Rules** +## Advanced Configuration + + +### Custom Rules AFWall+ supports custom iptables rules for advanced users: ```bash @@ -150,20 +204,23 @@ AFWall+ supports custom iptables rules for advanced users: -A afwall -p tcp --dport 443 -j REJECT ``` -### 🔄 **Profiles** + +### Profiles Create different rule sets for different scenarios: -- **Home**: Relaxed rules for trusted network -- **Work**: Restrictive rules for corporate network -- **Public**: Maximum security for public WiFi -- **Travel**: Balanced rules for mobile use +- Home: Relaxed rules for trusted network +- Work: Restrictive rules for corporate network +- Public: Maximum security for public WiFi +- Travel: Balanced rules for mobile use + -### 📊 **Logging Configuration** -- **Packet logging**: Uses nflog for detailed connection tracking -- **Log rotation**: Automatic cleanup of old logs -- **Export options**: Save logs for external analysis +### Logging Configuration +- Packet logging: Uses nflog for detailed connection tracking +- Log rotation: Automatic cleanup of old logs +- Export options: Save logs for external analysis --- + ## 🌍 Language Support AFWall+ is available in **40+ languages** thanks to our community translators: @@ -174,24 +231,29 @@ AFWall+ is available in **40+ languages** thanks to our community translators: --- -## 🛠️ Development -### 🏗️ **Building from Source** +## Development + + +### Building from Source + -#### **Prerequisites** +#### Prerequisites - Android SDK (API level 21+) - Java 17+ - Git - Android NDK (for native binaries) -#### **Quick Build** + +#### Quick Build ```bash git clone https://github.com/ukanth/afwall.git cd afwall ./gradlew clean assembleDebug ``` -#### **Native Binaries** + +#### Native Binaries To compile iptables, busybox, and other native components: ```bash # Requires Android NDK @@ -199,7 +261,8 @@ export NDK=/opt/android-ndk-r25 make -C external NDK=$NDK ``` -### 📁 **Project Structure** + +### Project Structure ``` afwall/ ├── app/src/main/java/dev/ukanth/ufirewall/ @@ -215,7 +278,8 @@ afwall/ └── scripts/ # Build scripts ``` -### 🧪 **Testing** + +### Testing ```bash # Run lint checks ./gradlew lint @@ -229,47 +293,53 @@ afwall/ --- -## 🤝 Contributing + +## Contributing We welcome contributions! Here's how you can help: -### 🐛 **Bug Reports** + +### Bug Reports - Check [existing issues](https://github.com/ukanth/afwall/issues) first - Follow our [bug report guide](https://github.com/ukanth/afwall/wiki/HOWTO-Report-Bug) - Include device info, Android version, and logs -### 💡 **Feature Requests** + +### Feature Requests - Open an issue with the "enhancement" label - Describe the use case and expected behavior - Consider if it fits AFWall+'s scope and philosophy -### 👨‍💻 **Code Contributions** + +### Code Contributions ```bash # Standard GitHub workflow 1. Fork the repository 2. Create a feature branch: git checkout -b feature-name 3. Make your changes and test thoroughly -4. Submit a pull request with clear description +4. Submit a pull request with a clear description ``` -### 🌐 **Translations** + +### Translations - Join our [Crowdin project](http://crowdin.net/project/afwall) - No technical knowledge required - Help make AFWall+ accessible worldwide --- -## 📞 Community & Support -### 💬 **Discussion Forums** +## Community & Support + +### Discussion Forums - **XDA Thread**: [Official community discussion](http://forum.xda-developers.com/showthread.php?t=1957231) - **GitHub Issues**: Technical problems and feature requests - **Wiki**: [Comprehensive documentation](https://github.com/ukanth/afwall/wiki) -### ❓ **Frequently Asked Questions** +### Frequently Asked Questions Before reporting issues, check our [FAQ](https://github.com/ukanth/afwall/wiki/FAQ) for common solutions. -### 🆘 **Getting Help** +### Getting Help 1. Check the FAQ and wiki 2. Search existing GitHub issues 3. Ask on XDA forums @@ -277,40 +347,47 @@ Before reporting issues, check our [FAQ](https://github.com/ukanth/afwall/wiki/F --- -## 📖 Technical Details -### 🔧 **Architecture** -AFWall+ uses a **layered architecture**: +## Technical Details + + +### Architecture +AFWall+ uses a layered architecture: 1. **UI Layer**: Android activities and fragments for user interaction 2. **Service Layer**: Background services for rule application and monitoring 3. **Core Layer**: iptables rule generation and management 4. **System Layer**: Native binaries and root shell interface -### 🏗️ **Key Components** -- **BootRuleManager**: Robust boot-time rule application with race condition prevention -- **InterfaceTracker**: Network interface monitoring and change detection -- **Api.java**: Central iptables command generation and execution -- **FirewallService**: Background service for continuous monitoring -- **LogService**: Network packet logging and analysis -### 📱 **Android Integration** -- **Broadcast Receivers**: Monitor system events (boot, network changes, app installs) -- **Content Providers**: Share configuration data securely -- **Notification System**: User alerts for blocked connections -- **Quick Settings Tile**: Fast firewall toggle (Android 7+) +### Key Components +- BootRuleManager: Robust boot-time rule application with race condition prevention +- InterfaceTracker: Network interface monitoring and change detection +- Api.java: Central iptables command generation and execution +- FirewallService: Background service for continuous monitoring +- LogService: Network packet logging and analysis + + +### Android Integration +- Broadcast Receivers: Monitor system events (boot, network changes, app installs) +- Content Providers: Share configuration data securely +- Notification System: User alerts for blocked connections +- Quick Settings Tile: Fast firewall toggle (Android 7+) --- -## 🏆 Acknowledgements + +## Acknowledgements AFWall+ builds upon the work of many open-source projects and contributors: -### 🌟 **Origins** -- **Original concept**: Derived from [DroidWall](http://code.google.com/p/droidwall) by Rodrigo Rosauro -- **Current maintainer**: [Umakanthan Chandran](https://github.com/ukanth) -### 📚 **Libraries & Dependencies** +### Origins +- Original concept: Derived from [DroidWall](http://code.google.com/p/droidwall) by Rodrigo Rosauro +- Current maintainer: [Umakanthan Chandran](https://github.com/ukanth) + + +### Libraries & Dependencies | Component | License | Purpose | |-----------|---------|---------| | [iptables](http://netfilter.org/projects/iptables/) | GPL v2 | Linux firewall framework | @@ -321,12 +398,14 @@ AFWall+ builds upon the work of many open-source projects and contributors: | [DBFlow](https://github.com/Raizlabs/DBFlow) | MIT | Database ORM | | [PrettyTime](https://github.com/ocpsoft/prettytime) | Apache 2.0 | Human-readable timestamps | -### 👥 **Contributors** + +### Contributors Thanks to all contributors who have helped improve AFWall+ over the years! --- -## 📄 License + +## License AFWall+ is released under the **GNU General Public License v3.0**. @@ -345,28 +424,11 @@ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. ``` -**Full license text**: [LICENSE](LICENSE) file or [gnu.org/licenses/gpl-3.0](https://www.gnu.org/licenses/gpl-3.0.html) - ---- - -## 💝 Support the Project - -AFWall+ is developed and maintained by volunteers in their free time. If you find it useful, consider supporting the project: - -### 💰 **Donations** -- **PayPal**: [![Donate](https://www.paypalobjects.com/en_US/i/btn/btn_donate_SM.gif)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=6E4VZTULRB8GU) -- **Google Play**: Purchase the [unlocker key](https://play.google.com/store/apps/details?id=dev.ukanth.ufirewall.donate) for additional features - -### 🌟 **Other Ways to Help** -- ⭐ Star this repository -- 🐛 Report bugs and test new features -- 🌐 Contribute translations -- 📝 Improve documentation -- 💬 Help other users in forums +**Full license text**: See the [LICENSE](LICENSE) file or [gnu.org/licenses/gpl-3.0](https://www.gnu.org/licenses/gpl-3.0.html) ---

Made with ❤️ for Android privacy and security
AFWall+ - Your Network, Your Rules -

\ No newline at end of file +

diff --git a/app/build.gradle b/app/build.gradle index 4821e41c4..451778c17 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,16 +1,21 @@ apply plugin: 'com.android.application' android { + namespace 'dev.ukanth.ufirewall' + compileSdk 36 defaultConfig { - compileSdk 35 - targetSdk 35 - applicationId "dev.ukanth.ufirewall" //applicationId "dev.ukanth.ufirewall.donate" - minSdkVersion 21 - versionCode 20241025 - versionName "3.6.1" + applicationId "dev.ukanth.ufirewall" + minSdkVersion 23 + targetSdk 36 + versionCode 20260927 + versionName "4.2.0" buildConfigField 'boolean', 'DONATE', 'false' + vectorDrawables.useSupportLibrary = true + ndk { + abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64' + } } buildFeatures { @@ -28,46 +33,63 @@ android { } } + testOptions { + unitTests.returnDefaultValues = true + } lint { abortOnError true disable 'MissingTranslation' } - namespace 'dev.ukanth.ufirewall' compileOptions { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } - compileSdk 35 - buildToolsVersion '34.0.0' + + packagingOptions { + jniLibs { + useLegacyPackaging = true + } + } + configurations.all { + resolutionStrategy { + force 'androidx.core:core:1.15.0' + force 'androidx.activity:activity:1.9.3' + force 'androidx.annotation:annotation:1.9.1' + } + } } dependencies { - def libsuVersion = '6.0.0' def dbFlowVersion = '4.2.4' + // the only root library: rule scripts, one-off root commands and the log watcher implementation "com.github.topjohnwu.libsu:core:${libsuVersion}" - implementation "com.github.topjohnwu.libsu:service:${libsuVersion}" - implementation "com.github.topjohnwu.libsu:nio:${libsuVersion}" - implementation "eu.chainfire:libsuperuser:1.1.0" implementation "com.github.ukanth:android-lockpattern:8.0.4" implementation "com.afollestad.material-dialogs:core:0.9.6.0" - implementation "androidx.appcompat:appcompat:1.7.0" + implementation "androidx.appcompat:appcompat:1.6.1" implementation "com.google.android.material:material:1.12.0" implementation "androidx.cardview:cardview:1.0.0" implementation "androidx.recyclerview:recyclerview:1.3.2" - implementation "androidx.annotation:annotation:1.9.0" - implementation "androidx.core:core:1.13.1" + implementation "androidx.annotation:annotation:1.9.1" + implementation "androidx.core:core:1.15.0" implementation "androidx.preference:preference:1.2.1" implementation "androidx.legacy:legacy-support-v13:1.0.0" + implementation "androidx.activity:activity:1.9.3" + + // DBFlow annotationProcessor "com.github.Raizlabs.DBFlow:dbflow-processor:${dbFlowVersion}" implementation "com.github.Raizlabs.DBFlow:dbflow-core:${dbFlowVersion}" implementation "com.github.Raizlabs.DBFlow:dbflow:${dbFlowVersion}" - implementation "io.reactivex.rxjava3:rxjava:3.1.9" + + implementation "io.reactivex.rxjava3:rxjava:3.1.12" implementation "org.ocpsoft.prettytime:prettytime:5.0.6.Final" - implementation "dnsjava:dnsjava:3.6.2" + implementation "dnsjava:dnsjava:3.6.5" + testImplementation "junit:junit:4.13.2" + // real org.json for unit tests (the Android one is only stubs there) + testImplementation "org.json:json:20240303" } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index bc15fdec2..58ba74c47 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -6,3 +6,16 @@ -dontobfuscate -keep class dev.ukanth.ufirewall.** { *; } -optimizations !code/allocation/variable + +# Android 16 specific proguard rules +-keep class android.window.** { *; } +-keep class androidx.activity.** { *; } +-dontwarn android.window.** +-dontwarn androidx.window.** + +# Edge-to-edge and window insets support +-keep class androidx.core.view.WindowInsetsCompat** { *; } +-keep class androidx.core.view.ViewCompat** { *; } + +# Notification channel compatibility +-keep class androidx.core.app.NotificationChannelCompat** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f12256dfb..b808cb0e8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -49,12 +49,25 @@ - - - + + + + + + + + + + + + + + + + + + + + - - - - + android:exported="false" + android:theme="@style/Theme.Transparent" /> - - - - + android:exported="false" + android:theme="@style/Theme.Transparent" /> + + + @@ -192,13 +215,24 @@ + + + + + + + + + + android:exported="false" > @@ -210,7 +244,8 @@ android:icon="@drawable/notification_quest" android:exported="true" android:label="@string/widget_label_status" - android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"> + android:permission="android.permission.BIND_QUICK_SETTINGS_TILE" + tools:targetApi="24"> @@ -229,7 +264,6 @@ - - - @@ -319,14 +351,17 @@ - + android:label="AFWallLogService" > + + + + + android:exported="false"> - * By disabling this all callbacks will be called from a thread other than - * the main UI thread. - */ - public static boolean handlerEnabled = true; - - - /** - * Setting this will change the default command timeout. - *

- * The default is 20000ms - */ - public static int defaultCommandTimeout = 20000; - - public enum LogLevel { - VERBOSE, - ERROR, - DEBUG, - WARN - } - // -------------------- - // # Public Methods # - // -------------------- - - /** - * This will close all open shells. - */ - public static void closeAllShells() throws IOException { - Shell.closeAll(); - } - - /** - * This will close the custom shell that you opened. - */ - public static void closeCustomShell() throws IOException { - Shell.closeCustomShell(); - } - - /** - * This will close either the root shell or the standard shell depending on what you specify. - * - * @param root a boolean to specify whether to close the root shell or the standard shell. - */ - public static void closeShell(boolean root) throws IOException { - if (root) { - Shell.closeRootShell(); - } else { - Shell.closeShell(); - } - } - - /** - * Use this to check whether or not a file exists on the filesystem. - * - * @param file String that represent the file, including the full path to the - * file and its name. - * @return a boolean that will indicate whether or not the file exists. - */ - public static boolean exists(final String file) { - return exists(file, false); - } - - /** - * Use this to check whether or not a file OR directory exists on the filesystem. - * - * @param file String that represent the file OR the directory, including the full path to the - * file and its name. - * @param isDir boolean that represent whether or not we are looking for a directory - * @return a boolean that will indicate whether or not the file exists. - */ - public static boolean exists(final String file, boolean isDir) { - final List result = new ArrayList(); - - String cmdToExecute = "ls " + (isDir ? "-d " : " "); - - Command command = new Command(0, false, cmdToExecute + file) { - @Override - public void commandOutput(int id, String line) { - RootShell.log(line); - result.add(line); - - super.commandOutput(id, line); - } - }; - - try { - //Try without root... - RootShell.getShell(false).add(command); - commandWait(RootShell.getShell(false), command); - - } catch (Exception e) { - RootShell.log("Exception: " + e); - return false; - } - - for (String line : result) { - if (line.trim().equals(file)) { - return true; - } - } - - result.clear(); - - command = new Command(0, false, cmdToExecute + file) { - @Override - public void commandOutput(int id, String line) { - RootShell.log(line); - result.add(line); - - super.commandOutput(id, line); - } - }; - - try { - RootShell.getShell(true).add(command); - commandWait(RootShell.getShell(true), command); - - } catch (Exception e) { - RootShell.log("Exception: " + e); - return false; - } - - //Avoid concurrent modification... - List final_result = new ArrayList(result); - - for (String line : final_result) { - if (line.trim().equals(file)) { - return true; - } - } - - return false; - - } - - /** - * @param binaryName String that represent the binary to find. - * @param singlePath boolean that represents whether to return a single path or multiple. - * - * @return List containing the locations the binary was found at. - */ - public static List findBinary(String binaryName, boolean singlePath) { - return findBinary(binaryName, null, singlePath); - } - - /** - * @param binaryName String that represent the binary to find. - * @param searchPaths List which contains the paths to search for this binary in. - * @param singlePath boolean that represents whether to return a single path or multiple. - * - * @return List containing the locations the binary was found at. - */ - public static List findBinary(final String binaryName, List searchPaths, boolean singlePath) { - - final List foundPaths = new ArrayList(); - - boolean found = false; - - if(searchPaths == null) - { - searchPaths = RootShell.getPath(); - } - - RootShell.log("Checking for " + binaryName); - - //Try to use stat first - try { - for (String path : searchPaths) { - - if(!path.endsWith("/")) - { - path += "/"; - } - - final String currentPath = path; - - Command cc = new Command(0, false, "stat " + path + binaryName) { - @Override - public void commandOutput(int id, String line) { - if (line.contains("File: ") && line.contains(binaryName)) { - foundPaths.add(currentPath); - - RootShell.log(binaryName + " was found here: " + currentPath); - } - - RootShell.log(line); - - super.commandOutput(id, line); - } - }; - - cc = RootShell.getShell(false).add(cc); - commandWait(RootShell.getShell(false), cc); - - if(foundPaths.size() > 0 && singlePath) { - break; - } - } - - found = !foundPaths.isEmpty(); - - } catch (Exception e) { - RootShell.log(binaryName + " was not found, more information MAY be available with Debugging on."); - } - - if (!found) { - RootShell.log("Trying second method"); - - for (String path : searchPaths) { - - if(!path.endsWith("/")) - { - path += "/"; - } - - if (RootShell.exists(path + binaryName)) { - RootShell.log(binaryName + " was found here: " + path); - foundPaths.add(path); - - if(foundPaths.size() > 0 && singlePath) { - break; - } - - } else { - RootShell.log(binaryName + " was NOT found here: " + path); - } - } - } - - Collections.reverse(foundPaths); - - return foundPaths; - } - - /** - * This will open or return, if one is already open, a custom shell, you are responsible for managing the shell, reading the output - * and for closing the shell when you are done using it. - * - * @param shellPath a String to Indicate the path to the shell that you want to open. - * @param timeout an int to Indicate the length of time before giving up on opening a shell. - * @throws TimeoutException - * @throws com.stericson.RootShell.exceptions.RootDeniedException - * @throws IOException - */ - public static Shell getCustomShell(String shellPath, int timeout) throws IOException, TimeoutException, RootDeniedException - { - return RootShell.getCustomShell(shellPath, timeout); - } - - /** - * This will return the environment variable PATH - * - * @return List A List of Strings representing the environment variable $PATH - */ - public static List getPath() { - return Arrays.asList(System.getenv("PATH").split(":")); - } - - /** - * This will open or return, if one is already open, a shell, you are responsible for managing the shell, reading the output - * and for closing the shell when you are done using it. - * - * @param root a boolean to Indicate whether or not you want to open a root shell or a standard shell - * @param timeout an int to Indicate the length of time to wait before giving up on opening a shell. - * @param shellContext the context to execute the shell with - * @param retry a int to indicate how many times the ROOT shell should try to open with root priviliges... - */ - public static Shell getShell(boolean root, int timeout, Shell.ShellContext shellContext, int retry) throws IOException, TimeoutException, RootDeniedException { - if (root) { - return Shell.startRootShell(timeout, shellContext, retry); - } else { - return Shell.startShell(timeout); - } - } - - /** - * This will open or return, if one is already open, a shell, you are responsible for managing the shell, reading the output - * and for closing the shell when you are done using it. - * - * @param root a boolean to Indicate whether or not you want to open a root shell or a standard shell - * @param timeout an int to Indicate the length of time to wait before giving up on opening a shell. - * @param shellContext the context to execute the shell with - */ - public static Shell getShell(boolean root, int timeout, Shell.ShellContext shellContext) throws IOException, TimeoutException, RootDeniedException { - return getShell(root, timeout, shellContext, 3); - } - - /** - * This will open or return, if one is already open, a shell, you are responsible for managing the shell, reading the output - * and for closing the shell when you are done using it. - * - * @param root a boolean to Indicate whether or not you want to open a root shell or a standard shell - * @param shellContext the context to execute the shell with - */ - public static Shell getShell(boolean root, Shell.ShellContext shellContext) throws IOException, TimeoutException, RootDeniedException { - return getShell(root, 0, shellContext, 3); - } - - /** - * This will open or return, if one is already open, a shell, you are responsible for managing the shell, reading the output - * and for closing the shell when you are done using it. - * - * @param root a boolean to Indicate whether or not you want to open a root shell or a standard shell - * @param timeout an int to Indicate the length of time to wait before giving up on opening a shell. - */ - public static Shell getShell(boolean root, int timeout) throws IOException, TimeoutException, RootDeniedException { - return getShell(root, timeout, Shell.defaultContext, 3); - } - - /** - * This will open or return, if one is already open, a shell, you are responsible for managing the shell, reading the output - * and for closing the shell when you are done using it. - * - * @param root a boolean to Indicate whether or not you want to open a root shell or a standard shell - */ - public static Shell getShell(boolean root) throws IOException, TimeoutException, RootDeniedException { - return RootShell.getShell(root, 0); - } - - /** - * @return true if your app has been given root access. - * @throws TimeoutException if this operation times out. (cannot determine if access is given) - */ - public static boolean isAccessGiven() { - return isAccessGiven(0,3); - } - public static boolean isAccessGiven(int timeout, int retry) { - final Set ID = new HashSet(); - final int IAG = 158; - - try { - RootShell.log("Checking for Root access"); - - Command command = new Command(IAG, false, "id") { - @Override - public void commandOutput(int id, String line) { - if (id == IAG) { - ID.addAll(Arrays.asList(line.split(" "))); - } - super.commandOutput(id, line); - } - }; - - Shell.startRootShell().add(command); - commandWait(Shell.startRootShell(), command); - - //parse the userid - for (String userid : ID) { - RootShell.log(userid); - - if (userid.toLowerCase().contains("uid=0")) { - RootShell.log("Access Given"); - return true; - } - } - - return false; - } catch (Exception e) { - e.printStackTrace(); - return false; - } - } - - /** - * @return true if BusyBox or Toybox was found. - */ - public static boolean isBusyboxAvailable() - { - return (findBinary("busybox", true)).size() > 0; - } - - /** - * @return true if su was found. - */ - public static boolean isRootAvailable() { - return (findBinary("su", true)).size() > 0; - } - - /** - * This method allows you to output debug messages only when debugging is on. This will allow - * you to add a debug option to your app, which by default can be left off for performance. - * However, when you need debugging information, a simple switch can enable it and provide you - * with detailed logging. - *

- * This method handles whether or not to log the information you pass it depending whether or - * not RootShell.debugMode is on. So you can use this and not have to worry about handling it - * yourself. - * - * @param msg The message to output. - */ - public static void log(String msg) { - log(null, msg, LogLevel.DEBUG, null); - } - - /** - * This method allows you to output debug messages only when debugging is on. This will allow - * you to add a debug option to your app, which by default can be left off for performance. - * However, when you need debugging information, a simple switch can enable it and provide you - * with detailed logging. - *

- * This method handles whether or not to log the information you pass it depending whether or - * not RootShell.debugMode is on. So you can use this and not have to worry about handling it - * yourself. - * - * @param TAG Optional parameter to define the tag that the Log will use. - * @param msg The message to output. - */ - public static void log(String TAG, String msg) { - log(TAG, msg, LogLevel.DEBUG, null); - } - - /** - * This method allows you to output debug messages only when debugging is on. This will allow - * you to add a debug option to your app, which by default can be left off for performance. - * However, when you need debugging information, a simple switch can enable it and provide you - * with detailed logging. - *

- * This method handles whether or not to log the information you pass it depending whether or - * not RootShell.debugMode is on. So you can use this and not have to worry about handling it - * yourself. - * - * @param msg The message to output. - * @param type The type of log, 1 for verbose, 2 for error, 3 for debug, 4 for warn - * @param e The exception that was thrown (Needed for errors) - */ - public static void log(String msg, LogLevel type, Exception e) { - log(null, msg, type, e); - } - - /** - * This method allows you to check whether logging is enabled. - * Yes, it has a goofy name, but that's to keep it as short as possible. - * After all writing logging calls should be painless. - * This method exists to save Android going through the various Java layers - * that are traversed any time a string is created (i.e. what you are logging) - *

- * Example usage: - * if(islog) { - * StrinbBuilder sb = new StringBuilder(); - * // ... - * // build string - * // ... - * log(sb.toString()); - * } - * - * @return true if logging is enabled - */ - public static boolean islog() { - return debugMode; - } - - /** - * This method allows you to output debug messages only when debugging is on. This will allow - * you to add a debug option to your app, which by default can be left off for performance. - * However, when you need debugging information, a simple switch can enable it and provide you - * with detailed logging. - *

- * This method handles whether or not to log the information you pass it depending whether or - * not RootShell.debugMode is on. So you can use this and not have to worry about handling it - * yourself. - * - * @param TAG Optional parameter to define the tag that the Log will use. - * @param msg The message to output. - * @param type The type of log, 1 for verbose, 2 for error, 3 for debug - * @param e The exception that was thrown (Needed for errors) - */ - public static void log(String TAG, String msg, LogLevel type, Exception e) { - if (msg != null && !msg.equals("")) { - if (debugMode) { - if (TAG == null) { - TAG = version; - } - - switch (type) { - case VERBOSE: - Log.v(TAG, msg); - break; - case ERROR: - Log.e(TAG, msg, e); - break; - case DEBUG: - Log.d(TAG, msg); - break; - case WARN: - Log.w(TAG, msg); - break; - } - } - } - } - - // -------------------- - // # Public Methods # - // -------------------- - - private static void commandWait(Shell shell, Command cmd) throws Exception { - while (!cmd.isFinished()) { - - RootShell.log(version, shell.getCommandQueuePositionString(cmd)); - RootShell.log(version, "Processed " + cmd.totalOutputProcessed + " of " + cmd.totalOutput + " output from command."); - - synchronized (cmd) { - try { - if (!cmd.isFinished()) { - cmd.wait(2000); - } - } catch (InterruptedException e) { - e.printStackTrace(); - } - } - - if (!cmd.isExecuting() && !cmd.isFinished()) { - if (!shell.isExecuting && !shell.isReading) { - RootShell.log(version, "Waiting for a command to be executed in a shell that is not executing and not reading! \n\n Command: " + cmd.getCommand()); - Exception e = new Exception(); - e.setStackTrace(Thread.currentThread().getStackTrace()); - e.printStackTrace(); - } else if (shell.isExecuting && !shell.isReading) { - RootShell.log(version, "Waiting for a command to be executed in a shell that is executing but not reading! \n\n Command: " + cmd.getCommand()); - Exception e = new Exception(); - e.setStackTrace(Thread.currentThread().getStackTrace()); - e.printStackTrace(); - } else { - RootShell.log(version, "Waiting for a command to be executed in a shell that is not reading! \n\n Command: " + cmd.getCommand()); - Exception e = new Exception(); - e.setStackTrace(Thread.currentThread().getStackTrace()); - e.printStackTrace(); - } - } - - } - } -} diff --git a/app/src/main/java/com/stericson/rootshell/SanityCheckRootShell.java b/app/src/main/java/com/stericson/rootshell/SanityCheckRootShell.java deleted file mode 100755 index f2a572acc..000000000 --- a/app/src/main/java/com/stericson/rootshell/SanityCheckRootShell.java +++ /dev/null @@ -1,415 +0,0 @@ -/* - * This file is part of the RootShell Project: http://code.google.com/p/RootShell/ - * - * Copyright (c) 2012 Stephen Erickson, Chris Ravenscroft, Dominik Schuermann, Adam Shanks - * - * This code is dual-licensed under the terms of the Apache License Version 2.0 and - * the terms of the General Public License (GPL) Version 2. - * You may use this code according to either of these licenses as is most appropriate - * for your project on a case-by-case basis. - * - * The terms of each license can be found in the root directory of this project's repository as well as at: - * - * * http://www.apache.org/licenses/LICENSE-2.0 - * * http://www.gnu.org/licenses/gpl-2.0.txt - * - * Unless required by applicable law or agreed to in writing, software - * distributed under these Licenses is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See each License for the specific language governing permissions and - * limitations under that License. - */ - -package com.stericson.rootshell; - -import android.app.Activity; -import android.app.ProgressDialog; -import android.content.Context; -import android.os.Bundle; -import android.os.Handler; -import android.os.Message; -import android.os.StrictMode; -import android.widget.ScrollView; -import android.widget.TextView; - -import com.stericson.rootshell.exceptions.RootDeniedException; -import com.stericson.rootshell.execution.Command; -import com.stericson.rootshell.execution.Shell; - -import java.io.IOException; -import java.util.List; -import java.util.concurrent.TimeoutException; - -public class SanityCheckRootShell extends Activity -{ - private ScrollView mScrollView; - private TextView mTextView; - private ProgressDialog mPDialog; - - @Override - public void onCreate(Bundle savedInstanceState) - { - super.onCreate(savedInstanceState); - - StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder() - .detectDiskReads() - .detectDiskWrites() - .detectNetwork() // or .detectAll() for all detectable problems - .penaltyLog() - .build()); - StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder() - .detectLeakedSqlLiteObjects() - .detectLeakedClosableObjects() - .penaltyLog() - .penaltyDeath() - .build()); - - RootShell.debugMode = true; - - mTextView = new TextView(this); - mTextView.setText(""); - mScrollView = new ScrollView(this); - mScrollView.addView(mTextView); - setContentView(mScrollView); - - print("SanityCheckRootShell \n\n"); - - if (RootShell.isRootAvailable()) - { - print("Root found.\n"); - } - else - { - print("Root not found"); - } - - try - { - RootShell.getShell(true); - } - catch (IOException e2) - { - // TODO Auto-generated catch block - e2.printStackTrace(); - } - catch (TimeoutException e) - { - print("[ TIMEOUT EXCEPTION! ]\n"); - e.printStackTrace(); - } - catch (RootDeniedException e) - { - print("[ ROOT DENIED EXCEPTION! ]\n"); - e.printStackTrace(); - } - - try - { - if (!RootShell.isAccessGiven()) - { - print("ERROR: No root access to this device.\n"); - return; - } - } - catch (Exception e) - { - print("ERROR: could not determine root access to this device.\n"); - return; - } - - // Display infinite progress bar - mPDialog = new ProgressDialog(this); - mPDialog.setCancelable(false); - mPDialog.setProgressStyle(ProgressDialog.STYLE_SPINNER); - - new SanityCheckThread(this, new TestHandler()).start(); - } - - protected void print(CharSequence text) - { - mTextView.append(text); - mScrollView.post(new Runnable() - { - public void run() - { - mScrollView.fullScroll(ScrollView.FOCUS_DOWN); - } - }); - } - - // Run our long-running tests in their separate thread so as to - // not interfere with proper rendering. - private class SanityCheckThread extends Thread - { - private final Handler mHandler; - - public SanityCheckThread(Context context, Handler handler) - { - mHandler = handler; - } - - public void run() - { - visualUpdate(TestHandler.ACTION_SHOW, null); - - // First test: Install a binary file for future use - // if it wasn't already installed. - /* - visualUpdate(TestHandler.ACTION_PDISPLAY, "Installing binary if needed"); - if(false == RootShell.installBinary(mContext, R.raw.nes, "nes_binary")) { - visualUpdate(TestHandler.ACTION_HIDE, "ERROR: Failed to install binary. Please see log file."); - return; - } - */ - - boolean result; - - visualUpdate(TestHandler.ACTION_PDISPLAY, "Testing getPath"); - visualUpdate(TestHandler.ACTION_DISPLAY, "[ getPath ]\n"); - - try - { - List paths = RootShell.getPath(); - - for (String path : paths) - { - visualUpdate(TestHandler.ACTION_DISPLAY, path + " k\n\n"); - } - - } - catch (Exception e) - { - e.printStackTrace(); - } - - visualUpdate(TestHandler.ACTION_PDISPLAY, "Testing A ton of commands"); - visualUpdate(TestHandler.ACTION_DISPLAY, "[ Ton of Commands ]\n"); - - for (int i = 0; i < 100; i++) - { - RootShell.exists("/system/xbin/busybox"); - } - - visualUpdate(TestHandler.ACTION_PDISPLAY, "Testing Find Binary"); - result = RootShell.isRootAvailable(); - visualUpdate(TestHandler.ACTION_DISPLAY, "[ Checking Root ]\n"); - visualUpdate(TestHandler.ACTION_DISPLAY, result + " k\n\n"); - - result = RootShell.isBusyboxAvailable(); - visualUpdate(TestHandler.ACTION_DISPLAY, "[ Checking Busybox ]\n"); - visualUpdate(TestHandler.ACTION_DISPLAY, result + " k\n\n"); - - visualUpdate(TestHandler.ACTION_PDISPLAY, "Testing file exists"); - visualUpdate(TestHandler.ACTION_DISPLAY, "[ Checking Exists() ]\n"); - visualUpdate(TestHandler.ACTION_DISPLAY, RootShell.exists("/system/sbin/[") + " k\n\n"); - - visualUpdate(TestHandler.ACTION_PDISPLAY, "Testing Is Access Given"); - result = RootShell.isAccessGiven(); - visualUpdate(TestHandler.ACTION_DISPLAY, "[ Checking for Access to Root ]\n"); - visualUpdate(TestHandler.ACTION_DISPLAY, result + " k\n\n"); - - - Shell shell; - - visualUpdate(TestHandler.ACTION_PDISPLAY, "Testing output capture"); - visualUpdate(TestHandler.ACTION_DISPLAY, "[ busybox ash --help ]\n"); - - try - { - shell = RootShell.getShell(true); - Command cmd = new Command( - 0, - "busybox ash --help") - { - - @Override - public void commandOutput(int id, String line) - { - visualUpdate(TestHandler.ACTION_DISPLAY, line + "\n"); - //super.commandOutput(id, line); - } - }; - shell.add(cmd); - - } - catch (Exception e) - { - e.printStackTrace(); - } - - visualUpdate(TestHandler.ACTION_PDISPLAY, "Switching RootContext - SYSTEM_APP"); - visualUpdate(TestHandler.ACTION_DISPLAY, "[ Switching Root Context - SYSTEM_APP ]\n"); - - try - { - shell = RootShell.getShell(true, Shell.ShellContext.SYSTEM_APP); - Command cmd = new Command( - 0, - "id") - { - - @Override - public void commandOutput(int id, String line) - { - visualUpdate(TestHandler.ACTION_DISPLAY, line + "\n"); - super.commandOutput(id, line); - } - }; - shell.add(cmd); - - } - catch (Exception e) - { - e.printStackTrace(); - } - - visualUpdate(TestHandler.ACTION_PDISPLAY, "Switching RootContext - UNTRUSTED"); - visualUpdate(TestHandler.ACTION_DISPLAY, "[ Switching Root Context - UNTRUSTED ]\n"); - - try - { - shell = RootShell.getShell(true, Shell.ShellContext.UNTRUSTED_APP); - Command cmd = new Command( - 0, - "id") - { - - @Override - public void commandOutput(int id, String line) - { - visualUpdate(TestHandler.ACTION_DISPLAY, line + "\n"); - super.commandOutput(id, line); - } - }; - shell.add(cmd); - - } - catch (Exception e) - { - e.printStackTrace(); - } - - try - { - shell = RootShell.getShell(true); - - Command cmd = new Command(42, false, "echo done") - { - - boolean _catch = false; - - @Override - public void commandOutput(int id, String line) - { - if (_catch) - { - RootShell.log("CAUGHT!!!"); - } - - super.commandOutput(id, line); - - } - - @Override - public void commandTerminated(int id, String reason) - { - synchronized (SanityCheckRootShell.this) - { - - _catch = true; - visualUpdate(TestHandler.ACTION_PDISPLAY, "All tests complete."); - visualUpdate(TestHandler.ACTION_HIDE, null); - - try - { - RootShell.closeAllShells(); - } - catch (IOException e) - { - // TODO Auto-generated catch block - e.printStackTrace(); - } - - } - } - - @Override - public void commandCompleted(int id, int exitCode) - { - synchronized (SanityCheckRootShell.this) - { - _catch = true; - - visualUpdate(TestHandler.ACTION_PDISPLAY, "All tests complete."); - visualUpdate(TestHandler.ACTION_HIDE, null); - - try - { - RootShell.closeAllShells(); - } - catch (IOException e) - { - // TODO Auto-generated catch block - e.printStackTrace(); - } - - } - } - }; - - shell.add(cmd); - - } - catch (Exception e) - { - e.printStackTrace(); - } - - } - - private void visualUpdate(int action, String text) - { - Message msg = mHandler.obtainMessage(); - Bundle bundle = new Bundle(); - bundle.putInt(TestHandler.ACTION, action); - bundle.putString(TestHandler.TEXT, text); - msg.setData(bundle); - mHandler.sendMessage(msg); - } - } - - private class TestHandler extends Handler - { - static final public String ACTION = "action"; - static final public int ACTION_SHOW = 0x01; - static final public int ACTION_HIDE = 0x02; - static final public int ACTION_DISPLAY = 0x03; - static final public int ACTION_PDISPLAY = 0x04; - static final public String TEXT = "text"; - - public void handleMessage(Message msg) - { - int action = msg.getData().getInt(ACTION); - String text = msg.getData().getString(TEXT); - - switch (action) - { - case ACTION_SHOW: - mPDialog.show(); - mPDialog.setMessage("Running Root Library Tests..."); - break; - case ACTION_HIDE: - if (null != text) - { print(text); } - mPDialog.hide(); - break; - case ACTION_DISPLAY: - print(text); - break; - case ACTION_PDISPLAY: - mPDialog.setMessage(text); - break; - } - } - } -} diff --git a/app/src/main/java/com/stericson/rootshell/containers/RootClass.java b/app/src/main/java/com/stericson/rootshell/containers/RootClass.java deleted file mode 100644 index 239812a49..000000000 --- a/app/src/main/java/com/stericson/rootshell/containers/RootClass.java +++ /dev/null @@ -1,328 +0,0 @@ -package com.stericson.rootshell.containers; - -import java.io.BufferedReader; -import java.io.File; -import java.io.FileFilter; -import java.io.FileNotFoundException; -import java.io.FileReader; -import java.io.FilenameFilter; -import java.io.IOException; -import java.lang.reflect.Constructor; -import java.lang.reflect.InvocationTargetException; -import java.util.ArrayList; -import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/* #ANNOTATIONS @SupportedAnnotationTypes("com.stericson.RootShell.containers.RootClass.Candidate") */ -/* #ANNOTATIONS @SupportedSourceVersion(SourceVersion.RELEASE_6) */ -public class RootClass /* #ANNOTATIONS extends AbstractProcessor */ { - - /* #ANNOTATIONS - @Override - public boolean process(Set typeElements, RoundEnvironment roundEnvironment) { - processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, "I was invoked!!!"); - - return false; - } - */ - - static String PATH_TO_DX = "/Users/Chris/Projects/android-sdk-macosx/build-tools/18.0.1/dx"; - - enum READ_STATE { - STARTING, FOUND_ANNOTATION - } - - public RootClass(String[] args) throws ClassNotFoundException, NoSuchMethodException, - IllegalAccessException, InvocationTargetException, InstantiationException { - - // Note: rather than calling System.load("/system/lib/libandroid_runtime.so"); - // which would leave a bunch of unresolved JNI references, - // we are using the 'withFramework' class as a preloader. - // So, yeah, russian dolls: withFramework > RootClass > actual method - - String className = args[0]; - RootArgs actualArgs = new RootArgs(); - actualArgs.args = new String[args.length - 1]; - System.arraycopy(args, 1, actualArgs.args, 0, args.length - 1); - Class classHandler = Class.forName(className); - Constructor classConstructor = classHandler.getConstructor(RootArgs.class); - classConstructor.newInstance(actualArgs); - } - - public @interface Candidate { - - } - - public static class RootArgs { - - public String[] args; - } - - static void displayError(Exception e) { - // Not using system.err to make it easier to capture from - // calling library. - System.out.println("##ERR##" + e.getMessage() + "##"); - e.printStackTrace(); - } - - // I reckon it would be better to investigate classes using getAttribute() - // however this method allows the developer to simply select "Run" on RootClass - // and immediately re-generate the necessary jar file. - static public class AnnotationsFinder { - - private final String AVOIDDIRPATH = "stericson" + File.separator + "RootShell" + File.separator; - - private final List classFiles; - - public AnnotationsFinder() throws IOException { - System.out.println("Discovering root class annotations..."); - classFiles = new ArrayList(); - lookup(new File("src"), classFiles); - System.out.println("Done discovering annotations. Building jar file."); - File builtPath = getBuiltPath(); - if (null != builtPath) { - // Android! Y U no have com.google.common.base.Joiner class? - String rc1 = "com" + File.separator - + "stericson" + File.separator - + "RootShell" + File.separator - + "containers" + File.separator - + "RootClass.class"; - String rc2 = "com" + File.separator - + "stericson" + File.separator - + "RootShell" + File.separator - + "containers" + File.separator - + "RootClass$RootArgs.class"; - String rc3 = "com" + File.separator - + "stericson" + File.separator - + "RootShell" + File.separator - + "containers" + File.separator - + "RootClass$AnnotationsFinder.class"; - String rc4 = "com" + File.separator - + "stericson" + File.separator - + "RootShell" + File.separator - + "containers" + File.separator - + "RootClass$AnnotationsFinder$1.class"; - String rc5 = "com" + File.separator - + "stericson" + File.separator - + "RootShell" + File.separator - + "containers" + File.separator - + "RootClass$AnnotationsFinder$2.class"; - String[] cmd; - boolean onWindows = (-1 != System.getProperty("os.name").toLowerCase().indexOf("win")); - if (onWindows) { - StringBuilder sb = new StringBuilder( - " " + rc1 + " " + rc2 + " " + rc3 + " " + rc4 + " " + rc5 - ); - for (File file : classFiles) { - sb.append(" ").append(file.getPath()); - } - cmd = new String[]{ - "cmd", "/C", - "jar cvf" + - " anbuild.jar" + - sb.toString() - }; - } else { - ArrayList al = new ArrayList(); - al.add("jar"); - al.add("cf"); - al.add("anbuild.jar"); - al.add(rc1); - al.add(rc2); - al.add(rc3); - al.add(rc4); - al.add(rc5); - for (File file : classFiles) { - al.add(file.getPath()); - } - cmd = al.toArray(new String[0]); - } - ProcessBuilder jarBuilder = new ProcessBuilder(cmd); - jarBuilder.directory(builtPath); - try { - jarBuilder.start().waitFor(); - } catch (IOException e) { - } catch (InterruptedException e) { - } - - File rawFolder = new File("res" + File.separator + "raw"); - if (!rawFolder.exists()) { - rawFolder.mkdirs(); - } - - System.out.println("Done building jar file. Creating dex file."); - if (onWindows) { - cmd = new String[]{ - "cmd", "/C", - "dx --dex --output=res" + File.separator + "raw" + File.separator + "anbuild.dex " - + builtPath + File.separator + "anbuild.jar" - }; - } else { - cmd = new String[]{ - getPathToDx(), - "--dex", - "--output=res" + File.separator + "raw" + File.separator + "anbuild.dex", - builtPath + File.separator + "anbuild.jar" - }; - } - ProcessBuilder dexBuilder = new ProcessBuilder(cmd); - try { - dexBuilder.start().waitFor(); - } catch (IOException e) { - } catch (InterruptedException e) { - } - } - System.out.println("All done. ::: anbuild.dex should now be in your project's res" + File.separator + "raw" + File.separator + " folder :::"); - } - - protected void lookup(File path, List fileList) { - String desourcedPath = path.toString().replace("src" + File.separator, ""); - File[] files = path.listFiles(); - for (File file : files) { - if (file.isDirectory()) { - if (-1 == file.getAbsolutePath().indexOf(AVOIDDIRPATH)) { - lookup(file, fileList); - } - } else { - if (file.getName().endsWith(".java")) { - if (hasClassAnnotation(file)) { - final String fileNamePrefix = file.getName().replace(".java", ""); - final File compiledPath = new File(getBuiltPath().toString() + File.separator + desourcedPath); - File[] classAndInnerClassFiles = compiledPath.listFiles(new FilenameFilter() { - @Override - public boolean accept(File dir, String filename) { - return filename.startsWith(fileNamePrefix); - } - }); - for (final File matchingFile : classAndInnerClassFiles) { - fileList.add(new File(desourcedPath + File.separator + matchingFile.getName())); - } - - } - } - } - } - } - - protected boolean hasClassAnnotation(File file) { - READ_STATE readState = READ_STATE.STARTING; - Pattern p = Pattern.compile(" class ([A-Za-z0-9_]+)"); - try { - BufferedReader reader = new BufferedReader(new FileReader(file)); - String line; - while (null != (line = reader.readLine())) { - switch (readState) { - case STARTING: - if (-1 < line.indexOf("@RootClass.Candidate")) { - readState = READ_STATE.FOUND_ANNOTATION; - } - break; - case FOUND_ANNOTATION: - Matcher m = p.matcher(line); - if (m.find()) { - System.out.println(" Found annotated class: " + m.group(0)); - return true; - } else { - System.err.println("Error: unmatched annotation in " + - file.getAbsolutePath()); - readState = READ_STATE.STARTING; - } - break; - } - } - } catch (FileNotFoundException e) { - e.printStackTrace(); - } catch (IOException e) { - e.printStackTrace(); - } - return false; - } - - protected String getPathToDx() throws IOException { - String androidHome = System.getenv("ANDROID_HOME"); - if (null == androidHome) { - throw new IOException("Error: you need to set $ANDROID_HOME globally"); - } - String dxPath = null; - File[] files = new File(androidHome + File.separator + "build-tools").listFiles(); - int recentSdkVersion = 0; - for (File file : files) { - - String fileName = null; - if (file.getName().contains("-")) { - String[] splitFileName = file.getName().split("-"); - if (splitFileName[1].contains("W")) { - char[] fileNameChars = splitFileName[1].toCharArray(); - fileName = String.valueOf(fileNameChars[0]); - } else { - fileName = splitFileName[1]; - } - } else { - fileName = file.getName(); - } - - int sdkVersion; - - String[] sdkVersionBits = fileName.split("[.]"); - sdkVersion = Integer.parseInt(sdkVersionBits[0]) * 10000; - if (sdkVersionBits.length > 1) { - sdkVersion += Integer.parseInt(sdkVersionBits[1]) * 100; - if (sdkVersionBits.length > 2) { - sdkVersion += Integer.parseInt(sdkVersionBits[2]); - } - } - if (sdkVersion > recentSdkVersion) { - String tentativePath = file.getAbsolutePath() + File.separator + "dx"; - if (new File(tentativePath).exists()) { - recentSdkVersion = sdkVersion; - dxPath = tentativePath; - } - } - } - if (dxPath == null) { - throw new IOException("Error: unable to find dx binary in $ANDROID_HOME"); - } - return dxPath; - } - - protected File getBuiltPath() { - File foundPath = null; - - File ideaPath = new File("out" + File.separator + "production"); // IntelliJ - if (ideaPath.isDirectory()) { - File[] children = ideaPath.listFiles(new FileFilter() { - @Override - public boolean accept(File pathname) { - return pathname.isDirectory(); - } - }); - if (children.length > 0) { - foundPath = new File(ideaPath.getAbsolutePath() + File.separator + children[0].getName()); - } - } - if (null == foundPath) { - File eclipsePath = new File("bin" + File.separator + "classes"); // Eclipse IDE - if (eclipsePath.isDirectory()) { - foundPath = eclipsePath; - } - } - - return foundPath; - } - - - } - - public static void main(String[] args) { - try { - if (args.length == 0) { - new AnnotationsFinder(); - } else { - new RootClass(args); - } - } catch (Exception e) { - displayError(e); - } - } -} diff --git a/app/src/main/java/com/stericson/rootshell/exceptions/RootDeniedException.java b/app/src/main/java/com/stericson/rootshell/exceptions/RootDeniedException.java deleted file mode 100644 index 4ee384963..000000000 --- a/app/src/main/java/com/stericson/rootshell/exceptions/RootDeniedException.java +++ /dev/null @@ -1,32 +0,0 @@ -/* - * This file is part of the RootShell Project: https://github.com/Stericson/RootShell - * - * Copyright (c) 2014 Stephen Erickson, Chris Ravenscroft - * - * This code is dual-licensed under the terms of the Apache License Version 2.0 and - * the terms of the General Public License (GPL) Version 2. - * You may use this code according to either of these licenses as is most appropriate - * for your project on a case-by-case basis. - * - * The terms of each license can be found in the root directory of this project's repository as well as at: - * - * * http://www.apache.org/licenses/LICENSE-2.0 - * * http://www.gnu.org/licenses/gpl-2.0.txt - * - * Unless required by applicable law or agreed to in writing, software - * distributed under these Licenses is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See each License for the specific language governing permissions and - * limitations under that License. - */ - -package com.stericson.rootshell.exceptions; - -public class RootDeniedException extends Exception { - - private static final long serialVersionUID = -8713947214162841310L; - - public RootDeniedException(String error) { - super(error); - } -} diff --git a/app/src/main/java/com/stericson/rootshell/execution/Command.java b/app/src/main/java/com/stericson/rootshell/execution/Command.java deleted file mode 100644 index f4b5ec90a..000000000 --- a/app/src/main/java/com/stericson/rootshell/execution/Command.java +++ /dev/null @@ -1,325 +0,0 @@ -/* - * This file is part of the RootShell Project: http://code.google.com/p/RootShell/ - * - * Copyright (c) 2014 Stephen Erickson, Chris Ravenscroft - * - * This code is dual-licensed under the terms of the Apache License Version 2.0 and - * the terms of the General Public License (GPL) Version 2. - * You may use this code according to either of these licenses as is most appropriate - * for your project on a case-by-case basis. - * - * The terms of each license can be found in the root directory of this project's repository as well as at: - * - * * http://www.apache.org/licenses/LICENSE-2.0 - * * http://www.gnu.org/licenses/gpl-2.0.txt - * - * Unless required by applicable law or agreed to in writing, software - * distributed under these Licenses is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See each License for the specific language governing permissions and - * limitations under that License. - */ - -package com.stericson.rootshell.execution; - - -import android.content.Context; -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.os.Message; - -import com.stericson.rootshell.RootShell; - -import java.io.IOException; - -public class Command { - - //directly modified by JavaCommand - protected boolean javaCommand = false; - protected Context context = null; - - public int totalOutput = 0; - - public int totalOutputProcessed = 0; - - ExecutionMonitor executionMonitor = null; - - Handler mHandler = null; - - //Has this command already been used? - protected boolean used = false; - - boolean executing = false; - - String[] command = {}; - - boolean finished = false; - - boolean terminated = false; - - boolean handlerEnabled = true; - - int exitCode = -1; - - int id = 0; - - int timeout = RootShell.defaultCommandTimeout; - - /** - * Constructor for executing a normal shell command - * - * @param id the id of the command being executed - * @param command the command, or commands, to be executed. - */ - public Command(int id, String... command) { - this.command = command; - this.id = id; - - createHandler(RootShell.handlerEnabled); - } - - /** - * Constructor for executing a normal shell command - * - * @param id the id of the command being executed - * @param handlerEnabled when true the handler will be used to call the - * callback methods if possible. - * @param command the command, or commands, to be executed. - */ - public Command(int id, boolean handlerEnabled, String... command) { - this.command = command; - this.id = id; - - createHandler(handlerEnabled); - } - - /** - * Constructor for executing a normal shell command - * - * @param id the id of the command being executed - * @param timeout the time allowed before the shell will give up executing the command - * and throw a TimeoutException. - * @param command the command, or commands, to be executed. - */ - public Command(int id, int timeout, String... command) { - this.command = command; - this.id = id; - this.timeout = timeout; - - createHandler(RootShell.handlerEnabled); - } - - //If you override this you MUST make a final call - //to the super method. The super call should be the last line of this method. - public void commandOutput(int id, String line) { - RootShell.log("Command", "ID: " + id + ", " + line); - totalOutputProcessed++; - } - - public void commandTerminated(int id, String reason) { - //pass - } - - public void commandCompleted(int id, int exitcode) { - //pass - } - - protected final void commandFinished() { - if (!terminated) { - synchronized (this) { - if (mHandler != null && handlerEnabled) { - Message msg = mHandler.obtainMessage(); - Bundle bundle = new Bundle(); - bundle.putInt(CommandHandler.ACTION, CommandHandler.COMMAND_COMPLETED); - msg.setData(bundle); - mHandler.sendMessage(msg); - } else { - commandCompleted(id, exitCode); - } - - RootShell.log("Command " + id + " finished."); - finishCommand(); - } - } - } - - private void createHandler(boolean handlerEnabled) { - - this.handlerEnabled = handlerEnabled; - - if (Looper.myLooper() != null && handlerEnabled) { - RootShell.log("CommandHandler created"); - mHandler = new CommandHandler(); - } else { - RootShell.log("CommandHandler not created"); - } - } - - public final void finish() - { - RootShell.log("Command finished at users request!"); - commandFinished(); - } - - protected final void finishCommand() { - this.executing = false; - this.finished = true; - this.notifyAll(); - } - - - public final String getCommand() { - StringBuilder sb = new StringBuilder(); - - for (int i = 0; i < command.length; i++) { - if (i > 0) { - sb.append('\n'); - } - - sb.append(command[i]); - } - - return sb.toString(); - } - - public final boolean isExecuting() { - return executing; - } - - public final boolean isHandlerEnabled() { - return handlerEnabled; - } - - public final boolean isFinished() { - return finished; - } - - public final int getExitCode() { - return this.exitCode; - } - - protected final void setExitCode(int code) { - synchronized (this) { - exitCode = code; - } - } - - protected final void startExecution() { - this.used = true; - executionMonitor = new ExecutionMonitor(this); - executionMonitor.setPriority(Thread.MIN_PRIORITY); - executionMonitor.start(); - executing = true; - } - - public final void terminate() - { - RootShell.log("Terminating command at users request!"); - terminated("Terminated at users request!"); - } - - protected final void terminate(String reason) { - try { - Shell.closeAll(); - RootShell.log("Terminating all shells."); - terminated(reason); - } catch (IOException e) { - } - } - - protected final void terminated(String reason) { - synchronized (Command.this) { - - if (mHandler != null && handlerEnabled) { - Message msg = mHandler.obtainMessage(); - Bundle bundle = new Bundle(); - bundle.putInt(CommandHandler.ACTION, CommandHandler.COMMAND_TERMINATED); - bundle.putString(CommandHandler.TEXT, reason); - msg.setData(bundle); - mHandler.sendMessage(msg); - } else { - commandTerminated(id, reason); - } - - RootShell.log("Command " + id + " did not finish because it was terminated. Termination reason: " + reason); - setExitCode(-1); - terminated = true; - finishCommand(); - } - } - - protected final void output(int id, String line) { - totalOutput++; - - if (mHandler != null && handlerEnabled) { - Message msg = mHandler.obtainMessage(); - Bundle bundle = new Bundle(); - bundle.putInt(CommandHandler.ACTION, CommandHandler.COMMAND_OUTPUT); - bundle.putString(CommandHandler.TEXT, line); - msg.setData(bundle); - mHandler.sendMessage(msg); - } else { - commandOutput(id, line); - } - } - - private class ExecutionMonitor extends Thread { - - private final Command command; - - public ExecutionMonitor(Command command) { - this.command = command; - } - - public void run() { - - if(command.timeout > 0) - { - synchronized (command) { - try { - RootShell.log("Command " + command.id + " is waiting for: " + command.timeout); - command.wait(command.timeout); - } catch (InterruptedException e) { - RootShell.log("Exception: " + e); - } - - if (!command.isFinished()) { - RootShell.log("Timeout Exception has occurred for command: " + command.id + "."); - terminate("Timeout Exception"); - } - } - } - } - } - - private class CommandHandler extends Handler { - - static final public String ACTION = "action"; - - static final public String TEXT = "text"; - - static final public int COMMAND_OUTPUT = 0x01; - - static final public int COMMAND_COMPLETED = 0x02; - - static final public int COMMAND_TERMINATED = 0x03; - - public final void handleMessage(Message msg) { - int action = msg.getData().getInt(ACTION); - String text = msg.getData().getString(TEXT); - - switch (action) { - case COMMAND_OUTPUT: - commandOutput(id, text); - break; - case COMMAND_COMPLETED: - commandCompleted(id, exitCode); - break; - case COMMAND_TERMINATED: - commandTerminated(id, text); - break; - } - } - } -} diff --git a/app/src/main/java/com/stericson/rootshell/execution/JavaCommand.java b/app/src/main/java/com/stericson/rootshell/execution/JavaCommand.java deleted file mode 100644 index 4614e21b4..000000000 --- a/app/src/main/java/com/stericson/rootshell/execution/JavaCommand.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.stericson.rootshell.execution; - -import android.content.Context; - -public class JavaCommand extends Command -{ - /** - * Constructor for executing Java commands rather than binaries - * - * @param context needed to execute java command. - */ - public JavaCommand(int id, Context context, String... command) { - super(id, command); - this.context = context; - this.javaCommand = true; - } - - /** - * Constructor for executing Java commands rather than binaries - * - * @param context needed to execute java command. - */ - public JavaCommand(int id, boolean handlerEnabled, Context context, String... command) { - super(id, handlerEnabled, command); - this.context = context; - this.javaCommand = true; - } - - /** - * Constructor for executing Java commands rather than binaries - * - * @param context needed to execute java command. - */ - public JavaCommand(int id, int timeout, Context context, String... command) { - super(id, timeout, command); - this.context = context; - this.javaCommand = true; - } - - - @Override - public void commandOutput(int id, String line) - { - super.commandOutput(id, line); - } - - @Override - public void commandTerminated(int id, String reason) - { - // pass - } - - @Override - public void commandCompleted(int id, int exitCode) - { - // pass - } -} diff --git a/app/src/main/java/com/stericson/rootshell/execution/Shell.java b/app/src/main/java/com/stericson/rootshell/execution/Shell.java deleted file mode 100644 index f5a522cd8..000000000 --- a/app/src/main/java/com/stericson/rootshell/execution/Shell.java +++ /dev/null @@ -1,1029 +0,0 @@ -/* - * This file is part of the RootShell Project: http://code.google.com/p/RootShell/ - * - * Copyright (c) 2014 Stephen Erickson, Chris Ravenscroft - * - * This code is dual-licensed under the terms of the Apache License Version 2.0 and - * the terms of the General Public License (GPL) Version 2. - * You may use this code according to either of these licenses as is most appropriate - * for your project on a case-by-case basis. - * - * The terms of each license can be found in the root directory of this project's repository as well as at: - * - * * http://www.apache.org/licenses/LICENSE-2.0 - * * http://www.gnu.org/licenses/gpl-2.0.txt - * - * Unless required by applicable law or agreed to in writing, software - * distributed under these Licenses is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See each License for the specific language governing permissions and - * limitations under that License. - */ -package com.stericson.rootshell.execution; - - -import android.content.Context; - -import com.stericson.rootshell.RootShell; -import com.stericson.rootshell.exceptions.RootDeniedException; - -import java.io.BufferedReader; -import java.io.EOFException; -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.OutputStreamWriter; -import java.io.Reader; -import java.io.Writer; -import java.lang.reflect.Field; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.TimeoutException; - -public class Shell { - - public enum ShellType { - NORMAL, - ROOT, - CUSTOM - } - - //this is only used with root shells - public enum ShellContext { - NORMAL("normal"), //The normal context... - SHELL("u:r:shell:s0"), //unprivileged shell (such as an adb shell) - SYSTEM_SERVER("u:r:system_server:s0"), // system_server, u:r:system:s0 on some firmwares - SYSTEM_APP("u:r:system_app:s0"), // System apps - PLATFORM_APP("u:r:platform_app:s0"), // System apps - UNTRUSTED_APP("u:r:untrusted_app:s0"), // Third-party apps - RECOVERY("u:r:recovery:s0"); //Recovery - - private final String value; - - ShellContext(String value) { - this.value = value; - } - - public String getValue() { - return this.value; - } - - } - - //Statics -- visible to all - private static final String token = "F*D^W@#FGF"; - - private static Shell rootShell = null; - - private static Shell shell = null; - - private static Shell customShell = null; - - private static final String[] suVersion = new String[]{ - null, null - }; - - //the default context for root shells... - public static ShellContext defaultContext = ShellContext.NORMAL; - - //per shell - private int shellTimeout = 25000; - - private ShellType shellType = null; - - private ShellContext shellContext = ShellContext.NORMAL; - - private String error = ""; - - private final Process proc; - - private final BufferedReader inputStream; - - private final BufferedReader errorStream; - - private final OutputStreamWriter outputStream; - - private final List commands = new ArrayList(); - - //indicates whether or not to close the shell - private boolean close = false; - - private Boolean isSELinuxEnforcing = null; - - public boolean isExecuting = false; - - public boolean isReading = false; - - public boolean isClosed = false; - - private final int maxCommands = 5000; - - private int read = 0; - - private int write = 0; - - private int totalExecuted = 0; - - private int totalRead = 0; - - private boolean isCleaning = false; - - private Shell(String cmd, ShellType shellType, ShellContext shellContext, int shellTimeout) throws IOException, TimeoutException, RootDeniedException { - - RootShell.log("Starting shell: " + cmd); - RootShell.log("Context: " + shellContext.getValue()); - RootShell.log("Timeout: " + shellTimeout); - - this.shellType = shellType; - this.shellTimeout = shellTimeout > 0 ? shellTimeout : this.shellTimeout; - this.shellContext = shellContext; - - if (this.shellContext == ShellContext.NORMAL) { - this.proc = Runtime.getRuntime().exec(cmd); - } else { - String display = getSuVersion(false); - String internal = getSuVersion(true); - - //only done for root shell... - //Right now only SUPERSU supports the --context switch - if (isSELinuxEnforcing() && - (display != null) && - (internal != null) && - (display.endsWith("SUPERSU")) && - (Integer.valueOf(internal) >= 190)) { - cmd += " --context " + this.shellContext.getValue(); - } else { - RootShell.log("Su binary --context switch not supported!"); - RootShell.log("Su binary display version: " + display); - RootShell.log("Su binary internal version: " + internal); - RootShell.log("SELinuxEnforcing: " + isSELinuxEnforcing()); - } - - this.proc = Runtime.getRuntime().exec(cmd); - - } - - this.inputStream = new BufferedReader(new InputStreamReader(this.proc.getInputStream(), StandardCharsets.UTF_8)); - this.errorStream = new BufferedReader(new InputStreamReader(this.proc.getErrorStream(), StandardCharsets.UTF_8)); - this.outputStream = new OutputStreamWriter(this.proc.getOutputStream(), StandardCharsets.UTF_8); - - /** - * Thread responsible for carrying out the requested operations - */ - Worker worker = new Worker(this); - worker.start(); - - try { - /** - * The flow of execution will wait for the thread to die or wait until the - * given timeout has expired. - * - * The result of the worker, which is determined by the exit code of the worker, - * will tell us if the operation was completed successfully or it the operation - * failed. - */ - worker.join(this.shellTimeout); - - /** - * The operation could not be completed before the timeout occurred. - */ - if (worker.exit == -911) { - - try { - this.proc.destroy(); - } catch (Exception e) { - } - - closeQuietly(this.inputStream); - closeQuietly(this.errorStream); - closeQuietly(this.outputStream); - - throw new TimeoutException(this.error); - } - /** - * Root access denied? - */ - else if (worker.exit == -42) { - - try { - this.proc.destroy(); - } catch (Exception e) { - } - - closeQuietly(this.inputStream); - closeQuietly(this.errorStream); - closeQuietly(this.outputStream); - - throw new RootDeniedException("Root Access Denied"); - } - /** - * Normal exit - */ - else { - /** - * The shell is open. - * - * Start two threads, one to handle the input and one to handle the output. - * - * input, and output are runnables that the threads execute. - */ - Thread si = new Thread(this.input, "Shell Input"); - si.setPriority(Thread.NORM_PRIORITY); - si.start(); - - Thread so = new Thread(this.output, "Shell Output"); - so.setPriority(Thread.NORM_PRIORITY); - so.start(); - } - } catch (InterruptedException ex) { - worker.interrupt(); - Thread.currentThread().interrupt(); - throw new TimeoutException(); - } - } - - - public Command add(Command command) throws IOException { - if (this.close) { - throw new IllegalStateException( - "Unable to add commands to a closed shell"); - } - - if(command.used) { - //The command has been used, don't re-use... - throw new IllegalStateException( - "This command has already been executed. (Don't re-use command instances.)"); - } - - while (this.isCleaning) { - //Don't add commands while cleaning - } - - this.commands.add(command); - - this.notifyThreads(); - - return command; - } - - public final void useCWD(Context context) throws IOException, TimeoutException, RootDeniedException { - add( - new Command( - -1, - false, - "cd " + context.getApplicationInfo().dataDir) - ); - } - - private void cleanCommands() { - this.isCleaning = true; - int toClean = Math.abs(this.maxCommands - (this.maxCommands / 4)); - RootShell.log("Cleaning up: " + toClean); - - this.commands.subList(0, toClean).clear(); - - this.read = this.commands.size() - 1; - this.write = this.commands.size() - 1; - this.isCleaning = false; - } - - private void closeQuietly(final Reader input) { - try { - if (input != null) { - input.close(); - } - } catch (Exception ignore) { - } - } - - private void closeQuietly(final Writer output) { - try { - if (output != null) { - output.close(); - } - } catch (Exception ignore) { - } - } - - public void close() throws IOException { - RootShell.log("Request to close shell!"); - - int count = 0; - while (isExecuting) { - RootShell.log("Waiting on shell to finish executing before closing..."); - count++; - - //fail safe - if (count > 10000) { - break; - } - - } - - synchronized (this.commands) { - /** - * instruct the two threads monitoring input and output - * of the shell to close. - */ - this.close = true; - this.notifyThreads(); - } - - RootShell.log("Shell Closed!"); - - if (this == Shell.rootShell) { - Shell.rootShell = null; - } else if (this == Shell.shell) { - Shell.shell = null; - } else if (this == Shell.customShell) { - Shell.customShell = null; - } - } - - public static void closeCustomShell() throws IOException { - RootShell.log("Request to close custom shell!"); - - if (Shell.customShell == null) { - return; - } - - Shell.customShell.close(); - } - - public static void closeRootShell() throws IOException { - RootShell.log("Request to close root shell!"); - - if (Shell.rootShell == null) { - return; - } - Shell.rootShell.close(); - } - - public static void closeShell() throws IOException { - RootShell.log("Request to close normal shell!"); - - if (Shell.shell == null) { - return; - } - Shell.shell.close(); - } - - public static void closeAll() throws IOException { - RootShell.log("Request to close all shells!"); - - Shell.closeShell(); - Shell.closeRootShell(); - Shell.closeCustomShell(); - } - - public int getCommandQueuePosition(Command cmd) { - return this.commands.indexOf(cmd); - } - - public String getCommandQueuePositionString(Command cmd) { - return "Command is in position " + getCommandQueuePosition(cmd) + " currently executing command at position " + this.write + " and the number of commands is " + commands.size(); - } - - public static Shell getOpenShell() { - if (Shell.customShell != null) { - return Shell.customShell; - } else if (Shell.rootShell != null) { - return Shell.rootShell; - } else { - return Shell.shell; - } - } - - /** - * From libsuperuser. - * - *

- * Detects the version of the su binary installed (if any), if supported - * by the binary. Most binaries support two different version numbers, - * the public version that is displayed to users, and an internal - * version number that is used for version number comparisons. Returns - * null if su not available or retrieving the version isn't supported. - *

- *

- * Note that su binary version and GUI (APK) version can be completely - * different. - *

- *

- * This function caches its result to improve performance on multiple - * calls - *

- * - * @param internal Request human-readable version or application - * internal version - * @return String containing the su version or null - */ - private synchronized String getSuVersion(boolean internal) { - int idx = internal ? 0 : 1; - if (suVersion[idx] == null) { - String version = null; - - // Replace libsuperuser:Shell.run with manual process execution - Process process; - try { - process = Runtime.getRuntime().exec(internal ? "su -V" : "su -v", null); - process.waitFor(); - } catch (IOException e) { - e.printStackTrace(); - return null; - } catch (InterruptedException e) { - e.printStackTrace(); - return null; - } - - // From libsuperuser:StreamGobbler - List stdout = new ArrayList(); - - BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); - try { - String line = null; - while ((line = reader.readLine()) != null) { - stdout.add(line); - } - } catch (IOException e) { - } - // make sure our stream is closed and resources will be freed - try { - reader.close(); - } catch (IOException e) { - } - - process.destroy(); - - if (stdout != null) { - for (String line : stdout) { - if (!internal) { - if (line.contains(".")) { - version = line; - break; - } - } else { - try { - if (Integer.parseInt(line) > 0) { - version = line; - break; - } - } catch (NumberFormatException e) { - } - } - } - } - - suVersion[idx] = version; - } - return suVersion[idx]; - } - - public static boolean isShellOpen() { - return Shell.shell == null; - } - - public static boolean isCustomShellOpen() { - return Shell.customShell == null; - } - - public static boolean isRootShellOpen() { - return Shell.rootShell == null; - } - - public static boolean isAnyShellOpen() { - return Shell.shell != null || Shell.rootShell != null || Shell.customShell != null; - } - - /** - * From libsuperuser. - * - * Detect if SELinux is set to enforcing, caches result - * - * @return true if SELinux set to enforcing, or false in the case of - * permissive or not present - */ - public synchronized boolean isSELinuxEnforcing() { - if (isSELinuxEnforcing == null) { - Boolean enforcing = null; - - // First known firmware with SELinux built-in was a 4.2 (17) - // leak - if (android.os.Build.VERSION.SDK_INT >= 17) { - - // Detect enforcing through sysfs, not always present - File f = new File("/sys/fs/selinux/enforce"); - if (f.exists()) { - try { - InputStream is = new FileInputStream("/sys/fs/selinux/enforce"); - try { - enforcing = (is.read() == '1'); - } finally { - is.close(); - } - } catch (Exception e) { - } - } - - // 4.4+ builds are enforcing by default, take the gamble - if (enforcing == null) { - enforcing = (android.os.Build.VERSION.SDK_INT >= 19); - } - } - - if (enforcing == null) { - enforcing = false; - } - - isSELinuxEnforcing = enforcing; - } - return isSELinuxEnforcing; - } - - /** - * Runnable to write commands to the open shell. - *

- * When writing commands we stay in a loop and wait for new - * commands to added to "commands" - *

- * The notification of a new command is handled by the method add in this class - */ - private final Runnable input = new Runnable() { - public void run() { - - try { - while (true) { - - synchronized (commands) { - /** - * While loop is used in the case that notifyAll is called - * and there are still no commands to be written, a rare - * case but one that could happen. - */ - while (!close && write >= commands.size()) { - isExecuting = false; - commands.wait(); - } - } - - if (write >= maxCommands) { - - /** - * wait for the read to catch up. - */ - while (read != write) { - RootShell.log("Waiting for read and write to catch up before cleanup."); - } - /** - * Clean up the commands, stay neat. - */ - cleanCommands(); - } - - /** - * Write the new command - * - * We write the command followed by the token to indicate - * the end of the command execution - */ - if (write < commands.size()) { - isExecuting = true; - Command cmd = commands.get(write); - cmd.startExecution(); - RootShell.log("Executing: " + cmd.getCommand() + " with context: " + shellContext); - - //write the command - outputStream.write(cmd.getCommand()); - outputStream.flush(); - - //write the token... - String line = "\necho " + token + " " + totalExecuted + " $?\n"; - outputStream.write(line); - outputStream.flush(); - - write++; - totalExecuted++; - } else if (close) { - /** - * close the thread, the shell is closing. - */ - isExecuting = false; - outputStream.write("\nexit 0\n"); - outputStream.flush(); - RootShell.log("Closing shell"); - return; - } - } - } catch (IOException | InterruptedException e) { - RootShell.log(e.getMessage(), RootShell.LogLevel.ERROR, e); - } - finally { - write = 0; - closeQuietly(outputStream); - } - } - }; - - protected void notifyThreads() { - Thread t = new Thread() { - public void run() { - synchronized (commands) { - commands.notifyAll(); - } - } - }; - - t.start(); - } - - /** - * Runnable to monitor the responses from the open shell. - * - * This include the output and error stream - */ - private final Runnable output = new Runnable() { - public void run() { - try { - Command command = null; - - //as long as there is something to read, we will keep reading. - while (!close || inputStream.ready() || read < commands.size()) { - isReading = false; - String outputLine = inputStream.readLine(); - isReading = true; - - /** - * If we receive EOF then the shell closed? - */ - if (outputLine == null) { - break; - } - - if (command == null) { - if (read >= commands.size()) { - if (close) { - break; - } - - continue; - } - - command = commands.get(read); - } - - /** - * trying to determine if all commands have been completed. - * - * if the token is present then the command has finished execution. - */ - int pos = -1; - - pos = outputLine.indexOf(token); - - if (pos == -1) { - /** - * send the output for the implementer to process - */ - command.output(command.id, outputLine); - } else if (pos > 0) { - /** - * token is suffix of output, send output part to implementer - */ - RootShell.log("Found token, line: " + outputLine); - command.output(command.id, outputLine.substring(0, pos)); - } - - if (pos >= 0) { - outputLine = outputLine.substring(pos); - String[] fields = outputLine.split(" "); - - if (fields.length >= 2 && fields[1] != null) { - int id = 0; - - try { - id = Integer.parseInt(fields[1]); - } catch (NumberFormatException e) { - } - - int exitCode = -1; - - try { - exitCode = Integer.parseInt(fields[2]); - } catch (NumberFormatException e) { - } - - if (id == totalRead) { - processErrors(command); - - - /** - * wait for output to be processed... - * - */ - int iterations = 0; - while (command.totalOutput > command.totalOutputProcessed) { - - if(iterations == 0) - { - iterations++; - RootShell.log("Waiting for output to be processed. " + command.totalOutputProcessed + " Of " + command.totalOutput); - } - - try { - - synchronized (this) - { - this.wait(2000); - } - } catch (Exception e) { - RootShell.log(e.getMessage()); - } - } - - RootShell.log("Read all output"); - - command.setExitCode(exitCode); - command.commandFinished(); - - command = null; - - read++; - totalRead++; - continue; - } - } - } - } - - try { - proc.waitFor(); - proc.destroy(); - } catch (Exception e) { - } - - while (read < commands.size()) { - if (command == null) { - command = commands.get(read); - } - - if(command.totalOutput < command.totalOutputProcessed) - { - command.terminated("All output not processed!"); - command.terminated("Did you forget the super.commandOutput call or are you waiting on the command object?"); - } - else - { - command.terminated("Unexpected Termination."); - } - - command = null; - read++; - } - - read = 0; - - } catch (IOException e) { - RootShell.log(e.getMessage(), RootShell.LogLevel.ERROR, e); - } finally { - closeQuietly(outputStream); - closeQuietly(errorStream); - closeQuietly(inputStream); - - RootShell.log("Shell destroyed"); - isClosed = true; - isReading = false; - } - } - }; - - public void processErrors(Command command) { - try { - while (errorStream.ready() && command != null) { - String line = errorStream.readLine(); - - /** - * If we recieve EOF then the shell closed? - */ - if (line == null) { - break; - } - - /** - * send the output for the implementer to process - */ - command.output(command.id, line); - } - } catch (Exception e) { - RootShell.log(e.getMessage(), RootShell.LogLevel.ERROR, e); - } - } - - public static Command runRootCommand(Command command) throws IOException, TimeoutException, RootDeniedException { - return Shell.startRootShell().add(command); - } - - public static Command runCommand(Command command) throws IOException, TimeoutException { - return Shell.startShell().add(command); - } - - public static Shell startRootShell() throws IOException, TimeoutException, RootDeniedException { - return Shell.startRootShell(0, 3); - } - - public static Shell startRootShell(int timeout) throws IOException, TimeoutException, RootDeniedException { - return Shell.startRootShell(timeout, 3); - } - - public static Shell startRootShell(int timeout, int retry) throws IOException, TimeoutException, RootDeniedException { - return Shell.startRootShell(timeout, Shell.defaultContext, retry); - } - - public static Shell startRootShell(int timeout, ShellContext shellContext, int retry) throws IOException, TimeoutException, RootDeniedException { - // keep prompting the user until they accept for x amount of times... - int retries = 0; - - if (Shell.rootShell == null) { - - RootShell.log("Starting Root Shell!"); - String cmd = "su"; - while (Shell.rootShell == null) { - try { - RootShell.log("Trying to open Root Shell, attempt #" + retries); - Shell.rootShell = new Shell(cmd, ShellType.ROOT, shellContext, timeout); - } catch (IOException e) { - if (retries++ >= retry) { - RootShell.log("IOException, could not start shell"); - throw e; - } - } catch (RootDeniedException e) { - if (retries++ >= retry) { - RootShell.log("RootDeniedException, could not start shell"); - throw e; - } - } catch (TimeoutException e) { - if (retries++ >= retry) { - RootShell.log("TimeoutException, could not start shell"); - throw e; - } - } - } - } else if (Shell.rootShell.shellContext != shellContext) { - try { - RootShell.log("Context is different than open shell, switching context... " + Shell.rootShell.shellContext + " VS " + shellContext); - Shell.rootShell.switchRootShellContext(shellContext); - } catch (IOException e) { - if (retries++ >= retry) { - RootShell.log("IOException, could not switch context!"); - throw e; - } - } catch (RootDeniedException e) { - if (retries++ >= retry) { - RootShell.log("RootDeniedException, could not switch context!"); - throw e; - } - } catch (TimeoutException e) { - if (retries++ >= retry) { - RootShell.log("TimeoutException, could not switch context!"); - throw e; - } - } - } else { - RootShell.log("Using Existing Root Shell!"); - } - - return Shell.rootShell; - } - - public static Shell startCustomShell(String shellPath) throws IOException, TimeoutException, RootDeniedException { - return Shell.startCustomShell(shellPath, 0); - } - - public static Shell startCustomShell(String shellPath, int timeout) throws IOException, TimeoutException, RootDeniedException { - - if (Shell.customShell == null) { - RootShell.log("Starting Custom Shell!"); - Shell.customShell = new Shell(shellPath, ShellType.CUSTOM, ShellContext.NORMAL, timeout); - } else { - RootShell.log("Using Existing Custom Shell!"); - } - - return Shell.customShell; - } - - public static Shell startShell() throws IOException, TimeoutException { - return Shell.startShell(0); - } - - public static Shell startShell(int timeout) throws IOException, TimeoutException { - - try { - if (Shell.shell == null) { - RootShell.log("Starting Shell!"); - Shell.shell = new Shell("/system/bin/sh", ShellType.NORMAL, ShellContext.NORMAL, timeout); - } else { - RootShell.log("Using Existing Shell!"); - } - return Shell.shell; - } catch (RootDeniedException e) { - //Root Denied should never be thrown. - throw new IOException(); - } - } - - public Shell switchRootShellContext(ShellContext shellContext) throws IOException, TimeoutException, RootDeniedException { - if (this.shellType == ShellType.ROOT) { - try { - Shell.closeRootShell(); - } catch (Exception e) { - RootShell.log("Problem closing shell while trying to switch context..."); - } - - //create new root shell with new context... - - return Shell.startRootShell(this.shellTimeout, shellContext, 3); - } else { - //can only switch context on a root shell... - RootShell.log("Can only switch context on a root shell!"); - return this; - } - } - - protected static class Worker extends Thread { - - public int exit = -911; - - public Shell shell; - - private Worker(Shell shell) { - this.shell = shell; - } - - public void run() { - - /** - * Trying to open the shell. - * - * We echo "Started" and we look for it in the output. - * - * If we find the output then the shell is open and we return. - * - * If we do not find it then we determine the error and report - * it by setting the value of the variable exit - */ - try { - shell.outputStream.write("echo Started\n"); - shell.outputStream.flush(); - - while (true) { - String line = shell.inputStream.readLine(); - - if (line == null) { - throw new EOFException(); - } else if ("".equals(line)) { - continue; - } else if ("Started".equals(line)) { - this.exit = 1; - setShellOom(); - break; - } - - shell.error = "unknown error occurred."; - } - } catch (IOException e) { - exit = -42; - if (e.getMessage() != null) { - shell.error = e.getMessage(); - } else { - shell.error = "RootAccess denied?."; - } - } - - } - - /* - * setOom for shell processes (sh and su if root shell) and discard outputs - * Negative values make the process LESS likely to be killed in an OOM situation - * Positive values make the process MORE likely to be killed in an OOM situation - */ - private void setShellOom() { - try { - Class processClass = shell.proc.getClass(); - Field field; - try { - field = processClass.getDeclaredField("pid"); - } catch (NoSuchFieldException e) { - field = processClass.getDeclaredField("id"); - } - field.setAccessible(true); - int pid = (Integer) field.get(shell.proc); - shell.outputStream.write("(echo -17 > /proc/" + pid + "/oom_adj) &> /dev/null\n"); - shell.outputStream.write("(echo -17 > /proc/$$/oom_adj) &> /dev/null\n"); - shell.outputStream.flush(); - } catch (Exception e) { - e.printStackTrace(); - } - } - } -} diff --git a/app/src/main/java/com/stericson/roottools/Constants.java b/app/src/main/java/com/stericson/roottools/Constants.java deleted file mode 100644 index f15ee7edc..000000000 --- a/app/src/main/java/com/stericson/roottools/Constants.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.stericson.roottools; - -public class Constants -{ - public static final String TAG = "RootTools v4.4"; - public static final int FPS = 1; - public static final int BBA = 3; - public static final int BBV = 4; - public static final int GI = 5; - public static final int GS = 6; - public static final int GSYM = 7; - public static final int GET_MOUNTS = 8; - public static final int GET_SYMLINKS = 9; - -} diff --git a/app/src/main/java/com/stericson/roottools/RootTools.java b/app/src/main/java/com/stericson/roottools/RootTools.java deleted file mode 100644 index 6b80873b5..000000000 --- a/app/src/main/java/com/stericson/roottools/RootTools.java +++ /dev/null @@ -1,848 +0,0 @@ -/* - * This file is part of the RootTools Project: http://code.google.com/p/RootTools/ - * - * Copyright (c) 2012 Stephen Erickson, Chris Ravenscroft, Dominik Schuermann, Adam Shanks - * - * This code is dual-licensed under the terms of the Apache License Version 2.0 and - * the terms of the General Public License (GPL) Version 2. - * You may use this code according to either of these licenses as is most appropriate - * for your project on a case-by-case basis. - * - * The terms of each license can be found in the root directory of this project's repository as well as at: - * - * * http://www.apache.org/licenses/LICENSE-2.0 - * * http://www.gnu.org/licenses/gpl-2.0.txt - * - * Unless required by applicable law or agreed to in writing, software - * distributed under these Licenses is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See each License for the specific language governing permissions and - * limitations under that License. - */ - -package com.stericson.roottools; - -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.util.Log; - -import com.stericson.rootshell.RootShell; -import com.stericson.rootshell.exceptions.RootDeniedException; -import com.stericson.rootshell.execution.Command; -import com.stericson.rootshell.execution.Shell; -import com.stericson.roottools.containers.Mount; -import com.stericson.roottools.containers.Permissions; -import com.stericson.roottools.containers.Symlink; -import com.stericson.roottools.internal.Remounter; -import com.stericson.roottools.internal.RootToolsInternalMethods; -import com.stericson.roottools.internal.Runner; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.concurrent.TimeoutException; - -public final class RootTools { - - /** - * This class is the gateway to every functionality within the RootTools library.The developer - * should only have access to this class and this class only.This means that this class should - * be the only one to be public.The rest of the classes within this library must not have the - * public modifier. - *

- * All methods and Variables that the developer may need to have access to should be here. - *

- * If a method, or a specific functionality, requires a fair amount of code, or work to be done, - * then that functionality should probably be moved to its own class and the call to it done - * here.For examples of this being done, look at the remount functionality. - */ - - private static RootToolsInternalMethods rim = null; - - public static void setRim(RootToolsInternalMethods rim) { - RootTools.rim = rim; - } - - private static final RootToolsInternalMethods getInternals() { - if (rim == null) { - RootToolsInternalMethods.getInstance(); - return rim; - } else { - return rim; - } - } - - // -------------------- - // # Public Variables # - // -------------------- - - public static boolean debugMode = false; - public static String utilPath; - - /** - * Setting this to false will disable the handler that is used - * by default for the 3 callback methods for Command. - *

- * By disabling this all callbacks will be called from a thread other than - * the main UI thread. - */ - public static boolean handlerEnabled = true; - - - /** - * Setting this will change the default command timeout. - *

- * The default is 20000ms - */ - public static int default_Command_Timeout = 20000; - - - // --------------------------- - // # Public Variable Getters # - // --------------------------- - - // ------------------ - // # Public Methods # - // ------------------ - - /** - * This will check a given binary, determine if it exists and determine that it has either the - * permissions 755, 775, or 777. - * - * @param util Name of the utility to check. - * @return boolean to indicate whether the binary is installed and has appropriate permissions. - */ - public static boolean checkUtil(String util) { - - return getInternals().checkUtil(util); - } - - /** - * This will close all open shells. - * - * @throws IOException - */ - public static void closeAllShells() throws IOException { - RootShell.closeAllShells(); - } - - /** - * This will close the custom shell that you opened. - * - * @throws IOException - */ - public static void closeCustomShell() throws IOException { - RootShell.closeCustomShell(); - } - - /** - * This will close either the root shell or the standard shell depending on what you specify. - * - * @param root a boolean to specify whether to close the root shell or the standard shell. - * @throws IOException - */ - public static void closeShell(boolean root) throws IOException { - RootShell.closeShell(root); - } - - /** - * Copys a file to a destination. Because cp is not available on all android devices, we have a - * fallback on the cat command - * - * @param source example: /data/data/org.adaway/files/hosts - * @param destination example: /system/etc/hosts - * @param remountAsRw remounts the destination as read/write before writing to it - * @param preserveFileAttributes tries to copy file attributes from source to destination, if only cat is available - * only permissions are preserved - * @return true if it was successfully copied - */ - public static boolean copyFile(String source, String destination, boolean remountAsRw, - boolean preserveFileAttributes) { - return getInternals().copyFile(source, destination, remountAsRw, preserveFileAttributes); - } - - /** - * Deletes a file or directory - * - * @param target example: /data/data/org.adaway/files/hosts - * @param remountAsRw remounts the destination as read/write before writing to it - * @return true if it was successfully deleted - */ - public static boolean deleteFileOrDirectory(String target, boolean remountAsRw) { - return getInternals().deleteFileOrDirectory(target, remountAsRw); - } - - /** - * Use this to check whether or not a file exists on the filesystem. - * - * @param file String that represent the file, including the full path to the - * file and its name. - * @return a boolean that will indicate whether or not the file exists. - */ - public static boolean exists(final String file) { - return exists(file, false); - } - - /** - * Use this to check whether or not a file OR directory exists on the filesystem. - * - * @param file String that represent the file OR the directory, including the full path to the - * file and its name. - * @param isDir boolean that represent whether or not we are looking for a directory - * @return a boolean that will indicate whether or not the file exists. - */ - public static boolean exists(final String file, boolean isDir) { - return RootShell.exists(file, isDir); - } - - /** - * This will try and fix a given binary. (This is for Busybox applets or Toolbox applets) By - * "fix", I mean it will try and symlink the binary from either toolbox or Busybox and fix the - * permissions if the permissions are not correct. - * - * @param util Name of the utility to fix. - * @param utilPath path to the toolbox that provides ln, rm, and chmod. This can be a blank string, a - * path to a binary that will provide these, or you can use - * RootTools.getWorkingToolbox() - */ - public static void fixUtil(String util, String utilPath) { - getInternals().fixUtil(util, utilPath); - } - - /** - * This will check an array of binaries, determine if they exist and determine that it has - * either the permissions 755, 775, or 777. If an applet is not setup correctly it will try and - * fix it. (This is for Busybox applets or Toolbox applets) - * - * @param utils Name of the utility to check. - * @return boolean to indicate whether the operation completed. Note that this is not indicative - * of whether the problem was fixed, just that the method did not encounter any - * exceptions. - * @throws Exception if the operation cannot be completed. - */ - public static boolean fixUtils(String[] utils) throws Exception { - return getInternals().fixUtils(utils); - } - - /** - * @param binaryName String that represent the binary to find. - * @param singlePath boolean that represents whether to return a single path or multiple. - * - * @return List containing the paths the binary was found at. - */ - public static List findBinary(String binaryName, boolean singlePath) { - return RootShell.findBinary(binaryName, singlePath); - } - - /** - * @param path String that represents the path to the Busybox binary you want to retrieve the version of. - * @return BusyBox version is found, "" if not found. - */ - public static String getBusyBoxVersion(String path) { - return getInternals().getBusyBoxVersion(path); - } - - /** - * @return BusyBox version is found, "" if not found. - */ - public static String getBusyBoxVersion() { - return RootTools.getBusyBoxVersion(""); - } - - /** - * This will return an List of Strings. Each string represents an applet available from BusyBox. - *

- * - * @return null If we cannot return the list of applets. - */ - public static List getBusyBoxApplets() throws Exception { - return RootTools.getBusyBoxApplets(""); - } - - /** - * This will return an List of Strings. Each string represents an applet available from BusyBox. - *

- * - * @param path Path to the busybox binary that you want the list of applets from. - * @return null If we cannot return the list of applets. - */ - public static List getBusyBoxApplets(String path) throws Exception { - return getInternals().getBusyBoxApplets(path); - } - - /** - * This will open or return, if one is already open, a custom shell, you are responsible for managing the shell, reading the output - * and for closing the shell when you are done using it. - * - * @param shellPath a String to Indicate the path to the shell that you want to open. - * @param timeout an int to Indicate the length of time before giving up on opening a shell. - * @throws TimeoutException - * @throws com.stericson.RootShell.exceptions.RootDeniedException - * @throws IOException - */ - public static Shell getCustomShell(String shellPath, int timeout) throws IOException, TimeoutException, RootDeniedException { - return RootShell.getCustomShell(shellPath, timeout); - } - - /** - * This will open or return, if one is already open, a custom shell, you are responsible for managing the shell, reading the output - * and for closing the shell when you are done using it. - * - * @param shellPath a String to Indicate the path to the shell that you want to open. - * @throws TimeoutException - * @throws com.stericson.RootShell.exceptions.RootDeniedException - * @throws IOException - */ - public static Shell getCustomShell(String shellPath) throws IOException, TimeoutException, RootDeniedException { - return RootTools.getCustomShell(shellPath, 10000); - } - - /** - * @param file String that represent the file, including the full path to the file and its name. - * @return An instance of the class permissions from which you can get the permissions of the - * file or if the file could not be found or permissions couldn't be determined then - * permissions will be null. - */ - public static Permissions getFilePermissionsSymlinks(String file) { - return getInternals().getFilePermissionsSymlinks(file); - } - - /** - * This method will return the inode number of a file. This method is dependent on having a version of - * ls that supports the -i parameter. - * - * @param file path to the file that you wish to return the inode number - * @return String The inode number for this file or "" if the inode number could not be found. - */ - public static String getInode(String file) { - return getInternals().getInode(file); - } - - /** - * This will return an ArrayList of the class Mount. The class mount contains the following - * property's: device mountPoint type flags - *

- * These will provide you with any information you need to work with the mount points. - * - * @return ArrayList an ArrayList of the class Mount. - * @throws Exception if we cannot return the mount points. - */ - public static ArrayList getMounts() throws Exception { - return getInternals().getMounts(); - } - - /** - * This will tell you how the specified mount is mounted. rw, ro, etc... - *

- * - * @param path The mount you want to check - * @return String What the mount is mounted as. - * @throws Exception if we cannot determine how the mount is mounted. - */ - public static String getMountedAs(String path) throws Exception { - return getInternals().getMountedAs(path); - } - - /** - * This will return the environment variable PATH - * - * @return List A List of Strings representing the environment variable $PATH - */ - public static List getPath() { - return Arrays.asList(System.getenv("PATH").split(":")); - } - - /** - * This will open or return, if one is already open, a shell, you are responsible for managing the shell, reading the output - * and for closing the shell when you are done using it. - * - * @param root a boolean to Indicate whether or not you want to open a root shell or a standard shell - * @param timeout an int to Indicate the length of time to wait before giving up on opening a shell. - * @param shellContext the context to execute the shell with - * @param retry a int to indicate how many times the ROOT shell should try to open with root priviliges... - * @throws TimeoutException - * @throws com.stericson.RootShell.exceptions.RootDeniedException - * @throws IOException - */ - public static Shell getShell(boolean root, int timeout, Shell.ShellContext shellContext, int retry) throws IOException, TimeoutException, RootDeniedException { - return RootShell.getShell(root, timeout, shellContext, retry); - } - - /** - * This will open or return, if one is already open, a shell, you are responsible for managing the shell, reading the output - * and for closing the shell when you are done using it. - * - * @param root a boolean to Indicate whether or not you want to open a root shell or a standard shell - * @param timeout an int to Indicate the length of time to wait before giving up on opening a shell. - * @param shellContext the context to execute the shell with - * @throws TimeoutException - * @throws com.stericson.RootShell.exceptions.RootDeniedException - * @throws IOException - */ - public static Shell getShell(boolean root, int timeout, Shell.ShellContext shellContext) throws IOException, TimeoutException, RootDeniedException { - return getShell(root, timeout, shellContext, 3); - } - - /** - * This will open or return, if one is already open, a shell, you are responsible for managing the shell, reading the output - * and for closing the shell when you are done using it. - * - * @param root a boolean to Indicate whether or not you want to open a root shell or a standard shell - * @param shellContext the context to execute the shell with - * @throws TimeoutException - * @throws com.stericson.RootShell.exceptions.RootDeniedException - * @throws IOException - */ - public static Shell getShell(boolean root, Shell.ShellContext shellContext) throws IOException, TimeoutException, RootDeniedException { - return getShell(root, 0, shellContext, 3); - } - - /** - * This will open or return, if one is already open, a shell, you are responsible for managing the shell, reading the output - * and for closing the shell when you are done using it. - * - * @param root a boolean to Indicate whether or not you want to open a root shell or a standard shell - * @param timeout an int to Indicate the length of time to wait before giving up on opening a shell. - * @throws TimeoutException - * @throws com.stericson.RootShell.exceptions.RootDeniedException - * @throws IOException - */ - public static Shell getShell(boolean root, int timeout) throws IOException, TimeoutException, RootDeniedException { - return getShell(root, timeout, Shell.defaultContext, 3); - } - - /** - * This will open or return, if one is already open, a shell, you are responsible for managing the shell, reading the output - * and for closing the shell when you are done using it. - * - * @param root a boolean to Indicate whether or not you want to open a root shell or a standard shell - * @throws TimeoutException - * @throws com.stericson.RootShell.exceptions.RootDeniedException - * @throws IOException - */ - public static Shell getShell(boolean root) throws IOException, TimeoutException, RootDeniedException { - return RootTools.getShell(root, 0); - } - - /** - * Get the space for a desired partition. - * - * @param path The partition to find the space for. - * @return the amount if space found within the desired partition. If the space was not found - * then the value is -1 - * @throws TimeoutException - */ - public static long getSpace(String path) { - return getInternals().getSpace(path); - } - - /** - * This will return a String that represent the symlink for a specified file. - *

- * - * @param file path to the file to get the Symlink for. (must have absolute path) - * @return String a String that represent the symlink for a specified file or an - * empty string if no symlink exists. - */ - public static String getSymlink(String file) { - return getInternals().getSymlink(file); - } - - /** - * This will return an ArrayList of the class Symlink. The class Symlink contains the following - * property's: path SymplinkPath - *

- * These will provide you with any Symlinks in the given path. - * - * @param path path to search for Symlinks. - * @return ArrayList an ArrayList of the class Symlink. - * @throws Exception if we cannot return the Symlinks. - */ - public static ArrayList getSymlinks(String path) throws Exception { - return getInternals().getSymlinks(path); - } - - /** - * This will return to you a string to be used in your shell commands which will represent the - * valid working toolbox with correct permissions. For instance, if Busybox is available it will - * return "busybox", if busybox is not available but toolbox is then it will return "toolbox" - * - * @return String that indicates the available toolbox to use for accessing applets. - */ - public static String getWorkingToolbox() { - return getInternals().getWorkingToolbox(); - } - - /** - * Checks if there is enough Space on SDCard - * - * @param updateSize size to Check (long) - * @return true if the Update will fit on SDCard, false if not enough - * space on SDCard. Will also return false, if the SDCard is not mounted as - * read/write - */ - public static boolean hasEnoughSpaceOnSdCard(long updateSize) { - return getInternals().hasEnoughSpaceOnSdCard(updateSize); - } - - /** - * Checks whether the toolbox or busybox binary contains a specific util - * - * @param util - * @param box Should contain "toolbox" or "busybox" - * @return true if it contains this util - */ - public static boolean hasUtil(final String util, final String box) { - //TODO Convert this to use the new shell. - return getInternals().hasUtil(util, box); - } - - /** - * This method can be used to unpack a binary from the raw resources folder and store it in - * /data/data/app.package/files/ This is typically useful if you provide your own C- or - * C++-based binary. This binary can then be executed using sendShell() and its full path. - * - * @param context the current activity's Context - * @param sourceId resource id; typically R.raw.id - * @param destName destination file name; appended to /data/data/app.package/files/ - * @param mode chmod value for this file - * @return a boolean which indicates whether or not we were able to create the new - * file. - */ - public static boolean installBinary(Context context, int sourceId, String destName, String mode) { - return getInternals().installBinary(context, sourceId, destName, mode); - } - - /** - * This method can be used to unpack a binary from the raw resources folder and store it in - * /data/data/app.package/files/ This is typically useful if you provide your own C- or - * C++-based binary. This binary can then be executed using sendShell() and its full path. - * - * @param context the current activity's Context - * @param sourceId resource id; typically R.raw.id - * @param binaryName destination file name; appended to /data/data/app.package/files/ - * @return a boolean which indicates whether or not we were able to create the new - * file. - */ - public static boolean installBinary(Context context, int sourceId, String binaryName) { - return installBinary(context, sourceId, binaryName, "700"); - } - - /** - * This method checks whether a binary is installed. - * - * @param context the current activity's Context - * @param binaryName binary file name; appended to /data/data/app.package/files/ - * @return a boolean which indicates whether or not - * the binary already exists. - */ - public static boolean hasBinary(Context context, String binaryName) { - return getInternals().isBinaryAvailable(context, binaryName); - } - - /** - * This will let you know if an applet is available from BusyBox - *

- * - * @param applet The applet to check for. - * @param path Path to the busybox binary that you want to check. (do not include binary name) - * @return true if applet is available, false otherwise. - */ - public static boolean isAppletAvailable(String applet, String path) { - return getInternals().isAppletAvailable(applet, path); - } - - /** - * This will let you know if an applet is available from BusyBox - *

- * - * @param applet The applet to check for. - * @return true if applet is available, false otherwise. - */ - public static boolean isAppletAvailable(String applet) { - return RootTools.isAppletAvailable(applet, ""); - } - /** - * @return true if your app has been given root access. - * @throws TimeoutException if this operation times out. (cannot determine if access is given) - */ - public static boolean isAccessGiven() { - return RootShell.isAccessGiven(); - } - - /** - * Control how many time of retries should request - * - * @param timeout The timeout - * @param retries The number of retries - * - * @return true if your app has been given root access. - * @throws TimeoutException if this operation times out. (cannot determine if access is given) - */ - public static boolean isAccessGiven(int timeout, int retries) { - return RootShell.isAccessGiven(timeout, retries); - } - - /** - * @return true if BusyBox was found. - */ - public static boolean isBusyboxAvailable() { - return RootShell.isBusyboxAvailable(); - } - - public static boolean isNativeToolsReady(int nativeToolsId, Context context) { - return getInternals().isNativeToolsReady(nativeToolsId, context); - } - - /** - * This method can be used to to check if a process is running - * - * @param processName name of process to check - * @return true if process was found - * @throws TimeoutException (Could not determine if the process is running) - */ - public static boolean isProcessRunning(final String processName) { - //TODO convert to new shell - return getInternals().isProcessRunning(processName); - } - - /** - * @return true if su was found. - */ - public static boolean isRootAvailable() { - return RootShell.isRootAvailable(); - } - - /** - * This method can be used to kill a running process - * - * @param processName name of process to kill - * @return true if process was found and killed successfully - */ - public static boolean killProcess(final String processName) { - //TODO convert to new shell - return getInternals().killProcess(processName); - } - - /** - * This will launch the Android market looking for BusyBox - * - * @param activity pass in your Activity - */ - public static void offerBusyBox(Activity activity) { - getInternals().offerBusyBox(activity); - } - - /** - * This will launch the Android market looking for BusyBox, but will return the intent fired and - * starts the activity with startActivityForResult - * - * @param activity pass in your Activity - * @param requestCode pass in the request code - * @return intent fired - */ - public static Intent offerBusyBox(Activity activity, int requestCode) { - return getInternals().offerBusyBox(activity, requestCode); - } - - /** - * This will launch the Android market looking for SuperUser - * - * @param activity pass in your Activity - */ - public static void offerSuperUser(Activity activity) { - getInternals().offerSuperUser(activity); - } - - /** - * This will launch the Android market looking for SuperUser, but will return the intent fired - * and starts the activity with startActivityForResult - * - * @param activity pass in your Activity - * @param requestCode pass in the request code - * @return intent fired - */ - public static Intent offerSuperUser(Activity activity, int requestCode) { - return getInternals().offerSuperUser(activity, requestCode); - } - - /** - * This will take a path, which can contain the file name as well, and attempt to remount the - * underlying partition. - *

- * For example, passing in the following string: - * "/system/bin/some/directory/that/really/would/never/exist" will result in /system ultimately - * being remounted. However, keep in mind that the longer the path you supply, the more work - * this has to do, and the slower it will run. - * - * @param file file path - * @param mountType mount type: pass in RO (Read only) or RW (Read Write) - * @return a boolean which indicates whether or not the partition has been - * remounted as specified. - */ - public static boolean remount(String file, String mountType) { - // Recieved a request, get an instance of Remounter - Remounter remounter = new Remounter(); - // send the request. - return (remounter.remount(file, mountType)); - } - - public static boolean remount(String file, String mountType, String customPath) { - // Recieved a request, get an instance of Remounter - Remounter remounter = new Remounter(customPath); - // send the request. - return (remounter.remount(file, mountType)); - } - - /** - * This restarts only Android OS without rebooting the whole device. This does NOT work on all - * devices. This is done by killing the main init process named zygote. Zygote is restarted - * automatically by Android after killing it. - * - * @throws TimeoutException - */ - /* public static void restartAndroid() { - RootTools.log("Restart Android"); - killProcess("zygote"); - }*/ - - /** - * Executes binary in a separated process. Before using this method, the binary has to be - * installed in /data/data/app.package/files/ using the installBinary method. - * - * @param context the current activity's Context - * @param binaryName name of installed binary - * @param parameter parameter to append to binary like "-vxf" - */ - public static void runBinary(Context context, String binaryName, String parameter) { - Runner runner = new Runner(context, binaryName, parameter); - runner.start(); - } - - /** - * Executes a given command with root access or without depending on the value of the boolean passed. - * This will also start a root shell or a standard shell without you having to open it specifically. - *

- * You will still need to close the shell after you are done using the shell. - * - * @param shell The shell to execute the command on, this can be a root shell or a standard shell. - * @param command The command to execute in the shell - * - * @throws IOException - */ - public static void runShellCommand(Shell shell, Command command) throws IOException { - shell.add(command); - } - - /** - * This method allows you to output debug messages only when debugging is on. This will allow - * you to add a debug option to your app, which by default can be left off for performance. - * However, when you need debugging information, a simple switch can enable it and provide you - * with detailed logging. - *

- * This method handles whether or not to log the information you pass it depending whether or - * not RootTools.debugMode is on. So you can use this and not have to worry about handling it - * yourself. - * - * @param msg The message to output. - */ - public static void log(String msg) { - log(null, msg, 3, null); - } - - /** - * This method allows you to output debug messages only when debugging is on. This will allow - * you to add a debug option to your app, which by default can be left off for performance. - * However, when you need debugging information, a simple switch can enable it and provide you - * with detailed logging. - *

- * This method handles whether or not to log the information you pass it depending whether or - * not RootTools.debugMode is on. So you can use this and not have to worry about handling it - * yourself. - * - * @param TAG Optional parameter to define the tag that the Log will use. - * @param msg The message to output. - */ - public static void log(String TAG, String msg) { - log(TAG, msg, 3, null); - } - - /** - * This method allows you to output debug messages only when debugging is on. This will allow - * you to add a debug option to your app, which by default can be left off for performance. - * However, when you need debugging information, a simple switch can enable it and provide you - * with detailed logging. - *

- * This method handles whether or not to log the information you pass it depending whether or - * not RootTools.debugMode is on. So you can use this and not have to worry about handling it - * yourself. - * - * @param msg The message to output. - * @param type The type of log, 1 for verbose, 2 for error, 3 for debug - * @param e The exception that was thrown (Needed for errors) - */ - public static void log(String msg, int type, Exception e) { - log(null, msg, type, e); - } - - /** - * This method allows you to check whether logging is enabled. - * Yes, it has a goofy name, but that's to keep it as short as possible. - * After all writing logging calls should be painless. - * This method exists to save Android going through the various Java layers - * that are traversed any time a string is created (i.e. what you are logging) - *

- * Example usage: - * if(islog) { - * StrinbBuilder sb = new StringBuilder(); - * // ... - * // build string - * // ... - * log(sb.toString()); - * } - * - * @return true if logging is enabled - */ - public static boolean islog() { - return debugMode; - } - - /** - * This method allows you to output debug messages only when debugging is on. This will allow - * you to add a debug option to your app, which by default can be left off for performance. - * However, when you need debugging information, a simple switch can enable it and provide you - * with detailed logging. - *

- * This method handles whether or not to log the information you pass it depending whether or - * not RootTools.debugMode is on. So you can use this and not have to worry about handling it - * yourself. - * - * @param TAG Optional parameter to define the tag that the Log will use. - * @param msg The message to output. - * @param type The type of log, 1 for verbose, 2 for error, 3 for debug - * @param e The exception that was thrown (Needed for errors) - */ - public static void log(String TAG, String msg, int type, Exception e) { - if (msg != null && !msg.equals("")) { - if (debugMode) { - if (TAG == null) { - TAG = Constants.TAG; - } - - switch (type) { - case 1: - Log.v(TAG, msg); - break; - case 2: - Log.e(TAG, msg, e); - break; - case 3: - Log.d(TAG, msg); - break; - } - } - } - } -} diff --git a/app/src/main/java/com/stericson/roottools/SanityCheckRootTools.java b/app/src/main/java/com/stericson/roottools/SanityCheckRootTools.java deleted file mode 100644 index 9d36748d7..000000000 --- a/app/src/main/java/com/stericson/roottools/SanityCheckRootTools.java +++ /dev/null @@ -1,459 +0,0 @@ -/* - * This file is part of the RootTools Project: http://code.google.com/p/RootTools/ - * - * Copyright (c) 2012 Stephen Erickson, Chris Ravenscroft, Dominik Schuermann, Adam Shanks - * - * This code is dual-licensed under the terms of the Apache License Version 2.0 and - * the terms of the General Public License (GPL) Version 2. - * You may use this code according to either of these licenses as is most appropriate - * for your project on a case-by-case basis. - * - * The terms of each license can be found in the root directory of this project's repository as well as at: - * - * * http://www.apache.org/licenses/LICENSE-2.0 - * * http://www.gnu.org/licenses/gpl-2.0.txt - * - * Unless required by applicable law or agreed to in writing, software - * distributed under these Licenses is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See each License for the specific language governing permissions and - * limitations under that License. - */ - -package com.stericson.roottools; - -import android.app.Activity; -import android.app.ProgressDialog; -import android.content.Context; -import android.os.Bundle; -import android.os.Handler; -import android.os.Message; -import android.os.StrictMode; -import android.widget.ScrollView; -import android.widget.TextView; - -import com.stericson.rootshell.exceptions.RootDeniedException; -import com.stericson.rootshell.execution.Command; -import com.stericson.rootshell.execution.Shell; -import com.stericson.roottools.containers.Permissions; - -import java.io.IOException; -import java.util.List; -import java.util.concurrent.TimeoutException; - -public class SanityCheckRootTools extends Activity { - private ScrollView mScrollView; - private TextView mTextView; - private ProgressDialog mPDialog; - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder() - .detectDiskReads() - .detectDiskWrites() - .detectNetwork() // or .detectAll() for all detectable problems - .penaltyLog() - .build()); - StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder() - .detectLeakedSqlLiteObjects() - .detectLeakedClosableObjects() - .penaltyLog() - .penaltyDeath() - .build()); - - RootTools.debugMode = true; - - mTextView = new TextView(this); - mTextView.setText(""); - mScrollView = new ScrollView(this); - mScrollView.addView(mTextView); - setContentView(mScrollView); - - print("SanityCheckRootTools \n\n"); - - if (RootTools.isRootAvailable()) { - print("Root found.\n"); - } else { - print("Root not found"); - } - - try { - Shell.startRootShell(); - } catch (IOException e2) { - // TODO Auto-generated catch block - e2.printStackTrace(); - } catch (TimeoutException e) { - print("[ TIMEOUT EXCEPTION! ]\n"); - e.printStackTrace(); - } catch (RootDeniedException e) { - print("[ ROOT DENIED EXCEPTION! ]\n"); - e.printStackTrace(); - } - - try { - if (!RootTools.isAccessGiven()) { - print("ERROR: No root access to this device.\n"); - return; - } - } catch (Exception e) { - print("ERROR: could not determine root access to this device.\n"); - return; - } - - // Display infinite progress bar - mPDialog = new ProgressDialog(this); - mPDialog.setCancelable(false); - mPDialog.setProgressStyle(ProgressDialog.STYLE_SPINNER); - - new SanityCheckThread(this, new TestHandler()).start(); - } - - protected void print(CharSequence text) { - mTextView.append(text); - mScrollView.post(new Runnable() { - public void run() { - mScrollView.fullScroll(ScrollView.FOCUS_DOWN); - } - }); - } - - // Run our long-running tests in their separate thread so as to - // not interfere with proper rendering. - private class SanityCheckThread extends Thread { - private final Handler mHandler; - - public SanityCheckThread(Context context, Handler handler) { - mHandler = handler; - } - - public void run() { - visualUpdate(TestHandler.ACTION_SHOW, null); - - // First test: Install a binary file for future use - // if it wasn't already installed. - /* - visualUpdate(TestHandler.ACTION_PDISPLAY, "Installing binary if needed"); - if(false == RootTools.installBinary(mContext, R.raw.nes, "nes_binary")) { - visualUpdate(TestHandler.ACTION_HIDE, "ERROR: Failed to install binary. Please see log file."); - return; - } - */ - - boolean result; - - visualUpdate(TestHandler.ACTION_PDISPLAY, "Testing getPath"); - visualUpdate(TestHandler.ACTION_DISPLAY, "[ getPath ]\n"); - - try { - List paths = RootTools.getPath(); - - for (String path : paths) { - visualUpdate(TestHandler.ACTION_DISPLAY, path + " k\n\n"); - } - - } catch (Exception e) { - e.printStackTrace(); - } - - visualUpdate(TestHandler.ACTION_PDISPLAY, "Testing A ton of commands"); - visualUpdate(TestHandler.ACTION_DISPLAY, "[ Ton of Commands ]\n"); - - for (int i = 0; i < 100; i++) { - RootTools.exists("/system/xbin/busybox"); - } - - visualUpdate(TestHandler.ACTION_PDISPLAY, "Testing Find Binary"); - result = RootTools.isRootAvailable(); - visualUpdate(TestHandler.ACTION_DISPLAY, "[ Checking Root ]\n"); - visualUpdate(TestHandler.ACTION_DISPLAY, result + " k\n\n"); - - visualUpdate(TestHandler.ACTION_PDISPLAY, "Testing file exists"); - visualUpdate(TestHandler.ACTION_DISPLAY, "[ Checking Exists() ]\n"); - visualUpdate(TestHandler.ACTION_DISPLAY, RootTools.exists("/system/sbin/[") + " k\n\n"); - - visualUpdate(TestHandler.ACTION_PDISPLAY, "Testing Is Access Given"); - result = RootTools.isAccessGiven(); - visualUpdate(TestHandler.ACTION_DISPLAY, "[ Checking for Access to Root ]\n"); - visualUpdate(TestHandler.ACTION_DISPLAY, result + " k\n\n"); - - visualUpdate(TestHandler.ACTION_PDISPLAY, "Testing Remount"); - result = RootTools.remount("/system", "rw"); - visualUpdate(TestHandler.ACTION_DISPLAY, "[ Remounting System as RW ]\n"); - visualUpdate(TestHandler.ACTION_DISPLAY, result + " k\n\n"); - - visualUpdate(TestHandler.ACTION_PDISPLAY, "Testing CheckUtil"); - visualUpdate(TestHandler.ACTION_DISPLAY, "[ Checking busybox is setup ]\n"); - visualUpdate(TestHandler.ACTION_DISPLAY, RootTools.checkUtil("busybox") + " k\n\n"); - - visualUpdate(TestHandler.ACTION_PDISPLAY, "Testing getBusyBoxVersion"); - visualUpdate(TestHandler.ACTION_DISPLAY, "[ Checking busybox version ]\n"); - visualUpdate(TestHandler.ACTION_DISPLAY, RootTools.getBusyBoxVersion("/system/xbin/") + " k\n\n"); - - try { - visualUpdate(TestHandler.ACTION_PDISPLAY, "Testing fixUtils"); - visualUpdate(TestHandler.ACTION_DISPLAY, "[ Checking Utils ]\n"); - visualUpdate(TestHandler.ACTION_DISPLAY, RootTools.fixUtils(new String[]{"ls", "rm", "ln", "dd", "chmod", "mount"}) + " k\n\n"); - } catch (Exception e2) { - // TODO Auto-generated catch block - e2.printStackTrace(); - } - - try { - visualUpdate(TestHandler.ACTION_PDISPLAY, "Testing getSymlink"); - visualUpdate(TestHandler.ACTION_DISPLAY, "[ Checking [[ for symlink ]\n"); - visualUpdate(TestHandler.ACTION_DISPLAY, RootTools.getSymlink("/system/bin/[[") + " k\n\n"); - } catch (Exception e2) { - // TODO Auto-generated catch block - e2.printStackTrace(); - } - - visualUpdate(TestHandler.ACTION_PDISPLAY, "Testing getInode"); - visualUpdate(TestHandler.ACTION_DISPLAY, "[ Checking Inodes ]\n"); - visualUpdate(TestHandler.ACTION_DISPLAY, RootTools.getInode("/system/bin/busybox") + " k\n\n"); - - visualUpdate(TestHandler.ACTION_PDISPLAY, "Testing GetBusyBoxapplets"); - try { - - visualUpdate(TestHandler.ACTION_DISPLAY, "[ Getting all available Busybox applets ]\n"); - for (String applet : RootTools.getBusyBoxApplets("/data/data/stericson.busybox/files/bb/busybox")) { - visualUpdate(TestHandler.ACTION_DISPLAY, applet + " k\n\n"); - } - - } catch (Exception e1) { - // TODO Auto-generated catch block - e1.printStackTrace(); - } - - visualUpdate(TestHandler.ACTION_PDISPLAY, "Testing GetBusyBox version in a special directory!"); - try { - - visualUpdate(TestHandler.ACTION_DISPLAY, "[ Testing GetBusyBox version in a special directory! ]\n"); - String v = RootTools.getBusyBoxVersion("/data/data/stericson.busybox/files/bb/"); - - visualUpdate(TestHandler.ACTION_DISPLAY, v + " k\n\n"); - - } catch (Exception e1) { - // TODO Auto-generated catch block - e1.printStackTrace(); - } - - visualUpdate(TestHandler.ACTION_PDISPLAY, "Testing getFilePermissionsSymlinks"); - Permissions permissions = RootTools.getFilePermissionsSymlinks("/system/xbin/busybox"); - visualUpdate(TestHandler.ACTION_DISPLAY, "[ Checking busybox permissions and symlink ]\n"); - - if (permissions != null) { - visualUpdate(TestHandler.ACTION_DISPLAY, "Symlink: " + permissions.getSymlink() + " k\n\n"); - visualUpdate(TestHandler.ACTION_DISPLAY, "Group Permissions: " + permissions.getGroupPermissions() + " k\n\n"); - visualUpdate(TestHandler.ACTION_DISPLAY, "Owner Permissions: " + permissions.getOtherPermissions() + " k\n\n"); - visualUpdate(TestHandler.ACTION_DISPLAY, "Permissions: " + permissions.getPermissions() + " k\n\n"); - visualUpdate(TestHandler.ACTION_DISPLAY, "Type: " + permissions.getType() + " k\n\n"); - visualUpdate(TestHandler.ACTION_DISPLAY, "User Permissions: " + permissions.getUserPermissions() + " k\n\n"); - } else { - visualUpdate(TestHandler.ACTION_DISPLAY, "Permissions == null k\n\n"); - } - - Shell shell; - - visualUpdate(TestHandler.ACTION_PDISPLAY, "Testing output capture"); - visualUpdate(TestHandler.ACTION_DISPLAY, "[ busybox ash --help ]\n"); - - try { - shell = RootTools.getShell(true); - Command cmd = new Command( - 0, - "busybox ash --help") { - - @Override - public void commandOutput(int id, String line) { - visualUpdate(TestHandler.ACTION_DISPLAY, line + "\n"); - super.commandOutput(id, line); - } - }; - shell.add(cmd); - - visualUpdate(TestHandler.ACTION_PDISPLAY, "getevent - /dev/input/event0"); - visualUpdate(TestHandler.ACTION_DISPLAY, "[ getevent - /dev/input/event0 ]\n"); - - cmd = new Command(0, 0, "getevent /dev/input/event0") { - @Override - public void commandOutput(int id, String line) { - visualUpdate(TestHandler.ACTION_DISPLAY, line + "\n"); - super.commandOutput(id, line); - } - - }; - shell.add(cmd); - - } catch (Exception e) { - e.printStackTrace(); - } - - visualUpdate(TestHandler.ACTION_PDISPLAY, "Switching RootContext - SYSTEM_APP"); - visualUpdate(TestHandler.ACTION_DISPLAY, "[ Switching Root Context - SYSTEM_APP ]\n"); - - try { - shell = RootTools.getShell(true, Shell.ShellContext.SYSTEM_APP); - Command cmd = new Command( - 0, - "id") { - - @Override - public void commandOutput(int id, String line) { - visualUpdate(TestHandler.ACTION_DISPLAY, line + "\n"); - super.commandOutput(id, line); - } - }; - shell.add(cmd); - - visualUpdate(TestHandler.ACTION_PDISPLAY, "Testing PM"); - visualUpdate(TestHandler.ACTION_DISPLAY, "[ Testing pm list packages -d ]\n"); - - cmd = new Command( - 0, - "sh /system/bin/pm list packages -d") { - - @Override - public void commandOutput(int id, String line) { - visualUpdate(TestHandler.ACTION_DISPLAY, line + "\n"); - super.commandOutput(id, line); - } - }; - shell.add(cmd); - - } catch (Exception e) { - e.printStackTrace(); - } - - visualUpdate(TestHandler.ACTION_PDISPLAY, "Switching RootContext - UNTRUSTED"); - visualUpdate(TestHandler.ACTION_DISPLAY, "[ Switching Root Context - UNTRUSTED ]\n"); - - try { - shell = RootTools.getShell(true, Shell.ShellContext.UNTRUSTED_APP); - Command cmd = new Command( - 0, - "id") { - - @Override - public void commandOutput(int id, String line) { - visualUpdate(TestHandler.ACTION_DISPLAY, line + "\n"); - super.commandOutput(id, line); - } - }; - shell.add(cmd); - - } catch (Exception e) { - e.printStackTrace(); - } - - visualUpdate(TestHandler.ACTION_PDISPLAY, "Testing df"); - long spaceValue = RootTools.getSpace("/data"); - visualUpdate(TestHandler.ACTION_DISPLAY, "[ Checking /data partition size]\n"); - visualUpdate(TestHandler.ACTION_DISPLAY, spaceValue + "k\n\n"); - - try { - shell = RootTools.getShell(true); - - Command cmd = new Command(42, false, "echo done") { - - boolean _catch = false; - - @Override - public void commandOutput(int id, String line) { - if (_catch) { - RootTools.log("CAUGHT!!!"); - } - - super.commandOutput(id, line); - - } - - @Override - public void commandTerminated(int id, String reason) { - synchronized (SanityCheckRootTools.this) { - - _catch = true; - visualUpdate(TestHandler.ACTION_PDISPLAY, "All tests complete."); - visualUpdate(TestHandler.ACTION_HIDE, null); - - try { - RootTools.closeAllShells(); - } catch (IOException e) { - // TODO Auto-generated catch block - e.printStackTrace(); - } - - } - } - - @Override - public void commandCompleted(int id, int exitCode) { - synchronized (SanityCheckRootTools.this) { - _catch = true; - - visualUpdate(TestHandler.ACTION_PDISPLAY, "All tests complete."); - visualUpdate(TestHandler.ACTION_HIDE, null); - - try { - RootTools.closeAllShells(); - } catch (IOException e) { - // TODO Auto-generated catch block - e.printStackTrace(); - } - - } - } - }; - - shell.add(cmd); - - } catch (Exception e) { - e.printStackTrace(); - } - - } - - private void visualUpdate(int action, String text) { - Message msg = mHandler.obtainMessage(); - Bundle bundle = new Bundle(); - bundle.putInt(TestHandler.ACTION, action); - bundle.putString(TestHandler.TEXT, text); - msg.setData(bundle); - mHandler.sendMessage(msg); - } - } - - private class TestHandler extends Handler { - static final public String ACTION = "action"; - static final public int ACTION_SHOW = 0x01; - static final public int ACTION_HIDE = 0x02; - static final public int ACTION_DISPLAY = 0x03; - static final public int ACTION_PDISPLAY = 0x04; - static final public String TEXT = "text"; - - public void handleMessage(Message msg) { - int action = msg.getData().getInt(ACTION); - String text = msg.getData().getString(TEXT); - - switch (action) { - case ACTION_SHOW: - mPDialog.show(); - mPDialog.setMessage("Running Root Library Tests..."); - break; - case ACTION_HIDE: - if (null != text) { - print(text); - } - mPDialog.hide(); - break; - case ACTION_DISPLAY: - print(text); - break; - case ACTION_PDISPLAY: - mPDialog.setMessage(text); - break; - } - } - } -} diff --git a/app/src/main/java/com/stericson/roottools/containers/Mount.java b/app/src/main/java/com/stericson/roottools/containers/Mount.java deleted file mode 100644 index ce455f2d4..000000000 --- a/app/src/main/java/com/stericson/roottools/containers/Mount.java +++ /dev/null @@ -1,70 +0,0 @@ -/* - * This file is part of the RootTools Project: http://code.google.com/p/RootTools/ - * - * Copyright (c) 2012 Stephen Erickson, Chris Ravenscroft, Dominik Schuermann, Adam Shanks - * - * This code is dual-licensed under the terms of the Apache License Version 2.0 and - * the terms of the General Public License (GPL) Version 2. - * You may use this code according to either of these licenses as is most appropriate - * for your project on a case-by-case basis. - * - * The terms of each license can be found in the root directory of this project's repository as well as at: - * - * * http://www.apache.org/licenses/LICENSE-2.0 - * * http://www.gnu.org/licenses/gpl-2.0.txt - * - * Unless required by applicable law or agreed to in writing, software - * distributed under these Licenses is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See each License for the specific language governing permissions and - * limitations under that License. - */ - -package com.stericson.roottools.containers; - -import java.io.File; -import java.util.Arrays; -import java.util.LinkedHashSet; -import java.util.Set; - -public class Mount -{ - final File mDevice; - final File mMountPoint; - final String mType; - final Set mFlags; - - public Mount(File device, File path, String type, String flagsStr) - { - mDevice = device; - mMountPoint = path; - mType = type; - mFlags = new LinkedHashSet(Arrays.asList(flagsStr.split(","))); - } - - public File getDevice() - { - return mDevice; - } - - public File getMountPoint() - { - return mMountPoint; - } - - public String getType() - { - return mType; - } - - public Set getFlags() - { - return mFlags; - } - - @Override - public String toString() - { - return String.format("%s on %s type %s %s", mDevice, mMountPoint, mType, mFlags); - } -} diff --git a/app/src/main/java/com/stericson/roottools/containers/Permissions.java b/app/src/main/java/com/stericson/roottools/containers/Permissions.java deleted file mode 100644 index 51aae5f0d..000000000 --- a/app/src/main/java/com/stericson/roottools/containers/Permissions.java +++ /dev/null @@ -1,125 +0,0 @@ -/* - * This file is part of the RootTools Project: http://code.google.com/p/RootTools/ - * - * Copyright (c) 2012 Stephen Erickson, Chris Ravenscroft, Dominik Schuermann, Adam Shanks - * - * This code is dual-licensed under the terms of the Apache License Version 2.0 and - * the terms of the General Public License (GPL) Version 2. - * You may use this code according to either of these licenses as is most appropriate - * for your project on a case-by-case basis. - * - * The terms of each license can be found in the root directory of this project's repository as well as at: - * - * * http://www.apache.org/licenses/LICENSE-2.0 - * * http://www.gnu.org/licenses/gpl-2.0.txt - * - * Unless required by applicable law or agreed to in writing, software - * distributed under these Licenses is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See each License for the specific language governing permissions and - * limitations under that License. - */ - -package com.stericson.roottools.containers; - -public class Permissions -{ - String type; - String user; - String group; - String other; - String symlink; - int permissions; - - public String getSymlink() - { - return this.symlink; - } - - public String getType() - { - return type; - } - - public int getPermissions() - { - return this.permissions; - } - - public String getUserPermissions() - { - return this.user; - } - - public String getGroupPermissions() - { - return this.group; - } - - public String getOtherPermissions() - { - return this.other; - } - - public void setSymlink(String symlink) - { - this.symlink = symlink; - } - - public void setType(String type) - { - this.type = type; - } - - public void setPermissions(int permissions) - { - this.permissions = permissions; - } - - public void setUserPermissions(String user) - { - this.user = user; - } - - public void setGroupPermissions(String group) - { - this.group = group; - } - - public void setOtherPermissions(String other) - { - this.other = other; - } - - public String getUser() - { - return user; - } - - public void setUser(String user) - { - this.user = user; - } - - public String getGroup() - { - return group; - } - - public void setGroup(String group) - { - this.group = group; - } - - public String getOther() - { - return other; - } - - public void setOther(String other) - { - this.other = other; - } - - -} diff --git a/app/src/main/java/com/stericson/roottools/containers/Symlink.java b/app/src/main/java/com/stericson/roottools/containers/Symlink.java deleted file mode 100644 index b811b1a59..000000000 --- a/app/src/main/java/com/stericson/roottools/containers/Symlink.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * This file is part of the RootTools Project: http://code.google.com/p/RootTools/ - * - * Copyright (c) 2012 Stephen Erickson, Chris Ravenscroft, Dominik Schuermann, Adam Shanks - * - * This code is dual-licensed under the terms of the Apache License Version 2.0 and - * the terms of the General Public License (GPL) Version 2. - * You may use this code according to either of these licenses as is most appropriate - * for your project on a case-by-case basis. - * - * The terms of each license can be found in the root directory of this project's repository as well as at: - * - * * http://www.apache.org/licenses/LICENSE-2.0 - * * http://www.gnu.org/licenses/gpl-2.0.txt - * - * Unless required by applicable law or agreed to in writing, software - * distributed under these Licenses is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See each License for the specific language governing permissions and - * limitations under that License. - */ - -package com.stericson.roottools.containers; - -import java.io.File; - -public class Symlink -{ - protected final File file; - protected final File symlinkPath; - - public Symlink(File file, File path) - { - this.file = file; - symlinkPath = path; - } - - public File getFile() - { - return this.file; - } - - public File getSymlinkPath() - { - return symlinkPath; - } -} diff --git a/app/src/main/java/com/stericson/roottools/internal/Installer.java b/app/src/main/java/com/stericson/roottools/internal/Installer.java deleted file mode 100644 index 54d57ae70..000000000 --- a/app/src/main/java/com/stericson/roottools/internal/Installer.java +++ /dev/null @@ -1,300 +0,0 @@ -/* - * This file is part of the RootTools Project: http://code.google.com/p/RootTools/ - * - * Copyright (c) 2012 Stephen Erickson, Chris Ravenscroft, Dominik Schuermann, Adam Shanks - * - * This code is dual-licensed under the terms of the Apache License Version 2.0 and - * the terms of the General Public License (GPL) Version 2. - * You may use this code according to either of these licenses as is most appropriate - * for your project on a case-by-case basis. - * - * The terms of each license can be found in the root directory of this project's repository as well as at: - * - * * http://www.apache.org/licenses/LICENSE-2.0 - * * http://www.gnu.org/licenses/gpl-2.0.txt - * - * Unless required by applicable law or agreed to in writing, software - * distributed under these Licenses is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See each License for the specific language governing permissions and - * limitations under that License. - */ - -package com.stericson.roottools.internal; - -import android.content.Context; -import android.util.Log; - -import com.stericson.rootshell.execution.Command; -import com.stericson.rootshell.execution.Shell; -import com.stericson.roottools.RootTools; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.nio.channels.Channels; -import java.nio.channels.FileChannel; -import java.nio.channels.ReadableByteChannel; -import java.security.DigestInputStream; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; - -class Installer -{ - - //------------- - //# Installer # - //------------- - - static final String LOG_TAG = "RootTools::Installer"; - - static final String BOGUS_FILE_NAME = "bogus"; - - Context context; - String filesPath; - - public Installer(Context context) - throws IOException - { - - this.context = context; - this.filesPath = context.getFilesDir().getCanonicalPath(); - } - - /** - * This method can be used to unpack a binary from the raw resources folder and store it in - * /data/data/app.package/files/ - * This is typically useful if you provide your own C- or C++-based binary. - * This binary can then be executed using sendShell() and its full path. - * - * @param sourceId resource id; typically R.raw.id - * @param destName destination file name; appended to /data/data/app.package/files/ - * @param mode chmod value for this file - * @return a boolean which indicates whether or not we were - * able to create the new file. - */ - protected boolean installBinary(int sourceId, String destName, String mode) - { - File mf = new File(filesPath + File.separator + destName); - if (!mf.exists() || - !getFileSignature(mf).equals( - getStreamSignature( - context.getResources().openRawResource(sourceId)) - )) - { - Log.e(LOG_TAG, "Installing a new version of binary: " + destName); - // First, does our files/ directory even exist? - // We cannot wait for android to lazily create it as we will soon - // need it. - try - { - FileInputStream fis = context.openFileInput(BOGUS_FILE_NAME); - fis.close(); - } - catch (FileNotFoundException e) - { - FileOutputStream fos = null; - try - { - fos = context.openFileOutput("bogus", Context.MODE_PRIVATE); - fos.write("justcreatedfilesdirectory".getBytes()); - } - catch (Exception ex) - { - if (RootTools.debugMode) - { - Log.e(LOG_TAG, ex.toString()); - } - return false; - } - finally - { - if (null != fos) - { - try - { - fos.close(); - context.deleteFile(BOGUS_FILE_NAME); - } - catch (IOException e1) - { - } - } - } - } - catch (IOException ex) - { - if (RootTools.debugMode) - { - Log.e(LOG_TAG, ex.toString()); - } - return false; - } - - // Only now can we start creating our actual file - InputStream iss = context.getResources().openRawResource(sourceId); - ReadableByteChannel rfc = Channels.newChannel(iss); - FileOutputStream oss = null; - try - { - oss = new FileOutputStream(mf); - FileChannel ofc = oss.getChannel(); - long pos = 0; - try - { - long size = iss.available(); - while ((pos += ofc.transferFrom(rfc, pos, size - pos)) < size) - { - } - } - catch (IOException ex) - { - if (RootTools.debugMode) - { - Log.e(LOG_TAG, ex.toString()); - } - return false; - } - } - catch (FileNotFoundException ex) - { - if (RootTools.debugMode) - { - Log.e(LOG_TAG, ex.toString()); - } - return false; - } - finally - { - if (oss != null) - { - try - { - oss.flush(); - oss.getFD().sync(); - oss.close(); - } - catch (Exception e) - { - } - } - } - try - { - iss.close(); - } - catch (IOException ex) - { - if (RootTools.debugMode) - { - Log.e(LOG_TAG, ex.toString()); - } - return false; - } - - try - { - Command command = new Command(0, false, "chmod " + mode + " " + filesPath + File.separator + destName); - Shell.startRootShell().add(command); - commandWait(command); - - } - catch (Exception e) - { - } - } - return true; - } - - protected boolean isBinaryInstalled(String destName) - { - boolean installed = false; - File mf = new File(filesPath + File.separator + destName); - if (mf.exists()) - { - installed = true; - // TODO: pass mode as argument and check it matches - } - return installed; - } - - protected String getFileSignature(File f) - { - String signature = ""; - try - { - signature = getStreamSignature(new FileInputStream(f)); - } - catch (FileNotFoundException ex) - { - Log.e(LOG_TAG, ex.toString()); - } - return signature; - } - - /* - * Note: this method will close any string passed to it - */ - protected String getStreamSignature(InputStream is) - { - String signature = ""; - try - { - MessageDigest md = MessageDigest.getInstance("MD5"); - DigestInputStream dis = new DigestInputStream(is, md); - byte[] buffer = new byte[4096]; - while (-1 != dis.read(buffer)) - { - } - byte[] digest = md.digest(); - StringBuffer sb = new StringBuffer(); - - for (int i = 0; i < digest.length; i++) - { - sb.append(Integer.toHexString(digest[i] & 0xFF)); - } - - signature = sb.toString(); - } - catch (IOException ex) - { - Log.e(LOG_TAG, ex.toString()); - } - catch (NoSuchAlgorithmException ex) - { - Log.e(LOG_TAG, ex.toString()); - } - finally - { - try - { - is.close(); - } - catch (IOException e) - { - } - } - return signature; - } - - private void commandWait(Command cmd) - { - synchronized (cmd) - { - try - { - if (!cmd.isFinished()) - { - cmd.wait(2000); - } - } - catch (InterruptedException ex) - { - Log.e(LOG_TAG, ex.toString()); - } - } - } -} diff --git a/app/src/main/java/com/stericson/roottools/internal/InternalVariables.java b/app/src/main/java/com/stericson/roottools/internal/InternalVariables.java deleted file mode 100644 index 6b52e20bd..000000000 --- a/app/src/main/java/com/stericson/roottools/internal/InternalVariables.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * This file is part of the RootTools Project: http://code.google.com/p/RootTools/ - * - * Copyright (c) 2012 Stephen Erickson, Chris Ravenscroft, Dominik Schuermann, Adam Shanks - * - * This code is dual-licensed under the terms of the Apache License Version 2.0 and - * the terms of the General Public License (GPL) Version 2. - * You may use this code according to either of these licenses as is most appropriate - * for your project on a case-by-case basis. - * - * The terms of each license can be found in the root directory of this project's repository as well as at: - * - * * http://www.apache.org/licenses/LICENSE-2.0 - * * http://www.gnu.org/licenses/gpl-2.0.txt - * - * Unless required by applicable law or agreed to in writing, software - * distributed under these Licenses is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See each License for the specific language governing permissions and - * limitations under that License. - */ - -package com.stericson.roottools.internal; - -import com.stericson.roottools.containers.Mount; -import com.stericson.roottools.containers.Permissions; -import com.stericson.roottools.containers.Symlink; - -import java.util.ArrayList; -import java.util.regex.Pattern; - -public class InternalVariables -{ - - // ---------------------- - // # Internal Variables # - // ---------------------- - - - protected static boolean nativeToolsReady = false; - protected static boolean found = false; - protected static boolean processRunning = false; - - protected static String[] space; - protected static String getSpaceFor; - protected static String busyboxVersion; - protected static String pid_list = ""; - protected static ArrayList mounts; - protected static ArrayList symlinks; - protected static String inode = ""; - protected static Permissions permissions; - - // regex to get pid out of ps line, example: - // root 2611 0.0 0.0 19408 2104 pts/2 S 13:41 0:00 bash - protected static final String PS_REGEX = "^\\S+\\s+([0-9]+).*$"; - protected static Pattern psPattern; - - static - { - psPattern = Pattern.compile(PS_REGEX); - } -} diff --git a/app/src/main/java/com/stericson/roottools/internal/Remounter.java b/app/src/main/java/com/stericson/roottools/internal/Remounter.java deleted file mode 100644 index f054962a8..000000000 --- a/app/src/main/java/com/stericson/roottools/internal/Remounter.java +++ /dev/null @@ -1,238 +0,0 @@ -/* - * This file is part of the RootTools Project: http://code.google.com/p/RootTools/ - * - * Copyright (c) 2012 Stephen Erickson, Chris Ravenscroft, Dominik Schuermann, Adam Shanks - * - * This code is dual-licensed under the terms of the Apache License Version 2.0 and - * the terms of the General Public License (GPL) Version 2. - * You may use this code according to either of these licenses as is most appropriate - * for your project on a case-by-case basis. - * - * The terms of each license can be found in the root directory of this project's repository as well as at: - * - * * http://www.apache.org/licenses/LICENSE-2.0 - * * http://www.gnu.org/licenses/gpl-2.0.txt - * - * Unless required by applicable law or agreed to in writing, software - * distributed under these Licenses is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See each License for the specific language governing permissions and - * limitations under that License. - */ - -package com.stericson.roottools.internal; - -import com.stericson.rootshell.execution.Command; -import com.stericson.rootshell.execution.Shell; -import com.stericson.roottools.Constants; -import com.stericson.roottools.RootTools; -import com.stericson.roottools.containers.Mount; - -import java.io.File; -import java.io.IOException; -import java.util.ArrayList; - -import dev.ukanth.ufirewall.Api; -import dev.ukanth.ufirewall.log.Log; - - -public class Remounter -{ - - private String customPath; - - public Remounter() { - } - - public Remounter(String path) { - this.customPath = path; - } - //------------- - //# Remounter # - //------------- - - /** - * This will take a path, which can contain the file name as well, - * and attempt to remount the underlying partition. - *

- * For example, passing in the following string: - * "/system/bin/some/directory/that/really/would/never/exist" - * will result in /system ultimately being remounted. - * However, keep in mind that the longer the path you supply, the more work this has to do, - * and the slower it will run. - * - * @param file file path - * @param mountType mount type: pass in RO (Read only) or RW (Read Write) - * @return a boolean which indicates whether or not the partition - * has been remounted as specified. - */ - public boolean remount(String file, String mountType) - { - //if the path has a trailing slash get rid of it. - if (file.endsWith("/") && !file.equals("/")) - { - file = file.substring(0, file.lastIndexOf("/")); - } - //Make sure that what we are trying to remount is in the mount list. - boolean foundMount = false; - while (!foundMount) - { - try - { - for (Mount mount : RootTools.getMounts()) - { - RootTools.log(mount.getMountPoint().toString()); - - if (file.equals(mount.getMountPoint().toString())) - { - foundMount = true; - break; - } - } - } - catch (Exception e) - { - if (RootTools.debugMode) - { - Log.d(Api.TAG, e.getMessage(), e); - } - return false; - } - if (!foundMount) - { - try - { - file = (new File(file).getParent()); - } - catch (Exception e) - { - Log.e(Api.TAG, e.getMessage(), e); - return false; - } - } - } - - Mount mountPoint = findMountPointRecursive(file); - - if (mountPoint != null) - { - - RootTools.log(Constants.TAG, "Remounting " + mountPoint.getMountPoint().getAbsolutePath() + " as " + mountType.toLowerCase()); - final boolean isMountMode = mountPoint.getFlags().contains(mountType.toLowerCase()); - - if (!isMountMode) - { - //grab an instance of the internal class - try - { - Command command = new Command(0, - true, - "busybox mount -o remount," + mountType.toLowerCase() + " " + mountPoint.getDevice().getAbsolutePath() + " " + mountPoint.getMountPoint().getAbsolutePath(), - "toolbox mount -o remount," + mountType.toLowerCase() + " " + mountPoint.getDevice().getAbsolutePath() + " " + mountPoint.getMountPoint().getAbsolutePath(), - "toybox mount -o remount," + mountType.toLowerCase() + " " + mountPoint.getDevice().getAbsolutePath() + " " + mountPoint.getMountPoint().getAbsolutePath(), - "mount -o remount," + mountType.toLowerCase() + " " + mountPoint.getDevice().getAbsolutePath() + " " + mountPoint.getMountPoint().getAbsolutePath(), - "mount -o remount," + mountType.toLowerCase() + " " + file, - "/system/bin/toolbox mount -o remount," + mountType.toLowerCase() + " " + mountPoint.getDevice().getAbsolutePath() + " " + mountPoint.getMountPoint().getAbsolutePath(), - "/system/bin/toybox mount -o remount," + mountType.toLowerCase() + " " + mountPoint.getDevice().getAbsolutePath() + " " + mountPoint.getMountPoint().getAbsolutePath() - ); - Shell.startRootShell().add(command); - commandWait(command); - - if(customPath != null) { - command = new Command(0, - true, - customPath + " mount -o remount," + mountType.toLowerCase() + " " + mountPoint.getDevice().getAbsolutePath() + " " + mountPoint.getMountPoint().getAbsolutePath()); - Shell.startRootShell().add(command); - commandWait(command); - } - } - catch (Exception e) - { - } - - mountPoint = findMountPointRecursive(file); - } - - if (mountPoint != null) - { - RootTools.log(Constants.TAG, mountPoint.getFlags() + " AND " + mountType.toLowerCase()); - if (mountPoint.getFlags().contains(mountType.toLowerCase())) - { - RootTools.log(mountPoint.getFlags().toString()); - return true; - } - else - { - RootTools.log(mountPoint.getFlags().toString()); - return false; - } - } - else - { - RootTools.log("mount is null, file was: " + file + " mountType was: " + mountType); - } - } - else - { - RootTools.log("mount is null, file was: " + file + " mountType was: " + mountType); - } - - return false; - } - - private Mount findMountPointRecursive(String file) - { - try - { - ArrayList mounts = RootTools.getMounts(); - - for (File path = new File(file); path != null; ) - { - for (Mount mount : mounts) - { - if (mount.getMountPoint().equals(path)) - { - return mount; - } - } - } - - return null; - - } - catch (IOException e) - { - if (RootTools.debugMode) - { - e.printStackTrace(); - } - } - catch (Exception e) - { - if (RootTools.debugMode) - { - e.printStackTrace(); - } - } - - return null; - } - - private void commandWait(Command cmd) - { - synchronized (cmd) - { - try - { - if (!cmd.isFinished()) - { - cmd.wait(2000); - } - } - catch (InterruptedException e) - { - e.printStackTrace(); - } - } - } -} diff --git a/app/src/main/java/com/stericson/roottools/internal/RootToolsInternalMethods.java b/app/src/main/java/com/stericson/roottools/internal/RootToolsInternalMethods.java deleted file mode 100644 index f5b825778..000000000 --- a/app/src/main/java/com/stericson/roottools/internal/RootToolsInternalMethods.java +++ /dev/null @@ -1,1342 +0,0 @@ -/* - * This file is part of the RootTools Project: http://code.google.com/p/RootTools/ - * - * Copyright (c) 2012 Stephen Erickson, Chris Ravenscroft, Dominik Schuermann, Adam Shanks - * - * This code is dual-licensed under the terms of the Apache License Version 2.0 and - * the terms of the General Public License (GPL) Version 2. - * You may use this code according to either of these licenses as is most appropriate - * for your project on a case-by-case basis. - * - * The terms of each license can be found in the root directory of this project's repository as well as at: - * - * * http://www.apache.org/licenses/LICENSE-2.0 - * * http://www.gnu.org/licenses/gpl-2.0.txt - * - * Unless required by applicable law or agreed to in writing, software - * distributed under these Licenses is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See each License for the specific language governing permissions and - * limitations under that License. - */ - -package com.stericson.roottools.internal; - -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.net.Uri; -import android.os.Build; -import android.os.Environment; -import android.os.StatFs; -import android.util.Log; - -import com.stericson.rootshell.RootShell; -import com.stericson.rootshell.execution.Command; -import com.stericson.rootshell.execution.Shell; -import com.stericson.roottools.Constants; -import com.stericson.roottools.RootTools; -import com.stericson.roottools.containers.Mount; -import com.stericson.roottools.containers.Permissions; -import com.stericson.roottools.containers.Symlink; - -import java.io.File; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; -import java.util.concurrent.TimeoutException; -import java.util.regex.Matcher; - -public final class RootToolsInternalMethods { - - // -------------------- - // # Internal methods # - // -------------------- - - protected RootToolsInternalMethods() { - } - - public static void getInstance() { - //this will allow RootTools to be the only one to get an instance of this class. - RootTools.setRim(new RootToolsInternalMethods()); - } - - public Permissions getPermissions(String line) { - - String[] lineArray = line.split(" "); - String rawPermissions = lineArray[0]; - - if (rawPermissions.length() == 10 - && (rawPermissions.charAt(0) == '-' - || rawPermissions.charAt(0) == 'd' || rawPermissions - .charAt(0) == 'l') - && (rawPermissions.charAt(1) == '-' || rawPermissions.charAt(1) == 'r') - && (rawPermissions.charAt(2) == '-' || rawPermissions.charAt(2) == 'w')) { - RootTools.log(rawPermissions); - - Permissions permissions = new Permissions(); - - permissions.setType(rawPermissions.substring(0, 1)); - - RootTools.log(permissions.getType()); - - permissions.setUserPermissions(rawPermissions.substring(1, 4)); - - RootTools.log(permissions.getUserPermissions()); - - permissions.setGroupPermissions(rawPermissions.substring(4, 7)); - - RootTools.log(permissions.getGroupPermissions()); - - permissions.setOtherPermissions(rawPermissions.substring(7, 10)); - - RootTools.log(permissions.getOtherPermissions()); - - StringBuilder finalPermissions = new StringBuilder(); - finalPermissions.append(parseSpecialPermissions(rawPermissions)); - finalPermissions.append(parsePermissions(permissions.getUserPermissions())); - finalPermissions.append(parsePermissions(permissions.getGroupPermissions())); - finalPermissions.append(parsePermissions(permissions.getOtherPermissions())); - - permissions.setPermissions(Integer.parseInt(finalPermissions.toString())); - - return permissions; - } - - return null; - } - - public int parsePermissions(String permission) { - permission = permission.toLowerCase(Locale.US); - int tmp; - if (permission.charAt(0) == 'r') { - tmp = 4; - } else { - tmp = 0; - } - - RootTools.log("permission " + tmp); - RootTools.log("character " + permission.charAt(0)); - - if (permission.charAt(1) == 'w') { - tmp += 2; - } else { - tmp += 0; - } - - RootTools.log("permission " + tmp); - RootTools.log("character " + permission.charAt(1)); - - if (permission.charAt(2) == 'x' || permission.charAt(2) == 's' - || permission.charAt(2) == 't') { - tmp += 1; - } else { - tmp += 0; - } - - RootTools.log("permission " + tmp); - RootTools.log("character " + permission.charAt(2)); - - return tmp; - } - - public int parseSpecialPermissions(String permission) { - int tmp = 0; - if (permission.charAt(2) == 's') { - tmp += 4; - } - - if (permission.charAt(5) == 's') { - tmp += 2; - } - - if (permission.charAt(8) == 't') { - tmp += 1; - } - - RootTools.log("special permissions " + tmp); - - return tmp; - } - - /** - * Copys a file to a destination. Because cp is not available on all android devices, we have a - * fallback on the cat command - * - * @param source example: /data/data/org.adaway/files/hosts - * @param destination example: /system/etc/hosts - * @param remountAsRw remounts the destination as read/write before writing to it - * @param preserveFileAttributes tries to copy file attributes from source to destination, if only cat is available - * only permissions are preserved - * @return true if it was successfully copied - */ - public boolean copyFile(String source, String destination, boolean remountAsRw, - boolean preserveFileAttributes) { - - Command command = null; - boolean result = true; - - try { - // mount destination as rw before writing to it - if (remountAsRw) { - RootTools.remount(destination, "RW"); - } - - // if cp is available and has appropriate permissions - if (checkUtil("cp")) { - RootTools.log("cp command is available!"); - - if (preserveFileAttributes) { - command = new Command(0, false, "cp -fp " + source + " " + destination); - Shell.startRootShell().add(command); - commandWait(Shell.startRootShell(), command); - - //ensure that the file was copied, an exitcode of zero means success - result = command.getExitCode() == 0; - - } else { - command = new Command(0, false, "cp -f " + source + " " + destination); - Shell.startRootShell().add(command); - commandWait(Shell.startRootShell(), command); - - //ensure that the file was copied, an exitcode of zero means success - result = command.getExitCode() == 0; - - } - } else { - if (checkUtil("busybox") && hasUtil("cp", "busybox")) { - RootTools.log("busybox cp command is available!"); - - if (preserveFileAttributes) { - command = new Command(0, false, "busybox cp -fp " + source + " " + destination); - Shell.startRootShell().add(command); - commandWait(Shell.startRootShell(), command); - - } else { - command = new Command(0, false, "busybox cp -f " + source + " " + destination); - Shell.startRootShell().add(command); - commandWait(Shell.startRootShell(), command); - - } - } else { // if cp is not available use cat - // if cat is available and has appropriate permissions - if (checkUtil("cat")) { - RootTools.log("cp is not available, use cat!"); - - int filePermission = -1; - if (preserveFileAttributes) { - // get permissions of source before overwriting - Permissions permissions = getFilePermissionsSymlinks(source); - filePermission = permissions.getPermissions(); - } - - // copy with cat - command = new Command(0, false, "cat " + source + " > " + destination); - Shell.startRootShell().add(command); - commandWait(Shell.startRootShell(), command); - - if (preserveFileAttributes) { - // set premissions of source to destination - command = new Command(0, false, "chmod " + filePermission + " " + destination); - Shell.startRootShell().add(command); - commandWait(Shell.startRootShell(), command); - } - } else { - result = false; - } - } - } - - // mount destination back to ro - if (remountAsRw) { - RootTools.remount(destination, "RO"); - } - } catch (Exception e) { - e.printStackTrace(); - result = false; - } - - if (command != null) { - //ensure that the file was copied, an exitcode of zero means success - result = command.getExitCode() == 0; - } - - return result; - } - - /** - * This will check a given binary, determine if it exists and determine that - * it has either the permissions 755, 775, or 777. - * - * @param util Name of the utility to check. - * @return boolean to indicate whether the binary is installed and has - * appropriate permissions. - */ - public boolean checkUtil(String util) { - List foundPaths = RootShell.findBinary(util, true); - if (foundPaths.size() > 0) { - - for (String path : foundPaths) { - Permissions permissions = RootTools - .getFilePermissionsSymlinks(path + "/" + util); - - if (permissions != null) { - String permission; - - if (Integer.toString(permissions.getPermissions()).length() > 3) { - permission = Integer.toString(permissions.getPermissions()).substring(1); - } else { - permission = Integer.toString(permissions.getPermissions()); - } - - if (permission.equals("755") || permission.equals("777") - || permission.equals("775")) { - RootTools.utilPath = path + "/" + util; - return true; - } - } - } - } - - return false; - - } - - /** - * Deletes a file or directory - * - * @param target example: /data/data/org.adaway/files/hosts - * @param remountAsRw remounts the destination as read/write before writing to it - * @return true if it was successfully deleted - */ - public boolean deleteFileOrDirectory(String target, boolean remountAsRw) { - boolean result = true; - - try { - // mount destination as rw before writing to it - if (remountAsRw) { - RootTools.remount(target, "RW"); - } - - if (hasUtil("rm", "toolbox")) { - RootTools.log("rm command is available!"); - - Command command = new Command(0, false, "rm -r " + target); - Shell.startRootShell().add(command); - commandWait(Shell.startRootShell(), command); - - if (command.getExitCode() != 0) { - RootTools.log("target not exist or unable to delete file"); - result = false; - } - } else { - if (checkUtil("busybox") && hasUtil("rm", "busybox")) { - RootTools.log("busybox rm command is available!"); - - Command command = new Command(0, false, "busybox rm -rf " + target); - Shell.startRootShell().add(command); - commandWait(Shell.startRootShell(), command); - - if (command.getExitCode() != 0) { - RootTools.log("target not exist or unable to delete file"); - result = false; - } - } - } - - // mount destination back to ro - if (remountAsRw) { - RootTools.remount(target, "RO"); - } - } catch (Exception e) { - e.printStackTrace(); - result = false; - } - - return result; - } - - /** - * This will try and fix a given binary. (This is for Busybox applets or Toolbox applets) By - * "fix", I mean it will try and symlink the binary from either toolbox or Busybox and fix the - * permissions if the permissions are not correct. - * - * @param util Name of the utility to fix. - * @param utilPath path to the toolbox that provides ln, rm, and chmod. This can be a blank string, a - * path to a binary that will provide these, or you can use - * RootTools.getWorkingToolbox() - */ - public void fixUtil(String util, String utilPath) { - try { - RootTools.remount("/system", "rw"); - - List foundPaths = RootShell.findBinary(util, true); - - if (foundPaths.size() > 0) { - for (String path : foundPaths) { - Command command = new Command(0, false, utilPath + " rm " + path + "/" + util); - RootShell.getShell(true).add(command); - commandWait(RootShell.getShell(true), command); - - } - - Command command = new Command(0, false, utilPath + " ln -s " + utilPath + " /system/bin/" + util, utilPath + " chmod 0755 /system/bin/" + util); - RootShell.getShell(true).add(command); - commandWait(RootShell.getShell(true), command); - - } - - RootTools.remount("/system", "ro"); - } catch (Exception e) { - } - } - - /** - * This will check an array of binaries, determine if they exist and determine that it has - * either the permissions 755, 775, or 777. If an applet is not setup correctly it will try and - * fix it. (This is for Busybox applets or Toolbox applets) - * - * @param utils Name of the utility to check. - * @return boolean to indicate whether the operation completed. Note that this is not indicative - * of whether the problem was fixed, just that the method did not encounter any - * exceptions. - * @throws Exception if the operation cannot be completed. - */ - public boolean fixUtils(String[] utils) throws Exception { - - for (String util : utils) { - if (!checkUtil(util)) { - if (checkUtil("busybox")) { - if (hasUtil(util, "busybox")) { - fixUtil(util, RootTools.utilPath); - } - } else { - if (checkUtil("toolbox")) { - if (hasUtil(util, "toolbox")) { - fixUtil(util, RootTools.utilPath); - } - } else { - return false; - } - } - } - } - - return true; - } - - /** - * This will return an List of Strings. Each string represents an applet available from BusyBox. - *

- * - * @param path Path to the busybox binary that you want the list of applets from. - * @return null If we cannot return the list of applets. - */ - public List getBusyBoxApplets(String path) throws Exception { - - if (path != null && !path.endsWith("/") && !path.equals("")) { - path += "/"; - } else if (path == null) { - //Don't know what the user wants to do...what am I pshycic? - throw new Exception("Path is null, please specifiy a path"); - } - - final List results = new ArrayList(); - - Command command = new Command(Constants.BBA, false, path + "busybox --list") { - @Override - public void commandOutput(int id, String line) { - if (id == Constants.BBA) { - if (!line.trim().equals("") && !line.trim().contains("not found") && !line.trim().contains("file busy")) { - results.add(line); - } - } - - super.commandOutput(id, line); - } - }; - - //try without root first... - RootShell.getShell(false).add(command); - commandWait(RootShell.getShell(false), command); - - if (results.size() <= 0) { - //try with root... - - command = new Command(Constants.BBA, false, path + "busybox --list") { - @Override - public void commandOutput(int id, String line) { - if (id == Constants.BBA) { - if (!line.trim().equals("") && !line.trim().contains("not found") && !line.trim().contains("file busy")) { - results.add(line); - } - } - - super.commandOutput(id, line); - } - }; - - RootShell.getShell(true).add(command); - commandWait(RootShell.getShell(true), command); - } - - return results; - } - - /** - * @return BusyBox version if found, "" if not found. - */ - public String getBusyBoxVersion(String path) { - - final StringBuilder version = new StringBuilder(); - - if (!path.equals("") && !path.endsWith("/")) { - path += "/"; - } - - try { - Command command = new Command(Constants.BBV, false, path + "busybox") { - @Override - public void commandOutput(int id, String line) { - line = line.trim(); - - boolean foundVersion = false; - - if (id == Constants.BBV) { - RootTools.log("Version Output: " + line); - - String[] temp = line.split(" "); - - if (temp.length > 1 && temp[1].contains("v1.") && !foundVersion) { - foundVersion = true; - version.append(temp[1]); - RootTools.log("Found Version: " + version.toString()); - } - } - - super.commandOutput(id, line); - } - }; - - //try without root first - RootTools.log("Getting BusyBox Version without root"); - Shell shell = RootTools.getShell(false); - shell.add(command); - commandWait(shell, command); - - if (version.length() <= 0) { - - command = new Command(Constants.BBV, false, path + "busybox") { - @Override - public void commandOutput(int id, String line) { - line = line.trim(); - - boolean foundVersion = false; - - if (id == Constants.BBV) { - RootTools.log("Version Output: " + line); - - String[] temp = line.split(" "); - - if (temp.length > 1 && temp[1].contains("v1.") && !foundVersion) { - foundVersion = true; - version.append(temp[1]); - RootTools.log("Found Version: " + version.toString()); - } - } - - super.commandOutput(id, line); - } - }; - - RootTools.log("Getting BusyBox Version with root"); - Shell rootShell = RootTools.getShell(true); - //Now look for it... - rootShell.add(command); - commandWait(rootShell, command); - } - - } catch (Exception e) { - RootTools.log("BusyBox was not found, more information MAY be available with Debugging on."); - return ""; - } - - RootTools.log("Returning found version: " + version.toString()); - return version.toString(); - } - - /** - * @return long Size, converted to kilobytes (from xxx or xxxm or xxxk etc.) - */ - public long getConvertedSpace(String spaceStr) { - try { - double multiplier = 1.0; - char c; - StringBuffer sb = new StringBuffer(); - for (int i = 0; i < spaceStr.length(); i++) { - c = spaceStr.charAt(i); - if (!Character.isDigit(c) && c != '.') { - if (c == 'm' || c == 'M') { - multiplier = 1024.0; - } else if (c == 'g' || c == 'G') { - multiplier = 1024.0 * 1024.0; - } - break; - } - sb.append(spaceStr.charAt(i)); - } - return (long) Math.ceil(Double.valueOf(sb.toString()) * multiplier); - } catch (Exception e) { - return -1; - } - } - - /** - * This method will return the inode number of a file. This method is dependent on having a version of - * ls that supports the -i parameter. - * - * @param file path to the file that you wish to return the inode number - * @return String The inode number for this file or "" if the inode number could not be found. - */ - public String getInode(String file) { - try { - Command command = new Command(Constants.GI, false, "/data/local/ls -i " + file) { - - @Override - public void commandOutput(int id, String line) { - if (id == Constants.GI) { - if (!line.trim().equals("") && Character.isDigit(line.trim().substring(0, 1).toCharArray()[0])) { - InternalVariables.inode = line.trim().split(" ")[0]; - } - } - - super.commandOutput(id, line); - } - }; - Shell.startRootShell().add(command); - commandWait(Shell.startRootShell(), command); - - return InternalVariables.inode; - } catch (Exception ignore) { - return ""; - } - } - - public boolean isNativeToolsReady(int nativeToolsId, Context context) { - RootTools.log("Preparing Native Tools"); - InternalVariables.nativeToolsReady = false; - - Installer installer; - try { - installer = new Installer(context); - } catch (IOException ex) { - if (RootTools.debugMode) { - ex.printStackTrace(); - } - return false; - } - - if (installer.isBinaryInstalled("nativetools")) { - InternalVariables.nativeToolsReady = true; - } else { - InternalVariables.nativeToolsReady = installer.installBinary(nativeToolsId, - "nativetools", "700"); - } - return InternalVariables.nativeToolsReady; - } - - /** - * @param file String that represent the file, including the full path to the - * file and its name. - * @return An instance of the class permissions from which you can get the - * permissions of the file or if the file could not be found or - * permissions couldn't be determined then permissions will be null. - */ - public Permissions getFilePermissionsSymlinks(String file) { - RootTools.log("Checking permissions for " + file); - if (RootTools.exists(file)) { - RootTools.log(file + " was found."); - try { - - Command command = new Command( - Constants.FPS, false, "ls -l " + file, - "busybox ls -l " + file, - "/system/bin/failsafe/toolbox ls -l " + file, - "toolbox ls -l " + file) { - @Override - public void commandOutput(int id, String line) { - if (id == Constants.FPS) { - String symlink_final = ""; - - String[] lineArray = line.split(" "); - if (lineArray[0].length() != 10) { - super.commandOutput(id, line); - return; - } - - RootTools.log("Line " + line); - - try { - String[] symlink = line.split(" "); - if (symlink[symlink.length - 2].equals("->")) { - RootTools.log("Symlink found."); - symlink_final = symlink[symlink.length - 1]; - } - } catch (Exception e) { - } - - try { - InternalVariables.permissions = getPermissions(line); - if (InternalVariables.permissions != null) { - InternalVariables.permissions.setSymlink(symlink_final); - } - } catch (Exception e) { - RootTools.log(e.getMessage()); - } - } - - super.commandOutput(id, line); - } - }; - RootShell.getShell(true).add(command); - commandWait(RootShell.getShell(true), command); - - return InternalVariables.permissions; - - } catch (Exception e) { - RootTools.log(e.getMessage()); - return null; - } - } - - return null; - } - - /** - * This will return an ArrayList of the class Mount. The class mount contains the following - * property's: device mountPoint type flags - *

- * These will provide you with any information you need to work with the mount points. - * - * @return ArrayList an ArrayList of the class Mount. - * @throws Exception if we cannot return the mount points. - */ - public ArrayList getMounts() throws Exception { - - InternalVariables.mounts = new ArrayList<>(); - - if(null == InternalVariables.mounts || InternalVariables.mounts.isEmpty()) { - Shell shell = RootTools.getShell(true); - - Command cmd = new Command(Constants.GET_MOUNTS, - false, - "cat /proc/mounts") { - - @Override - public void commandOutput(int id, String line) { - if (id == Constants.GET_MOUNTS) { - RootTools.log(line); - - String[] fields = line.split(" "); - InternalVariables.mounts.add(new Mount(new File(fields[0]), // device - new File(fields[1]), // mountPoint - fields[2], // fstype - fields[3] // flags - )); - } - - super.commandOutput(id, line); - } - }; - shell.add(cmd); - this.commandWait(shell, cmd); - } - - return InternalVariables.mounts; - } - - /** - * This will tell you how the specified mount is mounted. rw, ro, etc... - *

- * - * @param path mount you want to check - * @return String What the mount is mounted as. - * @throws Exception if we cannot determine how the mount is mounted. - */ - public String getMountedAs(String path) throws Exception { - InternalVariables.mounts = getMounts(); - String mp; - if (InternalVariables.mounts != null) { - for (Mount mount : InternalVariables.mounts) { - - mp = mount.getMountPoint().getAbsolutePath(); - - if (mp.equals("/")) { - if (path.equals("/")) { - return (String) mount.getFlags().toArray()[0]; - } else { - continue; - } - } - - if (path.equals(mp) || path.startsWith(mp + "/")) { - RootTools.log((String) mount.getFlags().toArray()[0]); - return (String) mount.getFlags().toArray()[0]; - } - } - - throw new Exception(); - } else { - throw new Exception(); - } - } - - /** - * Get the space for a desired partition. - * - * @param path The partition to find the space for. - * @return the amount if space found within the desired partition. If the space was not found - * then the value is -1 - * @throws TimeoutException - */ - public long getSpace(String path) { - InternalVariables.getSpaceFor = path; - boolean found = false; - RootTools.log("Looking for Space"); - try { - final Command command = new Command(Constants.GS, false, "df " + path) { - - @Override - public void commandOutput(int id, String line) { - if (id == Constants.GS) { - if (line.contains(InternalVariables.getSpaceFor.trim())) { - InternalVariables.space = line.split(" "); - } - } - - super.commandOutput(id, line); - } - }; - Shell.startRootShell().add(command); - commandWait(Shell.startRootShell(), command); - - } catch (Exception e) { - } - - if (InternalVariables.space != null) { - RootTools.log("First Method"); - - for (String spaceSearch : InternalVariables.space) { - - RootTools.log(spaceSearch); - - if (found) { - return getConvertedSpace(spaceSearch); - } else if (spaceSearch.equals("used,")) { - found = true; - } - } - - // Try this way - int count = 0, targetCount = 3; - - RootTools.log("Second Method"); - - if (InternalVariables.space[0].length() <= 5) { - targetCount = 2; - } - - for (String spaceSearch : InternalVariables.space) { - - RootTools.log(spaceSearch); - if (spaceSearch.length() > 0) { - RootTools.log(spaceSearch + ("Valid")); - if (count == targetCount) { - return getConvertedSpace(spaceSearch); - } - count++; - } - } - } - RootTools.log("Returning -1, space could not be determined."); - return -1; - } - - /** - * This will return a String that represent the symlink for a specified file. - *

- * - * @param file file to get the Symlink for. (must have absolute path) - * @return String a String that represent the symlink for a specified file or an - * empty string if no symlink exists. - */ - public String getSymlink(String file) { - RootTools.log("Looking for Symlink for " + file); - - try { - final List results = new ArrayList(); - - Command command = new Command(Constants.GSYM, false, "ls -l " + file) { - - @Override - public void commandOutput(int id, String line) { - if (id == Constants.GSYM) { - if (!line.trim().equals("")) { - results.add(line); - } - } - - super.commandOutput(id, line); - } - }; - Shell.startRootShell().add(command); - commandWait(Shell.startRootShell(), command); - - String[] symlink = results.get(0).split(" "); - if (symlink.length > 2 && symlink[symlink.length - 2].equals("->")) { - RootTools.log("Symlink found."); - - String final_symlink; - - if (!symlink[symlink.length - 1].equals("") && !symlink[symlink.length - 1].contains("/")) { - //We assume that we need to get the path for this symlink as it is probably not absolute. - List paths = RootShell.findBinary(symlink[symlink.length - 1], true); - if (paths.size() > 0) { - //We return the first found location. - final_symlink = paths.get(0) + symlink[symlink.length - 1]; - } else { - //we couldnt find a path, return the symlink by itself. - final_symlink = symlink[symlink.length - 1]; - } - } else { - final_symlink = symlink[symlink.length - 1]; - } - - return final_symlink; - } - } catch (Exception e) { - if (RootTools.debugMode) { - e.printStackTrace(); - } - } - - RootTools.log("Symlink not found"); - return ""; - } - - /** - * This will return an ArrayList of the class Symlink. The class Symlink contains the following - * property's: path SymplinkPath - *

- * These will provide you with any Symlinks in the given path. - * - * @param path path to search for Symlinks. - * @return ArrayList an ArrayList of the class Symlink. - * @throws Exception if we cannot return the Symlinks. - */ - public ArrayList getSymlinks(String path) throws Exception { - - // this command needs find - if (!checkUtil("find")) { - throw new Exception(); - } - - InternalVariables.symlinks = new ArrayList<>(); - - Command command = new Command(0, false, "find " + path + " -type l -exec ls -l {} \\;") { - @Override - public void commandOutput(int id, String line) { - if (id == Constants.GET_SYMLINKS) { - RootTools.log(line); - - String[] fields = line.split(" "); - InternalVariables.symlinks.add(new Symlink(new File(fields[fields.length - 3]), // file - new File(fields[fields.length - 1]) // SymlinkPath - )); - - } - - super.commandOutput(id, line); - } - }; - Shell.startRootShell().add(command); - commandWait(Shell.startRootShell(), command); - - if (InternalVariables.symlinks != null) { - return InternalVariables.symlinks; - } else { - throw new Exception(); - } - } - - /** - * This will return to you a string to be used in your shell commands which will represent the - * valid working toolbox with correct permissions. For instance, if Busybox is available it will - * return "busybox", if busybox is not available but toolbox is then it will return "toolbox" - * - * @return String that indicates the available toolbox to use for accessing applets. - */ - public String getWorkingToolbox() { - if (RootTools.checkUtil("busybox")) { - return "busybox"; - } else if (RootTools.checkUtil("toolbox")) { - return "toolbox"; - } else { - return ""; - } - } - - /** - * Checks if there is enough Space on SDCard - * - * @param updateSize size to Check (long) - * @return true if the Update will fit on SDCard, false if not enough - * space on SDCard. Will also return false, if the SDCard is not mounted as - * read/write - */ - @SuppressWarnings("deprecation") - public boolean hasEnoughSpaceOnSdCard(long updateSize) { - RootTools.log("Checking SDcard size and that it is mounted as RW"); - String status = Environment.getExternalStorageState(); - if (!status.equals(Environment.MEDIA_MOUNTED)) { - return false; - } - File path = Environment.getExternalStorageDirectory(); - StatFs stat = new StatFs(path.getPath()); - long blockSize = 0; - long availableBlocks = 0; - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) { - blockSize = stat.getBlockSize(); - availableBlocks = stat.getAvailableBlocks(); - } else { - blockSize = stat.getBlockSizeLong(); - availableBlocks = stat.getAvailableBlocksLong(); - } - return (updateSize < availableBlocks * blockSize); - } - - /** - * Checks whether the toolbox or busybox binary contains a specific util - * - * @param util - * @param box Should contain "toolbox" or "busybox" - * @return true if it contains this util - */ - public boolean hasUtil(final String util, final String box) { - - InternalVariables.found = false; - - // only for busybox and toolbox - if (!(box.endsWith("toolbox") || box.endsWith("busybox"))) { - return false; - } - - try { - - Command command = new Command(0, false, box.endsWith("toolbox") ? box + " " + util : box + " --list") { - - @Override - public void commandOutput(int id, String line) { - if (box.endsWith("toolbox")) { - if (!line.contains("no such tool")) { - InternalVariables.found = true; - } - } else if (box.endsWith("busybox")) { - // go through all lines of busybox --list - if (line.contains(util)) { - RootTools.log("Found util!"); - InternalVariables.found = true; - } - } - - super.commandOutput(id, line); - } - }; - RootTools.getShell(true).add(command); - commandWait(RootTools.getShell(true), command); - - if (InternalVariables.found) { - RootTools.log("Box contains " + util + " util!"); - return true; - } else { - RootTools.log("Box does not contain " + util + " util!"); - return false; - } - } catch (Exception e) { - RootTools.log(e.getMessage()); - return false; - } - } - - /** - * This method can be used to unpack a binary from the raw resources folder and store it in - * /data/data/app.package/files/ This is typically useful if you provide your own C- or - * C++-based binary. This binary can then be executed using sendShell() and its full path. - * - * @param context the current activity's Context - * @param sourceId resource id; typically R.raw.id - * @param destName destination file name; appended to /data/data/app.package/files/ - * @param mode chmod value for this file - * @return a boolean which indicates whether or not we were able to create the new - * file. - */ - public boolean installBinary(Context context, int sourceId, String destName, String mode) { - Installer installer; - - try { - installer = new Installer(context); - } catch (IOException ex) { - if (RootTools.debugMode) { - ex.printStackTrace(); - } - return false; - } - - return (installer.installBinary(sourceId, destName, mode)); - } - - /** - * This method checks whether a binary is installed. - * - * @param context the current activity's Context - * @param binaryName binary file name; appended to /data/data/app.package/files/ - * @return a boolean which indicates whether or not - * the binary already exists. - */ - public boolean isBinaryAvailable(Context context, String binaryName) { - Installer installer; - - try { - installer = new Installer(context); - } catch (IOException ex) { - if (RootTools.debugMode) { - ex.printStackTrace(); - } - return false; - } - - return (installer.isBinaryInstalled(binaryName)); - } - - /** - * This will let you know if an applet is available from BusyBox - *

- * - * @param applet The applet to check for. - * @return true if applet is available, false otherwise. - */ - public boolean isAppletAvailable(String applet, String binaryPath) { - try { - for (String aplet : getBusyBoxApplets(binaryPath)) { - if (aplet.equals(applet)) { - return true; - } - } - return false; - } catch (Exception e) { - RootTools.log(e.toString()); - return false; - } - } - - /** - * This method can be used to to check if a process is running - * - * @param processName name of process to check - * @return true if process was found - * @throws TimeoutException (Could not determine if the process is running) - */ - public boolean isProcessRunning(final String processName) { - - RootTools.log("Checks if process is running: " + processName); - - InternalVariables.processRunning = false; - - try { - Command command = new Command(0, false, "ps") { - @Override - public void commandOutput(int id, String line) { - if (line.contains(processName)) { - InternalVariables.processRunning = true; - } - - super.commandOutput(id, line); - } - }; - RootTools.getShell(true).add(command); - commandWait(RootTools.getShell(true), command); - - } catch (Exception e) { - RootTools.log(e.getMessage()); - } - - return InternalVariables.processRunning; - } - - /** - * This method can be used to kill a running process - * - * @param processName name of process to kill - * @return true if process was found and killed successfully - */ - public boolean killProcess(final String processName) { - RootTools.log("Killing process " + processName); - - InternalVariables.pid_list = ""; - - //Assume that the process is running - InternalVariables.processRunning = true; - - try { - - Command command = new Command(0, false, "ps") { - @Override - public void commandOutput(int id, String line) { - if (line.contains(processName)) { - Matcher psMatcher = InternalVariables.psPattern.matcher(line); - - try { - if (psMatcher.find()) { - String pid = psMatcher.group(1); - - InternalVariables.pid_list += " " + pid; - InternalVariables.pid_list = InternalVariables.pid_list.trim(); - - RootTools.log("Found pid: " + pid); - } else { - RootTools.log("Matching in ps command failed!"); - } - } catch (Exception e) { - RootTools.log("Error with regex!"); - e.printStackTrace(); - } - } - - super.commandOutput(id, line); - } - }; - RootTools.getShell(true).add(command); - commandWait(RootTools.getShell(true), command); - - // get all pids in one string, created in process method - String pids = InternalVariables.pid_list; - - // kill processes - if (!pids.equals("")) { - try { - // example: kill -9 1234 1222 5343 - command = new Command(0, false, "kill -9 " + pids); - RootTools.getShell(true).add(command); - commandWait(RootTools.getShell(true), command); - - return true; - } catch (Exception e) { - RootTools.log(e.getMessage()); - } - } else { - //no pids match, must be dead - return true; - } - } catch (Exception e) { - RootTools.log(e.getMessage()); - } - - return false; - } - - /** - * This will launch the Android market looking for BusyBox - * - * @param activity pass in your Activity - */ - public void offerBusyBox(Activity activity) { - RootTools.log("Launching Market for BusyBox"); - Intent i = new Intent(Intent.ACTION_VIEW, - Uri.parse("market://details?id=stericson.busybox")); - activity.startActivity(i); - } - - /** - * This will launch the Android market looking for BusyBox, but will return the intent fired and - * starts the activity with startActivityForResult - * - * @param activity pass in your Activity - * @param requestCode pass in the request code - * @return intent fired - */ - public Intent offerBusyBox(Activity activity, int requestCode) { - RootTools.log("Launching Market for BusyBox"); - Intent i = new Intent(Intent.ACTION_VIEW, - Uri.parse("market://details?id=stericson.busybox")); - activity.startActivityForResult(i, requestCode); - return i; - } - - /** - * This will launch the Play Store looking for SuperUser - * - * @param activity pass in your Activity - */ - public void offerSuperUser(Activity activity) { - RootTools.log("Launching Play Store for SuperSU"); - Intent i = new Intent(Intent.ACTION_VIEW, - Uri.parse("market://details?id=eu.chainfire.supersu")); - activity.startActivity(i); - } - - /** - * This will launch the Play Store looking for SuperSU, but will return the intent fired - * and starts the activity with startActivityForResult - * - * @param activity pass in your Activity - * @param requestCode pass in the request code - * @return intent fired - */ - public Intent offerSuperUser(Activity activity, int requestCode) { - RootTools.log("Launching Play Store for SuperSU"); - Intent i = new Intent(Intent.ACTION_VIEW, - Uri.parse("market://details?id=eu.chainfire.supersu")); - activity.startActivityForResult(i, requestCode); - return i; - } - - private void commandWait(Shell shell, Command cmd) throws Exception { - - while (!cmd.isFinished()) { - - RootTools.log(Constants.TAG, shell.getCommandQueuePositionString(cmd)); - RootTools.log(Constants.TAG, "Processed " + cmd.totalOutputProcessed + " of " + cmd.totalOutput + " output from command."); - - synchronized (cmd) { - try { - if (!cmd.isFinished()) { - cmd.wait(2000); - } - } catch (InterruptedException e) { - e.printStackTrace(); - } - } - - if (!cmd.isExecuting() && !cmd.isFinished()) { - if (!shell.isExecuting && !shell.isReading) { - Log.e(Constants.TAG, "Waiting for a command to be executed in a shell that is not executing and not reading! \n\n Command: " + cmd.getCommand()); - Exception e = new Exception(); - e.setStackTrace(Thread.currentThread().getStackTrace()); - e.printStackTrace(); - } else if (shell.isExecuting && !shell.isReading) { - Log.e(Constants.TAG, "Waiting for a command to be executed in a shell that is executing but not reading! \n\n Command: " + cmd.getCommand()); - Exception e = new Exception(); - e.setStackTrace(Thread.currentThread().getStackTrace()); - e.printStackTrace(); - } else { - Log.e(Constants.TAG, "Waiting for a command to be executed in a shell that is not reading! \n\n Command: " + cmd.getCommand()); - Exception e = new Exception(); - e.setStackTrace(Thread.currentThread().getStackTrace()); - e.printStackTrace(); - } - } - - } - } -} diff --git a/app/src/main/java/com/stericson/roottools/internal/Runner.java b/app/src/main/java/com/stericson/roottools/internal/Runner.java deleted file mode 100644 index 4221fca24..000000000 --- a/app/src/main/java/com/stericson/roottools/internal/Runner.java +++ /dev/null @@ -1,98 +0,0 @@ -/* - * This file is part of the RootTools Project: http://code.google.com/p/RootTools/ - * - * Copyright (c) 2012 Stephen Erickson, Chris Ravenscroft, Dominik Schuermann, Adam Shanks - * - * This code is dual-licensed under the terms of the Apache License Version 2.0 and - * the terms of the General Public License (GPL) Version 2. - * You may use this code according to either of these licenses as is most appropriate - * for your project on a case-by-case basis. - * - * The terms of each license can be found in the root directory of this project's repository as well as at: - * - * * http://www.apache.org/licenses/LICENSE-2.0 - * * http://www.gnu.org/licenses/gpl-2.0.txt - * - * Unless required by applicable law or agreed to in writing, software - * distributed under these Licenses is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See each License for the specific language governing permissions and - * limitations under that License. - */ - -package com.stericson.roottools.internal; - -import android.content.Context; -import android.util.Log; - -import com.stericson.rootshell.execution.Command; -import com.stericson.rootshell.execution.Shell; -import com.stericson.roottools.RootTools; - -import java.io.IOException; - -public class Runner extends Thread -{ - - private static final String LOG_TAG = "RootTools::Runner"; - - Context context; - String binaryName; - String parameter; - - public Runner(Context context, String binaryName, String parameter) - { - this.context = context; - this.binaryName = binaryName; - this.parameter = parameter; - } - - public void run() - { - String privateFilesPath = null; - try - { - privateFilesPath = context.getFilesDir().getCanonicalPath(); - } - catch (IOException e) - { - if (RootTools.debugMode) - { - Log.e(LOG_TAG, "Problem occured while trying to locate private files directory!"); - } - e.printStackTrace(); - } - if (privateFilesPath != null) - { - try - { - Command command = new Command(0, false, privateFilesPath + "/" + binaryName + " " + parameter); - Shell.startRootShell().add(command); - commandWait(command); - - } - catch (Exception e) - { - } - } - } - - private void commandWait(Command cmd) - { - synchronized (cmd) - { - try - { - if (!cmd.isFinished()) - { - cmd.wait(2000); - } - } - catch (InterruptedException e) - { - e.printStackTrace(); - } - } - } - -} diff --git a/app/src/main/java/dev/ukanth/ufirewall/Api.java b/app/src/main/java/dev/ukanth/ufirewall/Api.java index d274ee4da..69fceca82 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/Api.java +++ b/app/src/main/java/dev/ukanth/ufirewall/Api.java @@ -68,6 +68,7 @@ import android.util.Base64; import android.util.SparseArray; import android.widget.Toast; +import android.app.Activity; import androidx.annotation.NonNull; import androidx.core.app.NotificationCompat; @@ -78,12 +79,12 @@ import com.raizlabs.android.dbflow.sql.language.Delete; import com.raizlabs.android.dbflow.sql.language.SQLite; import com.raizlabs.android.dbflow.sql.language.Select; -import com.stericson.roottools.RootTools; import com.topjohnwu.superuser.Shell; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; +import org.json.JSONTokener; import java.io.BufferedReader; import java.io.File; @@ -105,10 +106,12 @@ import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.TreeSet; import java.util.StringTokenizer; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -127,14 +130,26 @@ import dev.ukanth.ufirewall.log.Log; import dev.ukanth.ufirewall.log.LogData; import dev.ukanth.ufirewall.log.LogData_Table; -import dev.ukanth.ufirewall.preferences.DefaultConnectionPref; -import dev.ukanth.ufirewall.preferences.DefaultConnectionPref_Table; import dev.ukanth.ufirewall.profiles.ProfileData; import dev.ukanth.ufirewall.profiles.ProfileHelper; import dev.ukanth.ufirewall.service.FirewallService; import dev.ukanth.ufirewall.service.RootCommand; +import dev.ukanth.ufirewall.service.RootShellService; +import dev.ukanth.ufirewall.customrules.CustomRule; +import dev.ukanth.ufirewall.customrules.CustomRule_Table; +import dev.ukanth.ufirewall.util.ApkInfo; +import dev.ukanth.ufirewall.util.AppRuleHelper; +import dev.ukanth.ufirewall.util.BackupHelper; import dev.ukanth.ufirewall.util.G; +import dev.ukanth.ufirewall.util.IptablesRestorePlanner; +import dev.ukanth.ufirewall.util.IptablesVersion; import dev.ukanth.ufirewall.util.JsonHelper; +import dev.ukanth.ufirewall.util.NetworkChangeDebouncer; +import dev.ukanth.ufirewall.util.Notifications; +import dev.ukanth.ufirewall.util.RootFiles; +import dev.ukanth.ufirewall.util.SystemUids; +import dev.ukanth.ufirewall.util.UidListParser; +import dev.ukanth.ufirewall.util.UidResolver; import dev.ukanth.ufirewall.widget.StatusWidget; /** @@ -192,6 +207,8 @@ public final class Api { public static final String SCRIPT_EXTRA = "dev.ukanth.ufirewall.intent.extra.SCRIPT"; public static final String SCRIPT2_EXTRA = "dev.ukanth.ufirewall.intent.extra.SCRIPT2"; public static final int ERROR_NOTIFICATION_ID = 9; + public static final int CUSTOM_SCRIPT_WARNING_NOTIFICATION_ID = 10; + public static final int LOG_WATCHER_FAILED_NOTIFICATION_ID = 11; private static final int WIFI_EXPORT = 0; private static final int DATA_EXPORT = 1; private static final int ROAM_EXPORT = 2; @@ -208,10 +225,21 @@ public final class Api { private static final int IPTABLES_TRY_AGAIN = 4; private static final String[] dynChains = {"-3g-postcustom", "-3g-fork", "-wifi-postcustom", "-wifi-fork"}; private static final String[] natChains = {"", "-tor-check", "-tor-filter"}; - private static final String[] staticChains = {"", "-input", "-3g", "-wifi", "-reject", "-vpn", "-3g-tether", "-3g-home", "-3g-roam", "-wifi-tether", "-wifi-wan", "-wifi-lan", "-usb-tether", "-tor", "-tor-reject", "-tether"}; + private static final String[] staticChains = {"", "-input", "-3g", "-wifi", "-reject", "-vpn", "-3g-tether", "-3g-home", "-3g-roam", "-wifi-tether", "-wifi-wan", "-wifi-lan", "-usb-tether", "-tor", "-tor-reject", "-tether", "-3g-home-reject", "-3g-roam-reject", "-wifi-wan-reject", "-wifi-lan-reject", "-vpn-reject", "-tether-reject"}; + // LAN-selected apps also need discovery destinations such as mDNS, SSDP, and broadcast. + private static final String[] LOCAL_RESERVED_IPV4_RANGES = {"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "169.254.0.0/16", "224.0.0.0/4", "255.255.255.255/32"}; + private static final String[] LOCAL_RESERVED_IPV6_RANGES = {"fc00::/7", "fe80::/10", "ff00::/8"}; private static volatile boolean globalStatus = false; private static final Object GLOBAL_STATUS_LOCK = new Object(); + + /** + * Check if rules are currently being applied + * @return true if rules application is in progress + */ + public static boolean isRulesBeingApplied() { + return globalStatus; + } public static List getListOfUids() { return listOfUids; @@ -220,10 +248,10 @@ public static List getListOfUids() { private static List listOfUids = new ArrayList<>(); - private static Map uidToApplicationInfoMap = null; - private static final Pattern dual_pattern = Pattern.compile("package:(.*) uid:(.*)", Pattern.MULTILINE); + // "package: uid:"; without --user the uids of every user follow, comma-separated + // ("uid:10152,1010152,1110152"): take the first one (the main user's) /** * @brief Special user/group IDs that aren't associated with @@ -272,6 +300,15 @@ public static List getListOfUids() { private static Map specialApps = null; private static volatile boolean rulesUpToDate = false; private static final Object RULES_LOCK = new Object(); + /** + * @return true while a full rule apply runs + */ + public static boolean isApplyInProgress() { + synchronized (GLOBAL_STATUS_LOCK) { + return globalStatus; + } + } + public static void setRulesUpToDate(boolean rulesUpToDate) { synchronized (RULES_LOCK) { Api.rulesUpToDate = rulesUpToDate; @@ -329,32 +366,110 @@ public static void toast(final Context ctx, final CharSequence msgText, final in } public static String getBinaryPath(Context ctx, boolean setv6) { - boolean builtin; String ip_path = G.ip_path(); - - if (ip_path.equals("system")) { - builtin = false; - } else if(ip_path.equals("builtin")) { - builtin = true; - } else{ - builtin = false; + String binaryName = setv6 ? "ip6tables" : "iptables"; + + // If built-in binaries have previously failed with exit 126, prefer system binaries + if (G.isBuiltinIptablesFailed() && !ip_path.equals("builtin")) { + Log.i(TAG, "Built-in iptables previously failed, preferring system binary for " + binaryName); + String systemBinaryPath = findSystemBinary(binaryName); + if (systemBinaryPath != null) { + if (Api.bbPath == null) { + Api.bbPath = getBusyBoxPath(ctx, true); + } + return systemBinaryPath; + } + Log.w(TAG, "System binary " + binaryName + " not found despite previous built-in failure"); } - - String dir = ""; - if (builtin) { - dir = ctx.getDir("bin", 0).getAbsolutePath() + "/"; + + // First priority: check system binary if preference is "system" or "auto" + if (ip_path.equals("system") || ip_path.equals("auto")) { + String systemBinaryPath = findSystemBinary(binaryName); + if (systemBinaryPath != null) { + if (Api.bbPath == null) { + Api.bbPath = getBusyBoxPath(ctx, true); + } + return systemBinaryPath; + } + + // If system binary not found and preference is "system", log warning + if (ip_path.equals("system")) { + Log.w(TAG, "System binary " + binaryName + " not found, falling back to built-in"); + } } - - String ipPath = dir + (setv6 ? "ip6tables" : "iptables" ); - - /*if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) { - dir = ctx.getDir("bin", 0).getAbsolutePath() + "/"; - ipPath = dir + "run_pie " + dir + (setv6 ? "ip6tables" : "iptables"); - }*/ + + // Second priority: use built-in binary + // Check if built-in binary exists for current architecture + String builtinDir = ctx.getDir("bin", 0).getAbsolutePath() + "/"; + String builtinPath = builtinDir + binaryName; + + File builtinFile = new File(builtinPath); + if (builtinFile.exists() && builtinFile.canExecute()) { + if (Api.bbPath == null) { + Api.bbPath = getBusyBoxPath(ctx, true); + } + return builtinPath; + } + + // Fallback: try to install built-in binaries if they don't exist + Log.w(TAG, "Built-in binary " + binaryName + " not found, attempting to install binaries"); + if (assertBinaries(ctx, false)) { + if (Api.bbPath == null) { + Api.bbPath = getBusyBoxPath(ctx, true); + } + return builtinPath; + } + + // Last resort: return the path even if binary doesn't exist (will likely fail at runtime) + Log.e(TAG, "No working " + binaryName + " binary found, returning built-in path anyway"); if (Api.bbPath == null) { Api.bbPath = getBusyBoxPath(ctx, true); } - return ipPath; + return builtinPath; + } + + /** + * Find system binary by checking common system paths + * + * @param binaryName the name of the binary to find + * @return full path to the binary if found, null otherwise + */ + public static String findSystemBinary(String binaryName) { + // Common paths where system iptables/ip6tables binaries are located + String[] systemPaths = { + "/system/bin/" + binaryName, + "/system/xbin/" + binaryName, + "/vendor/bin/" + binaryName, + "/sbin/" + binaryName, + "/usr/bin/" + binaryName, + "/bin/" + binaryName + }; + + for (String path : systemPaths) { + File binaryFile = new File(path); + if (binaryFile.exists() && binaryFile.canExecute()) { + Log.i(TAG, "Found system binary: " + path); + return path; + } + } + + // Also try using 'which' command if available + try { + Shell.Result result = Shell.cmd("which " + binaryName).exec(); + if (result.isSuccess() && !result.getOut().isEmpty()) { + String whichPath = result.getOut().get(0).trim(); + File whichFile = new File(whichPath); + if (whichFile.exists() && whichFile.canExecute()) { + Log.i(TAG, "Found system binary via 'which': " + whichPath); + return whichPath; + } + } + } catch (Exception e) { + Log.d(TAG, "Unable to use 'which' command to find " + binaryName + ": " + e.getMessage()); + } + + Log.d(TAG, "System binary " + binaryName + " not found in any standard location"); + return null; } /** @@ -365,45 +480,94 @@ public static String getBinaryPath(Context ctx, boolean setv6) { * @return */ public static String getBusyBoxPath(Context ctx, boolean considerSystem) { - - if (G.bb_path().equals("system") && considerSystem) { - return "busybox "; + String bb_path = G.bb_path(); + + // First priority: check system busybox if preference is "system" or "auto" and considerSystem is true + if (considerSystem && (bb_path.equals("system") || bb_path.equals("auto"))) { + String systemBusybox = findSystemBinary("busybox"); + if (systemBusybox != null) { + return systemBusybox + " "; + } + + // If system busybox not found and preference is "system", log warning and fall back + if (bb_path.equals("system")) { + Log.w(TAG, "System busybox not found, falling back to built-in"); + } + } + + // Second priority: use built-in busybox + String dir = ctx.getDir("bin", 0).getAbsolutePath(); + String builtinPath = dir + "/busybox"; + + File builtinFile = new File(builtinPath); + if (builtinFile.exists() && builtinFile.canExecute()) { + return builtinPath + " "; + } + + // Fallback: return built-in path even if it doesn't exist yet (may be installed later) + if (!builtinFile.exists()) { + Log.w(TAG, "Built-in busybox not found at " + builtinPath + ", returning path anyway"); } else { - String dir = ctx.getDir("bin", 0).getAbsolutePath(); - return dir + "/busybox "; + Log.w(TAG, "Built-in busybox exists but not executable at " + builtinPath + ", permissions: " + + (builtinFile.canRead() ? "R" : "-") + + (builtinFile.canWrite() ? "W" : "-") + + (builtinFile.canExecute() ? "X" : "-")); } + return builtinPath + " "; } /** - * Get NFLog Path + * Get NFLog Path - Enhanced version with fallback support * - * @param ctx - * @returnC + * @param ctx Context + * @return path to best available nflog binary */ public static String getNflogPath(Context ctx) { String dir = ctx.getDir("bin", 0).getAbsolutePath(); - String nflogPath = dir + "/nflog"; + String originalPath = dir + "/nflog"; + File originalFile = new File(originalPath); - // Check if nflog binary exists and is executable - File nflogFile = new File(nflogPath); - if (!nflogFile.exists()) { - Log.w(TAG, "NFLOG binary not found at: " + nflogPath); + if (!originalFile.exists()) { + Log.w(TAG, "No NFLOG binary found at: " + originalPath); return null; } - if (!nflogFile.canExecute()) { - Log.w(TAG, "NFLOG binary not executable at: " + nflogPath); + if (!originalFile.canExecute()) { + Log.w(TAG, "NFLOG binary not executable at: " + originalPath); // Try to make it executable try { - nflogFile.setExecutable(true); + originalFile.setExecutable(true); + if (!originalFile.canExecute()) { + Log.e(TAG, "Failed to make nflog executable"); + return null; + } } catch (Exception e) { Log.e(TAG, "Failed to make nflog executable: " + e.getMessage()); return null; } } - return nflogPath + " "; + Log.i(TAG, "Using original NFLOG binary"); + return originalPath; + } + + /** + * Get enhanced NFLOG command with optimized parameters + * + * @param ctx Context + * @param queueNum NFLOG queue number + * @return complete command string with optimizations + */ + public static String getEnhancedNflogCommand(Context ctx, int queueNum) { + String nflogPath = getNflogPath(ctx); + if (nflogPath == null) { + return null; + } + + // Use standard nflog command with queue number + return nflogPath + " " + queueNum; } + /** * Copies a raw resource file, given its ID to the given location @@ -429,7 +593,45 @@ private static void copyRawFile(Context ctx, int resid, File file, String mode) is.close(); // Change the permissions - Runtime.getRuntime().exec("chmod " + mode + " " + abspath).waitFor(); + executeSecureCommand(new String[]{"chmod", mode, abspath}); + } + + /** + * Execute system commands securely using ProcessBuilder to prevent command injection + * + * @param command Array of command and arguments (prevents shell interpretation) + * @throws IOException if command execution fails + * @throws InterruptedException if command is interrupted + */ + private static void executeSecureCommand(String[] command) throws IOException, InterruptedException { + if (command == null || command.length == 0) { + throw new IllegalArgumentException("Command cannot be null or empty"); + } + + // Validate command and arguments don't contain dangerous characters + for (String arg : command) { + if (arg == null || arg.contains("\n") || arg.contains("\r") || + arg.contains(";") || arg.contains("&") || arg.contains("|") || + arg.contains("`") || arg.contains("$")) { + Log.w(TAG, "Rejecting command with potentially dangerous characters: " + java.util.Arrays.toString(command)); + throw new SecurityException("Command contains illegal characters"); + } + } + + ProcessBuilder pb = new ProcessBuilder(command); + pb.environment().clear(); // Clear environment to prevent injection via env vars + Process process = pb.start(); + int exitCode = process.waitFor(); + + if (exitCode != 0) { + // For chmod commands, permission denied is expected on Android - don't fail the installation + if (command.length > 0 && "chmod".equals(command[0])) { + Log.w(TAG, "chmod command failed (expected on Android without root): exit code " + exitCode + " for " + java.util.Arrays.toString(command)); + return; // Don't throw exception for chmod failures + } + Log.w(TAG, "Command failed with exit code " + exitCode + ": " + java.util.Arrays.toString(command)); + throw new IOException("Command execution failed with exit code: " + exitCode); + } } /** @@ -448,6 +650,45 @@ private static void addRuleForUsers(List listCommands, String[] users, S } } + // null until probed; the owner "--socket-exists" and conntrack matches depend on the kernel + private static volatile Boolean ownerlessRuleSupported; + + /** + * @return true if the kernel supports the rule for socket-less packets of established + * connections (tested once per app run in a scratch chain, IPv4 and IPv6) + */ + static boolean ownerlessEstablishedSupported() { + Boolean supported = ownerlessRuleSupported; + if (supported != null) { + return supported; + } + boolean ok = probeOwnerlessRule(false) && (!G.enableIPv6() || probeOwnerlessRule(true)); + ownerlessRuleSupported = ok; + Log.i(TAG, "Rule for closing packets of allowed connections: " + (ok ? "supported" : "not supported by this kernel")); + return ok; + } + + private static boolean probeOwnerlessRule(boolean ipv6) { + try { + Context c = G.getContext(); + String bin = getBinaryPath(c, ipv6); + if (bin == null) { + return false; + } + String chain = "afwall-probe"; + Shell.Result result = Shell.cmd( + bin + " -N " + chain + " 2>/dev/null", + bin + " -A " + chain + " -m owner ! --socket-exists -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN" + + " && echo AFWALL_PROBE_OK", + bin + " -F " + chain + " 2>/dev/null", + bin + " -X " + chain + " 2>/dev/null").exec(); + return result.getOut().contains("AFWALL_PROBE_OK"); + } catch (Exception e) { + Log.w(TAG, "Unable to test iptables support: " + e.getMessage()); + return false; + } + } + private static void addRulesForUidlist(List cmds, List uids, String chain, boolean whitelist) { String action = whitelist ? " -j RETURN" : " -j " + chain + "-reject"; @@ -490,66 +731,129 @@ private static void addRulesForUidlist(List cmds, List uids, St //cmds.add("-A " + chain + " -p tcp --dport 443" + " -j ACCEPT"); } + if (ownerlessEstablishedSupported()) { + // Packets without a socket that belong to a connection already allowed: the + // closing ACK/FIN/RST sent after the app closed its socket. Unless the kernel entry + // was allowed these were rejected (and logged as "unknown"), leaving the peer to + // retransmit. New connections without a socket (kernel VPNs such as WireGuard, + // IPsec) still follow the kernel entry: they are not ESTABLISHED yet. + cmds.add("-A " + chain + " -m owner ! --socket-exists -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN"); + } + boolean kernel_checked = uids.contains(SPECIAL_UID_KERNEL); + if (whitelist) { if (kernel_checked) { // reject any other UIDs, but allow the kernel through - cmds.add("-A " + chain + " -m owner --uid-owner 0:999999999 -j " + chain + "-reject"); + // Use fallback rule if owner module is not available + if (G.hasOwnerModule()) { + Log.d(TAG, "Adding whitelist kernel rule with owner module for chain " + chain); + cmds.add("-A " + chain + " -m owner --uid-owner 0:999999999 -j " + chain + "-reject"); + } else { + Log.w(TAG, "Owner module not available, using fallback rule for chain " + chain); + cmds.add("-A " + chain + " -j " + chain + "-reject"); + } } else { // kernel is blocked so reject everything - cmds.add("-A " + chain + " -j " + chain + "-reject"); + String rejectRule = "-A " + chain + " -j " + chain + "-reject"; + cmds.add(rejectRule); } } else { if (kernel_checked) { // allow any other UIDs, but block the kernel - cmds.add("-A " + chain + " -m owner --uid-owner 0:999999999 -j RETURN"); - cmds.add("-A " + chain + " -j " + chain + "-reject"); + if (G.hasOwnerModule()) { + cmds.add("-A " + chain + " -m owner --uid-owner 0:999999999 -j RETURN"); + cmds.add("-A " + chain + " -j " + chain + "-reject"); + } else { + Log.w(TAG, "Owner module not available, using fallback rule for chain " + chain); + cmds.add("-A " + chain + " -j " + chain + "-reject"); + } } } - //add 1052 for LAN - if(G.enableLAN()) { - cmds.add("-A " + "afwall-wifi-lan" + " -m owner --uid-owner 1052 -j RETURN"); + // add 1052 for LAN; "afwall" under multi-user, so take the prefix of this chain + String chainPrefix = chain.contains("-") ? chain.substring(0, chain.indexOf('-')) : chain; + if(G.enableLAN() && G.hasOwnerModule()) { + cmds.add("-A " + chainPrefix + "-wifi-lan" + " -m owner --uid-owner 1052 -j RETURN"); } - cmds.add("-A " + "afwall-wifi-wan" + " -m owner --uid-owner 1052 -j RETURN"); + if (G.hasOwnerModule()) { + cmds.add("-A " + chainPrefix + "-wifi-wan" + " -m owner --uid-owner 1052 -j RETURN"); + } } } + private static final String[] INDIVIDUAL_REJECT_CHAIN_SUFFIXES = {"-3g-home-reject", "-3g-roam-reject", + "-wifi-wan-reject", "-wifi-lan-reject", "-vpn-reject", "-tether-reject"}; + /** every chain {@link #addRejectRules} appends to */ + private static final String[] REJECT_CHAIN_SUFFIXES = {"-reject", "-3g-home-reject", "-3g-roam-reject", + "-wifi-wan-reject", "-wifi-lan-reject", "-vpn-reject", "-tether-reject"}; + private static void addRejectRules(List cmds, String chainName) { // set up reject chain to log or not log // this can be changed dynamically through the Firewall Logs activity - if (G.enableLogService()) { - if (G.logTarget().trim().equals("LOG")) { - //cmds.add("-A " + chainName + " -m limit --limit 1000/min -j LOG --log-prefix \"{AFL-ALLOW}\" --log-level 4 --log-uid"); - cmds.add("-A " + chainName + "-reject" + " -m limit --limit 1000/min -j LOG --log-prefix \"{AFL}\" --log-level 4 --log-uid --log-tcp-options --log-ip-options"); - } else if (G.logTarget().trim().equals("NFLOG")) { - //cmds.add("-A " + chainName + " -j NFLOG --nflog-prefix \"{AFL-ALLOW}\" --nflog-group 40"); - cmds.add("-A " + chainName + "-reject" + " -j NFLOG --nflog-prefix \"{AFL}\" --nflog-group 40"); - } + addLogRuleForRejectChain(cmds, chainName + "-reject"); + String rejectRule = "-A " + chainName + "-reject" + " -j REJECT"; + Log.d(TAG, "Adding final REJECT rule: " + rejectRule); + cmds.add(rejectRule); + + // Also populate individual reject chains that are used by whitelist mode + for (String suffix : INDIVIDUAL_REJECT_CHAIN_SUFFIXES) { + String individualRejectChain = chainName + suffix; + Log.d(TAG, "Populating individual reject chain: " + individualRejectChain); + addLogRuleForRejectChain(cmds, individualRejectChain); + String individualRejectRule = "-A " + individualRejectChain + " -j REJECT"; + Log.d(TAG, "Adding REJECT to individual reject chain: " + individualRejectRule); + cmds.add(individualRejectRule); + } + } + + private static void addLogRuleForRejectChain(List cmds, String rejectChain) { + if (!G.enableLogService()) { + return; + } + String logTarget = G.logTarget().trim(); + if (logTarget.equals("LOG")) { + // Whitelist mode uses per-interface reject chains, so LOG must be + // added anywhere packets can be rejected, not only the shared chain. + String logRule = "-A " + rejectChain + " -m limit --limit 1000/min -j LOG --log-prefix \"{AFL}\" --log-level 4 --log-uid --log-tcp-options --log-ip-options"; + Log.d(TAG, "Adding LOG rule to reject chain: " + logRule); + cmds.add(logRule); + } else if (logTarget.equals("NFLOG")) { + String nflogRule = "-A " + rejectChain + " -j NFLOG --nflog-prefix \"{AFL}\" --nflog-group 40"; + Log.d(TAG, "Adding NFLOG rule to reject chain: " + nflogRule); + cmds.add(nflogRule); } - cmds.add("-A " + chainName + "-reject" + " -j REJECT"); } private static void addTorRules(List cmds, List uids, Boolean whitelist, Boolean ipv6, String chainName) { + Integer socks_port = 9050; + Integer http_port = 8118; + Integer dns_port = 5400; + Integer tcp_port = 9040; + + Log.i(TAG, "Adding Tor redirect rules before interface filters"); + // Tor selection is an outbound owner match; jumping from INPUT breaks on several iptables backends. + for (Integer uid : uids) { if (uid != null && uid >= 0) { - if (G.enableInbound() || ipv6) { + if (ipv6) { cmds.add("-A " + chainName + "-tor-reject -m owner --uid-owner " + uid + " -j " + chainName + "-reject"); } if (!ipv6) { cmds.add("-t nat -A " + chainName + "-tor-check -m owner --uid-owner " + uid + " -j " + chainName + "-tor-filter"); + // Tor rules run before interface chains so redirected traffic is not rejected as plain Wi-Fi/mobile. + cmds.add("-A " + chainName + "-tor -m owner --uid-owner " + uid + " -d 127.0.0.1 -p tcp --dport " + socks_port + " -j ACCEPT"); + cmds.add("-A " + chainName + "-tor -m owner --uid-owner " + uid + " -d 127.0.0.1 -p tcp --dport " + http_port + " -j ACCEPT"); + cmds.add("-A " + chainName + "-tor -m owner --uid-owner " + uid + " -d 127.0.0.1 -p tcp --dport " + tcp_port + " -j ACCEPT"); + cmds.add("-A " + chainName + "-tor -m owner --uid-owner " + uid + " -d 127.0.0.1 -p udp --dport " + dns_port + " -j ACCEPT"); } } } if (ipv6) { cmds.add("-A " + chainName + " -j " + chainName + "-tor-reject"); } else { - Integer socks_port = 9050; - Integer http_port = 8118; - Integer dns_port = 5400; - Integer tcp_port = 9040; cmds.add("-t nat -A " + chainName + "-tor-filter -d 127.0.0.1 -p tcp --dport " + socks_port + " -j RETURN"); cmds.add("-t nat -A " + chainName + "-tor-filter -d 127.0.0.1 -p tcp --dport " + http_port + " -j RETURN"); cmds.add("-t nat -A " + chainName + "-tor-filter -p udp --dport 53 -j REDIRECT --to-ports " + dns_port); @@ -559,47 +863,148 @@ private static void addTorRules(List cmds, List uids, Boolean w cmds.add("-A " + chainName + "-tor -m mark --mark 0x500 -j " + chainName + "-reject"); cmds.add("-A " + chainName + " -j " + chainName + "-tor"); } - if (G.enableInbound()) { - cmds.add("-A " + chainName + "-input -j " + chainName + "-tor-reject"); - } } private static String sanitizeRule(String rule) { - // Remove potentially dangerous characters and commands - if (rule.contains("&&") || rule.contains("||") || rule.contains(";") || - rule.contains("|") || rule.contains("`") || rule.contains("$") || - rule.contains("rm ") || rule.contains("dd ") || rule.contains("chmod ") || - rule.contains("chown ") || rule.contains("su ") || rule.contains("sudo ")) { - Log.w(TAG, "Rejecting potentially dangerous custom rule: " + rule); + String trimmed = rule.trim(); + + // Check for dangerous command chaining/substitution + if (trimmed.contains("&&") || trimmed.contains("||") || trimmed.contains(";") || + trimmed.contains("|") || trimmed.contains("`")) { + Log.w(TAG, "Rejecting potentially dangerous custom rule (command chaining): " + rule); + return null; + } + + // Check for dangerous commands + if (trimmed.contains("rm ") || trimmed.contains("dd ") || + trimmed.contains("chmod ") || trimmed.contains("chown ") || + trimmed.contains("su ") || trimmed.contains("sudo ")) { + Log.w(TAG, "Rejecting potentially dangerous custom rule (system modification): " + rule); + return null; + } + + // Allow $ only for whitelisted variables + if (trimmed.contains("$")) { + // Check if it's using allowed variables + String tempRule = trimmed; + tempRule = tempRule.replace("$IPTABLES", ""); + tempRule = tempRule.replace("$IP6TABLES", ""); + tempRule = tempRule.replace("$BUSYBOX", ""); + tempRule = tempRule.replace("$IPV6", ""); + + if (tempRule.contains("$")) { + Log.w(TAG, "Rejecting custom rule with non-whitelisted variables: " + rule); + return null; + } + } + + // Reject file sourcing (dot-source) - potential command injection vector + if (trimmed.startsWith(". ") || trimmed.startsWith("source ")) { + Log.w(TAG, "Rejecting file sourcing in custom rule (security risk): " + rule); return null; } - // Only allow basic iptables/ip6tables commands - if (!rule.startsWith("iptables ") && !rule.startsWith("ip6tables ") && - !rule.startsWith("-A ") && !rule.startsWith("-I ") && - !rule.startsWith("-D ") && !rule.startsWith("-F ") && - !rule.startsWith("-P ") && !rule.startsWith("-N ")) { + // Allow basic iptables commands (keep existing check) + if (!trimmed.startsWith("iptables ") && !trimmed.startsWith("ip6tables ") && + !trimmed.startsWith("$IPTABLES ") && !trimmed.startsWith("$IP6TABLES ") && + !trimmed.startsWith("-A ") && !trimmed.startsWith("-I ") && + !trimmed.startsWith("-D ") && !trimmed.startsWith("-F ") && + !trimmed.startsWith("-P ") && !trimmed.startsWith("-N ")) { Log.w(TAG, "Rejecting non-iptables rule: " + rule); return null; } - return rule; + return trimmed; + } + + public static String validateCustomRuleForStorage(String rule) { + if (rule == null) { + return null; + } + return sanitizeRule(rule); } private static void addCustomRules(String prefName, List cmds) { + addCustomRules(prefName, cmds, false); + } + + private static void addCustomRules(String prefName, List cmds, boolean ipv6) { + addCustomRules(prefName, cmds, ipv6, true); + } + + /** + * @param includeDatabaseRules add the per-app (direct) rules too. They are appended to the main + * chain, which only a full apply rebuilds, so a partial apply must + * not add them again (each network change used to add a copy). + */ + private static void addCustomRules(String prefName, List cmds, boolean ipv6, + boolean includeDatabaseRules) { String customRulesStr = G.pPrefs.getString(prefName, ""); - if (customRulesStr.isEmpty()) return; + if (!customRulesStr.isEmpty()) { + String[] customRules = customRulesStr.split("[\\r\\n]+"); + for (String rule : customRules) { + if (rule.matches(".*\\S.*")) { + // Sanitize the rule to prevent command injection + String sanitizedRule = sanitizeRule(rule.trim()); + if (sanitizedRule != null && !sanitizedRule.isEmpty()) { + cmds.add("#LITERAL# " + sanitizedRule); + } + } + } + } + + if (includeDatabaseRules && PREF_CUSTOMSCRIPT.equals(prefName)) { + addDatabaseCustomRules(cmds, ipv6); + } + } - String[] customRules = customRulesStr.split("[\\r\\n]+"); - for (String rule : customRules) { - if (rule.matches(".*\\S.*")) { - // Sanitize the rule to prevent command injection - String sanitizedRule = sanitizeRule(rule.trim()); - if (sanitizedRule != null && !sanitizedRule.isEmpty()) { - cmds.add("#LITERAL# " + sanitizedRule); + /** + * The direct (per-app) rules of the current profile. Built from the rule itself for the main + * chain of this user and the table's address family: IPv6 only with IPv6 support on, and a + * rule with a destination only in the table of its family. + */ + private static void addDatabaseCustomRules(List cmds, boolean ipv6) { + if (!G.enableCustomRules() || (ipv6 && !G.enableIPv6())) { + return; + } + String chain = getThreadSafeChainName(); + try { + List customRules = SQLite.select() + .from(CustomRule.class) + .where(CustomRule_Table.active.eq(true)) + .queryList(); + + for (CustomRule customRule : customRules) { + if (!AppRuleHelper.belongsToCurrentProfile(customRule)) { + continue; + } + String rule = AppRuleHelper.buildRule(AppRuleHelper.parseRuleName(customRule.getName()), chain, ipv6); + if (rule != null && rule.matches(".*\\S.*")) { + String sanitizedRule = sanitizeRule(rule.trim()); + if (sanitizedRule != null && !sanitizedRule.isEmpty()) { + cmds.add(sanitizedRule); + } } } + } catch (Exception e) { + Log.e(TAG, "Unable to load database custom rules", e); + } + } + + private static Set getLanDestinationRanges(InterfaceDetails cfg, boolean ipv6) { + LinkedHashSet ranges = new LinkedHashSet<>(); + if (ipv6) { + if (cfg != null) { + ranges.addAll(cfg.lanMaskV6); + } + ranges.addAll(Arrays.asList(LOCAL_RESERVED_IPV6_RANGES)); + } else { + if (cfg != null) { + ranges.addAll(cfg.lanMaskV4); + } + ranges.addAll(Arrays.asList(LOCAL_RESERVED_IPV4_RANGES)); } + return ranges; } /** @@ -612,6 +1017,11 @@ private static void addCustomRules(String prefName, List cmds) { * @param cmds command list */ private static void addInterfaceRouting(Context ctx, List cmds, boolean ipv6, String chainName) { + addInterfaceRouting(ctx, cmds, ipv6, chainName, null); + } + + private static void addInterfaceRouting(Context ctx, List cmds, boolean ipv6, String chainName, + List lanList) { try { //force only for v4 final InterfaceDetails cfg = InterfaceTracker.getCurrentCfg(ctx, !ipv6); @@ -631,7 +1041,7 @@ private static void addInterfaceRouting(Context ctx, List cmds, boolean } else { cmds.add("-A " + chainName + "-wifi-postcustom -j " + chainName + "-wifi-fork"); } - + if (cfg.isUsbTethered) { cmds.add("-A " + chainName + "-3g-postcustom -j " + chainName + "-usb-tether"); } else { @@ -643,26 +1053,24 @@ private static void addInterfaceRouting(Context ctx, List cmds, boolean } if (G.enableLAN() && !cfg.isWifiTethered) { - if (ipv6) { - if (!cfg.lanMaskV6.equals("")) { - cmds.add("-A " + chainName + "-wifi-fork -d " + cfg.lanMaskV6 + " -j " + chainName + "-wifi-lan"); - cmds.add("-A " + chainName + "-wifi-fork '!' -d " + cfg.lanMaskV6 + " -j " + chainName + "-wifi-wan"); - } else { - Log.i(TAG, "no ipv6 found: " + G.enableIPv6() + "," + cfg.lanMaskV6); - } - } else { - if (!cfg.lanMaskV4.equals("")) { - cmds.add("-A " + chainName + "-wifi-fork -d " + cfg.lanMaskV4 + " -j " + chainName + "-wifi-lan"); - cmds.add("-A " + chainName + "-wifi-fork '!' -d " + cfg.lanMaskV4 + " -j " + chainName + "-wifi-wan"); - } else { - Log.i(TAG, "no ipv4 found:" + G.enableIPv6() + "," + cfg.lanMaskV4); - } + // Support multiple LAN subnets (Issue #1362) plus reserved local/discovery ranges. + // Subnet-specific rules are added first, then a catch-all routes remaining traffic to WAN. + Set lanRanges = getLanDestinationRanges(cfg, ipv6); + if (lanRanges.isEmpty()) { + Log.i(TAG, "no LAN ranges found: " + G.enableIPv6() + "," + (ipv6 ? cfg.lanMaskV6 : cfg.lanMaskV4)); + } + for (String subnet : lanRanges) { + cmds.add("-A " + chainName + "-wifi-fork -d " + subnet + " -g " + chainName + "-wifi-lan"); } - if (cfg.lanMaskV4.equals("") && cfg.lanMaskV6.equals("")) { - Log.i(TAG, "No ipaddress found for LAN"); - // lets find one more time - //atleast allow internet - don't block completely - cmds.add("-A " + chainName + "-wifi-fork -j " + chainName + "-wifi-wan"); + // Catch-all: route everything not matching a LAN subnet to WAN + cmds.add("-A " + chainName + "-wifi-fork -j " + chainName + "-wifi-wan"); + + // Rebuild the LAN chain's per-UID rules so fastApply is self-healing. + // If lanList is null this path is skipped (e.g., during a full apply that already + // rebuilds staticChains separately). + if (lanList != null) { + cmds.add("#NOCHK# -F " + chainName + "-wifi-lan"); + addRulesForUidlist(cmds, lanList, chainName + "-wifi-lan", whitelist); } } else { cmds.add("-A " + chainName + "-wifi-fork -j " + chainName + "-wifi-wan"); @@ -674,32 +1082,49 @@ private static void addInterfaceRouting(Context ctx, List cmds, boolean cmds.add("-A " + chainName + "-3g-fork -j " + chainName + "-3g-home"); } - } catch (Exception e) { - Log.i(TAG, "Exception while applying shortRules " + e.getMessage()); + // The dynamic chains were already flushed above; running the rest of the script would + // leave them empty. Let the caller fail the apply instead. + throw new IllegalStateException("Unable to build interface routing rules", e); } + } + /** + * Add or update the LAN chain rule for a single UID. Used for incremental per-uid updates + * so LAN-selected apps retain RFC1918/multicast access without a full rule rebuild. + */ + static void addLanReservedUidDelta(List cmds, int uid, String chainName, boolean whitelist) { + if (uid < 0) return; + String action = whitelist ? " -j RETURN" : " -j " + chainName + "-wifi-lan-reject"; + // Remove any existing rule for this UID before re-adding (idempotent). + cmds.add("#NOCHK# -D " + chainName + "-wifi-lan -m owner --uid-owner " + uid + action); + cmds.add("-A " + chainName + "-wifi-lan -m owner --uid-owner " + uid + action); } public static String getSpecialAppName(int uid) { + // First, try special apps (AFWall+ specific entries) List packageInfoData = getSpecialData(); for (PackageInfoData infoData : packageInfoData) { if (infoData.uid == uid) { return infoData.names.get(0); } } - return ctx.getString(R.string.unknown_item); + + // If not found in special apps, use comprehensive UID resolver + return UidResolver.resolveUid(ctx, uid); } private static void applyShortRules(Context ctx, List cmds, boolean ipv6) { Log.i(TAG, "Setting OUTPUT chain to DROP"); cmds.add("-P OUTPUT DROP"); - /*FIXME: Adding custom rules might increase the time */ Log.i(TAG, "Applying custom rules"); - addCustomRules(Api.PREF_CUSTOMSCRIPT, cmds); + addCustomRules(Api.PREF_CUSTOMSCRIPT, cmds, ipv6, false); String chainName = getThreadSafeChainName(); - addInterfaceRouting(ctx, cmds, ipv6, chainName); + // Pass the current LAN UID list so fastApply also rebuilds the -wifi-lan chain, + // keeping LAN access self-healing across network-change routing refreshes. + List lanList = getDataSet().lanList; + addInterfaceRouting(ctx, cmds, ipv6, chainName, lanList); Log.i(TAG, "Setting OUTPUT chain to ACCEPT"); cmds.add("-P OUTPUT ACCEPT"); } @@ -713,11 +1138,17 @@ private static void applyShortRules(Context ctx, List cmds, boolean ipv6 */ private static boolean applyIptablesRulesImpl(final Context ctx, RuleDataSet ruleDataSet, final boolean showErrors, List out, boolean ipv6) { - return applyIptablesRulesImpl(ctx, ruleDataSet, showErrors, out, ipv6, null); + return applyIptablesRulesImpl(ctx, ruleDataSet, showErrors, out, ipv6, null, null); } - + + /** + * @param out receives the shell commands to run one by one + * @param rawOut if not null, receives the same commands before they are turned into shell + * commands (input for {@link IptablesRestorePlanner}) + */ private static boolean applyIptablesRulesImpl(final Context ctx, RuleDataSet ruleDataSet, final boolean showErrors, - List out, boolean ipv6, String threadSafeChainName) { + List out, boolean ipv6, String threadSafeChainName, + List rawOut) { if (ctx == null) { return false; } @@ -753,13 +1184,14 @@ private static boolean applyIptablesRulesImpl(final Context ctx, RuleDataSet rul cmds.add("-P OUTPUT DROP"); // Create and flush all chains first to ensure they exist + // Use NOCHK to avoid errors if chain already exists, then flush to ensure clean state for (String s : staticChains) { cmds.add("#NOCHK# -N " + chainName + s); - cmds.add("-F " + chainName + s); + cmds.add("#NOCHK# -F " + chainName + s); } for (String s : dynChains) { cmds.add("#NOCHK# -N " + chainName + s); - cmds.add("-F " + chainName + s); + cmds.add("#NOCHK# -F " + chainName + s); } @@ -782,11 +1214,22 @@ private static boolean applyIptablesRulesImpl(final Context ctx, RuleDataSet rul } // custom rules in afwall-{3g,wifi,reject} supersede everything else - addCustomRules(Api.PREF_CUSTOMSCRIPT, cmds); + addCustomRules(Api.PREF_CUSTOMSCRIPT, cmds, ipv6); + + // Loopback is self-device traffic, not LAN or WAN. Keep it out of + // the LAN split chains so local app services continue to work when + // LAN control is enabled in either firewall mode. + cmds.add("-A " + chainName + " -o lo -j RETURN"); + if (G.enableInbound()) { + cmds.add("-A " + chainName + "-input -i lo -j RETURN"); + } cmds.add("-A " + chainName + "-3g -j " + chainName + "-3g-postcustom"); cmds.add("-A " + chainName + "-wifi -j " + chainName + "-wifi-postcustom"); addRejectRules(cmds, chainName); + if (ipv6) { + addIpv6ControlTrafficRules(cmds, chainName); + } if (G.enableInbound()) { // we don't have any rules in the INPUT chain prohibiting inbound traffic, but @@ -795,6 +1238,12 @@ private static boolean applyIptablesRulesImpl(final Context ctx, RuleDataSet rul cmds.add("-A " + chainName + "-input -m state --state ESTABLISHED -j RETURN"); } + // Tor must redirect before interface chains so redirected traffic is not + // rejected as plain Wi-Fi/mobile before reaching the local Orbot ports. + if (G.enableTor()) { + addTorRules(cmds, ruleDataSet.torList, whitelist, ipv6, chainName); + } + addInterfaceRouting(ctx, cmds, ipv6, chainName); // send wifi, 3G, VPN packets to the appropriate dynamic chain based on interface @@ -819,6 +1268,13 @@ private static boolean applyIptablesRulesImpl(final Context ctx, RuleDataSet rul } } + /*if (G.enableLAN()) { + // Allow all Android system UIDs (0-9999) on loopback unconditionally + cmds.add("-A " + chainName + " -o lo -m owner --uid-owner 0:9999 -j RETURN"); + // Route remaining loopback traffic through the LAN chain for per-app control + cmds.add("-A " + chainName + " -o lo -j " + chainName + "-wifi-lan"); + }*/ + for (final String itf : ITFS_WIFI) { cmds.add("#NOCHK# -A " + chainName + " -o " + itf + " -j " + chainName + "-wifi"); } @@ -836,6 +1292,7 @@ private static boolean applyIptablesRulesImpl(final Context ctx, RuleDataSet rul if (containsUidOrAny(ruleDataSet.wifiList, SPECIAL_UID_TETHER)) { // DHCP replies to client addRuleForUsers(cmds, users_dhcp, "-A " + chainName + "-wifi-tether", "-p udp --sport=67 --dport=68" + action); + addTetherDhcpReplyRule(cmds, chainName + "-wifi-tether", action); // DNS replies to client addRuleForUsers(cmds, users_dns, "-A " + chainName + "-wifi-tether", "-p udp --sport=53" + action); addRuleForUsers(cmds, users_dns, "-A " + chainName + "-wifi-tether", "-p tcp --sport=53" + action); @@ -846,13 +1303,15 @@ private static boolean applyIptablesRulesImpl(final Context ctx, RuleDataSet rul if (containsUidOrAny(ruleDataSet.wifiList, SPECIAL_UID_TETHER) || containsUidOrAny(ruleDataSet.tetherList, SPECIAL_UID_TETHER)) { // DHCP replies to USB tethered client addRuleForUsers(cmds, users_dhcp, "-A " + chainName + "-usb-tether", "-p udp --sport=67 --dport=68" + action); - // DNS replies to USB tethered client + addTetherDhcpReplyRule(cmds, chainName + "-usb-tether", action); + // DNS replies to USB tethered client addRuleForUsers(cmds, users_dns, "-A " + chainName + "-usb-tether", "-p udp --sport=53" + action); addRuleForUsers(cmds, users_dns, "-A " + chainName + "-usb-tether", "-p tcp --sport=53" + action); } if (containsUidOrAny(ruleDataSet.tetherList, SPECIAL_UID_TETHER)) { // DHCP replies to client addRuleForUsers(cmds, users_dhcp, "-A " + chainName + "-tether", "-p udp --sport=67 --dport=68" + action); + addTetherDhcpReplyRule(cmds, chainName + "-tether", action); // DNS replies to client addRuleForUsers(cmds, users_dns, "-A " + chainName + "-tether", "-p udp --sport=53" + action); addRuleForUsers(cmds, users_dns, "-A " + chainName + "-tether", "-p tcp --sport=53" + action); @@ -878,7 +1337,7 @@ private static boolean applyIptablesRulesImpl(final Context ctx, RuleDataSet rul // on the LAN - use specific DNS servers instead of opening to all LAN hosts if (whitelist) { // Add rules for specific DNS servers instead of all LAN hosts - addDnsServerRules(cmds, cfg, chainName + "-wifi-lan", false); + addDnsServerRules(cmds, cfg, chainName + "-wifi-lan", ipv6); // Fallback: if no specific DNS servers found, use the old broad rule if (cfg.dnsServersV4.isEmpty() && cfg.dnsServersV6.isEmpty()) { @@ -909,16 +1368,19 @@ private static boolean applyIptablesRulesImpl(final Context ctx, RuleDataSet rul addRulesForUidlist(cmds, ruleDataSet.lanList, chainName + "-wifi-lan", whitelist); addRulesForUidlist(cmds, ruleDataSet.vpnList, chainName + "-vpn", whitelist); addRulesForUidlist(cmds, ruleDataSet.tetherList, chainName + "-tether", whitelist); - if (G.enableTor()) { - addTorRules(cmds, ruleDataSet.torList, whitelist, ipv6, chainName); - } + cmds.add("-P OUTPUT ACCEPT"); } catch (Exception e) { - Log.e(e.getClass().getName(), e.getMessage(), e); + // Never run a partially built rule set: it would flush the chains, set OUTPUT to DROP + // and stop before the per-app rules, while being reported as a success. + Log.e(TAG, "Unable to build " + (ipv6 ? "IPv6" : "IPv4") + " rules", e); + return false; } + if (rawOut != null) { + rawOut.addAll(cmds); + } iptablesCommands(cmds, out, ipv6); - Log.i(TAG, "Total # of rules for " + (ipv6 ? "v6": "v4") + " " + cmds.size()); return true; } @@ -943,8 +1405,10 @@ private static void iptablesCommands(List in, List out, boolean String ipPath = getBinaryPath(G.ctx, ipv6); String waitTime = ""; - if(G.ip_path().equals("system") && G.addDelay()) { - waitTime = " -w 1"; + if(G.ip_path().equals("system")) { + // Always use wait flag with system iptables to prevent lock contention + // (in the form this iptables version supports) + waitTime = IptablesVersion.waitOption(iptablesVersion(ipPath)); } boolean firstLit = true; for (String s : in) { @@ -959,7 +1423,9 @@ private static void iptablesCommands(List in, List out, boolean + "export IPV6=" + (ipv6 ? "1" : "0") + "; " + "true"); } - out.add(s.replaceFirst("^#LITERAL# ", "")); + // custom script line: a failure is reported, but no longer aborts the whole apply + // (e.g. an IPv4-only line in the IPv6 pass, #1493) + out.add("#WARN# " + s.replaceFirst("^#LITERAL# ", "")); } else if (s.matches("#NOCHK# .*")) { out.add(s.replaceFirst("^#NOCHK# ", "#NOCHK# " + ipPath + " ")); } else { @@ -1004,143 +1470,430 @@ public static void waitAndTerminate(ExecutorService executorService) { } } - public static void applySavedIptablesRules(Context ctx, boolean showErrors, RootCommand callback) { - synchronized (GLOBAL_STATUS_LOCK) { - if(!globalStatus) { - Log.i(TAG, "Using applySavedIptablesRules"); - globalStatus = true; - + private static void completeRootCommandFailure(Context ctx, RootCommand callback, String command, Throwable throwable) { + if (callback == null || callback.done) { + return; + } + callback.lastCommand = command; + if (throwable != null && throwable.getMessage() != null) { + callback.lastCommandResult = new StringBuilder(throwable.getMessage()); + } + deliverRootCommandResult(ctx, callback, 1, true); + } + + /** + * Complete a RootCommand that was not itself run on the root shell: record the result, + * invoke its callback and show its toast, the same way the shell does for submitted commands. + */ + private static void deliverRootCommandResult(Context ctx, RootCommand target, int exitCode, boolean showToast) { + target.exitCode = exitCode; + target.done = true; + try { + if (target.cb != null) { + target.cb.cbFunc(target); + } + } catch (Throwable t) { + Log.e(TAG, "RootCommand callback failed: " + android.util.Log.getStackTraceString(t)); + } + if (showToast && ctx != null) { + int toast = exitCode == 0 ? target.successToast : target.failureToast; + if (toast != RootShellService.NO_TOAST) { + sendToastBroadcast(ctx.getApplicationContext(), ctx.getString(toast)); + } + } + } + + // Apply requests that arrive while an apply is running are coalesced into a single full apply + // that starts as soon as the current one finishes (it reads the latest saved rules). Each + // request's callback receives that apply's result. Guarded by GLOBAL_STATUS_LOCK. + private static final List pendingApplyCallbacks = new ArrayList<>(); + // purgeGeneration at the time each pending request was queued (same order as above) + private static final List pendingApplyGenerations = new ArrayList<>(); + private static boolean pendingApplyShowErrors; + // Incremented by purgeIptables(). A queued apply requested before a later purge (e.g. the + // firewall was disabled meanwhile) is dropped instead of re-adding rules after the purge. + private static long purgeGeneration; + private static final java.util.concurrent.atomic.AtomicInteger disablesInProgress = + new java.util.concurrent.atomic.AtomicInteger(); + // Incremented on every connectivity/tether broadcast, including ones ignored because the + // firewall is still being enabled, so an apply built on a stale network config is detected. + private static final java.util.concurrent.atomic.AtomicLong networkChangeSeq = + new java.util.concurrent.atomic.AtomicLong(); + + public static void noteNetworkChange() { + networkChangeSeq.incrementAndGet(); + } + + // Wrap the caller-supplied callback so that globalStatus and the up-to-date flag are only + // reset once the entire (IPv4 + IPv6) command sequence has actually finished. + private static RootCommand wrapApplyCompletionCallback(Context ctx, RootCommand callback) { + final Context appCtx = ctx != null ? ctx.getApplicationContext() : G.getContext(); + // the rules are built right after this from the current network config + final long networkSeqAtBuild = networkChangeSeq.get(); + final RootCommand completionCallback = callback == null ? new RootCommand() : callback; + final RootCommand.Callback originalCallback = completionCallback.cb; + completionCallback.setCallback(new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + // before the callbacks: they may post the error notification, which shows the details + Notifications.recordApplyResult(state); try { - RuleDataSet dataSet = getDataSet(); - List ipv4cmds = new ArrayList<>(); - List ipv6cmds = new ArrayList<>(); - - // Create thread-safe chain name for this execution - final String chainName = getThreadSafeChainName(); - - // Apply IPv4 rules first (sequentially) - try { - Log.i(TAG, "Applying IPv4 rules"); - applyIptablesRulesImpl(ctx, dataSet, showErrors, ipv4cmds, false, chainName); - applySavedIp4tablesRules(ctx, ipv4cmds, callback); - Log.i(TAG, "Successfully applied IPv4 rules"); - } catch (Exception e) { - Log.e(TAG, "Error applying IPv4 rules", e); - throw new RuntimeException(e); + if (originalCallback != null) { + originalCallback.cbFunc(state); } - - // Apply IPv6 rules second (sequentially after IPv4) - if (G.enableIPv6()) { - try { - Log.i(TAG, "Applying IPv6 rules"); - applyIptablesRulesImpl(ctx, dataSet, showErrors, ipv6cmds, true, chainName); - applySavedIp6tablesRules(ctx, ipv6cmds, new RootCommand()); - Log.i(TAG, "Successfully applied IPv6 rules"); - } catch (Exception e) { - Log.e(TAG, "Error applying IPv6 rules", e); - throw new RuntimeException(e); - } + } finally { + onApplyFinished(appCtx, state.exitCode == 0, networkSeqAtBuild); + Notifications.onApplyFinished(appCtx, state.exitCode == 0); + if (!state.warnings.isEmpty() && appCtx != null) { + customScriptWarningNotification(appCtx, new ArrayList<>(state.warnings)); } - - Log.i(TAG, "Successfully applied all firewall rules"); + } + } + }); + return completionCallback; + } - } catch (Exception e) { - Log.e(TAG, "Error applying rules", e); - } finally { - globalStatus = false; - setRulesUpToDate(true); + private static void onApplyFinished(Context ctx, boolean success, long networkSeqAtBuild) { + final List pending = new ArrayList<>(); + final List overtaken = new ArrayList<>(); + final Context pendingCtx; + final boolean pendingShowErrors; + synchronized (GLOBAL_STATUS_LOCK) { + globalStatus = false; + setRulesUpToDate(success); + for (int i = 0; i < pendingApplyCallbacks.size(); i++) { + if (pendingApplyGenerations.get(i) < purgeGeneration) { + overtaken.add(pendingApplyCallbacks.get(i)); + } else { + pending.add(pendingApplyCallbacks.get(i)); } - } else { - Log.i(TAG, "ignore applySavedIptablesRules as existing thread running"); } + pendingApplyCallbacks.clear(); + pendingApplyGenerations.clear(); + pendingCtx = ctx != null ? ctx : G.getContext(); + pendingShowErrors = pendingApplyShowErrors; + pendingApplyShowErrors = false; + } + + if (!success && ctx != null) { + // A failed script can stop right after "-P OUTPUT DROP" and leave the device without + // any network. Put the chain policies back to what the user configured. + Log.w(TAG, "Rule apply failed; restoring configured default chain policies"); + applyDefaultChains(ctx, new RootCommand()); + } + + if (success && pending.isEmpty() && ctx != null && networkChangeSeq.get() != networkSeqAtBuild) { + // The network changed while these rules were being applied (e.g. while enabling, when + // the change is ignored); re-check the interface routing once things have settled. + Log.i(TAG, "Network changed during rule apply; re-checking interface rules"); + NetworkChangeDebouncer.scheduleNetworkChange(ctx, InterfaceTracker.CONNECTIVITY_CHANGE); + } + + if (!overtaken.isEmpty()) { + Log.i(TAG, "Dropping " + overtaken.size() + " queued apply request(s) superseded by a purge"); + for (RootCommand request : overtaken) { + deliverRootCommandResult(pendingCtx, request, 0, false); + } + } + if (pending.isEmpty()) { + return; } + Log.i(TAG, "Running " + pending.size() + " apply request(s) queued during the previous apply"); + applySavedIptablesRules(pendingCtx, pendingShowErrors, new RootCommand() + .setCallback(new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + for (RootCommand request : pending) { + request.lastCommand = state.lastCommand; + request.lastCommandResult = state.lastCommandResult; + deliverRootCommandResult(pendingCtx, request, state.exitCode, true); + } + } + })); } + private static RootCommand newApplyPart(RootCommand parent, List cmds, boolean ipv6) { + RootCommand part = new RootCommand() + .setRetryExitCode(IPTABLES_TRY_AGAIN) + .setReopenShell(parent.reopenShell); + part.res = parent.res; + part.isv6 = ipv6; + part.setCommmands(cmds); + return part; + } - private static RuleDataSet getDataSet() { - initSpecial(); + // "iptables --version" output per binary path + private static final Map iptablesVersions = new java.util.concurrent.ConcurrentHashMap<>(); - final String savedPkg_wifi_uid = G.pPrefs.getString(PREF_WIFI_PKG_UIDS, ""); - final String savedPkg_3g_uid = G.pPrefs.getString(PREF_3G_PKG_UIDS, ""); - final String savedPkg_roam_uid = G.pPrefs.getString(PREF_ROAMING_PKG_UIDS, ""); - final String savedPkg_vpn_uid = G.pPrefs.getString(PREF_VPN_PKG_UIDS, ""); - final String savedPkg_tether_uid = G.pPrefs.getString(PREF_TETHER_PKG_UIDS, ""); - final String savedPkg_lan_uid = G.pPrefs.getString(PREF_LAN_PKG_UIDS, ""); - final String savedPkg_tor_uid = G.pPrefs.getString(PREF_TOR_PKG_UIDS, ""); + /** + * @return "iptables --version" output of the binary (cached), or null if it can't be run + */ + static String iptablesVersion(String binPath) { + if (binPath == null) { + return null; + } + String cached = iptablesVersions.get(binPath); + if (cached != null) { + return cached.isEmpty() ? null : cached; + } + String version = ""; + try { + Process process = new ProcessBuilder(binPath, "--version").redirectErrorStream(true).start(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line = reader.readLine(); + if (line != null) { + version = line.trim(); + } + } + process.waitFor(); + } catch (Exception e) { + Log.w(TAG, "Unable to read the version of " + binPath + ": " + e.getMessage()); + } + Log.i(TAG, binPath + " version: " + (version.isEmpty() ? "unknown" : version)); + iptablesVersions.put(binPath, version); + return version.isEmpty() ? null : version; + } - return new RuleDataSet(getListFromPref(savedPkg_wifi_uid), - getListFromPref(savedPkg_3g_uid), - getListFromPref(savedPkg_roam_uid), - getListFromPref(savedPkg_vpn_uid), - getListFromPref(savedPkg_tether_uid), - getListFromPref(savedPkg_lan_uid), - getListFromPref(savedPkg_tor_uid)); + private static final String RESTORE_SUFFIX = "-restore"; + // iptables-restore failed for this family (0 = IPv4, 1 = IPv6) in this process; use the + // per-command script from now on. + private static final boolean[] restoreUnavailable = new boolean[2]; + /** + * Commands that load a full rule set with one iptables-restore (see + * {@link IptablesRestorePlanner}) followed by the few built-in chain commands, or null when the + * per-command script has to be used. + */ + private static List buildRestoreCommands(Context ctx, List rawCmds, boolean ipv6) { + if (restoreUnavailable[ipv6 ? 1 : 0]) { + return null; + } + IptablesRestorePlanner.Plan plan = IptablesRestorePlanner.plan(rawCmds); + if (plan == null) { + Log.i(TAG, "Rules can't be loaded with iptables-restore (e.g. custom script); applying one by one"); + return null; + } + String restoreBin = getRestoreBinary(ctx, ipv6); + if (restoreBin == null) { + restoreUnavailable[ipv6 ? 1 : 0] = true; + Log.i(TAG, "No iptables-restore available for " + (ipv6 ? "IPv6" : "IPv4") + "; applying rules one by one"); + return null; + } + File file = new File(ctx.getFilesDir(), ipv6 ? "rules6.restore" : "rules4.restore"); + try (FileOutputStream fos = new FileOutputStream(file)) { + fos.write(plan.restoreInput.getBytes(StandardCharsets.UTF_8)); + } catch (IOException e) { + Log.e(TAG, "Unable to write " + file, e); + return null; + } + Log.i(TAG, "Loading " + (ipv6 ? "IPv6" : "IPv4") + " rules with " + restoreBin); + List out = new ArrayList<>(); + String ipPath = getBinaryPath(ctx, ipv6); + out.add(restoreBin + IptablesVersion.restoreWaitOption(iptablesVersion(ipPath)) + + " --noflush " + file.getAbsolutePath()); + iptablesCommands(plan.postCommands, out, ipv6); + return out; } /** - * Purge and re-add all saved rules (not in-memory ones). - * This is much faster than just calling "applyIptablesRules", since it don't need to read installed applications. - * - * @param ctx application context (mandatory) - * @param callback If non-null, use a callback instead of blocking the current thread + * @return iptables-restore / ip6tables-restore matching the configured iptables binary, or null */ - public static boolean applySavedIp4tablesRules(Context ctx, List cmds, RootCommand callback) { - if (ctx == null) { - return false; + private static String getRestoreBinary(Context ctx, boolean ipv6) { + String ipPath = getBinaryPath(ctx, ipv6); + if (ipPath == null || !ipPath.startsWith("/")) { + return null; } - try { - Log.i(TAG, "Using applySaved4IptablesRules"); - callback.setRetryExitCode(IPTABLES_TRY_AGAIN).run(ctx, cmds); - return true; - } catch (Exception e) { - Log.d(TAG, "Exception while applying rules: " + e.getMessage()); - applyDefaultChains(ctx, callback); - return false; + File restore = new File(ipPath + RESTORE_SUFFIX); + String builtinDir = ctx.getDir("bin", 0).getAbsolutePath() + "/"; + if (ipPath.startsWith(builtinDir) && !restore.exists()) { + // The bundled binary is a multi-call xtables binary that runs as iptables-restore when + // invoked under that name. + try { + android.system.Os.symlink(new File(ipPath).getName(), restore.getAbsolutePath()); + } catch (Exception e) { + Log.w(TAG, "Unable to create " + restore + ": " + e.getMessage()); + return null; + } } + return restore.exists() ? restore.getAbsolutePath() : null; } + /** + * Run the IPv4/IPv6 parts of an apply back to back and report one combined result to + * {@code parent}. If a part fails, the remaining parts are skipped. A part that was loaded + * with iptables-restore and whose restore failed is re-run with its per-command fallback. + */ + private static void runApplyParts(final Context ctx, final RootCommand parent, final List parts, + final List> fallbacks) { + final int[] remaining = {parts.size()}; + final RootCommand[] failed = {null}; + for (int i = 0; i < parts.size(); i++) { + final int index = i; + parts.get(i).setCallback(new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + // parts run one after another on the root shell thread, so no locking needed + List fallback = fallbacks.get(index); + if (state.exitCode != 0 && fallback != null && state.lastCommand != null + && state.lastCommand.contains(RESTORE_SUFFIX + " ")) { + // The restore itself failed; each table commits atomically, so nothing half + // done was left behind. Don't try it again this session for this family. + restoreUnavailable[state.isv6 ? 1 : 0] = true; + Log.w(TAG, "iptables-restore failed (exit " + state.exitCode + "): " + + state.lastCommandResult + "; applying " + (state.isv6 ? "IPv6" : "IPv4") + + " rules one by one"); + fallbacks.set(index, null); + RootCommand retry = newApplyPart(parent, fallback, state.isv6); + retry.setCallback(this); + parts.set(index, retry); + RootCommand.runAll(ctx, java.util.Collections.singletonList(retry)); + return; + } + parent.warnings.addAll(state.warnings); + if (state.exitCode != 0 && failed[0] == null) { + failed[0] = state; + for (int j = index + 1; j < parts.size(); j++) { + parts.get(j).getCommmands().clear(); + } + } + if (--remaining[0] == 0) { + RootCommand result = failed[0] != null ? failed[0] : state; + parent.lastCommand = result.lastCommand; + parent.lastCommandResult = result.lastCommandResult; + deliverRootCommandResult(ctx, parent, result.exitCode, true); + } + } + }); + } + RootCommand.runAll(ctx, parts); + } - public static boolean applySavedIp6tablesRules(Context ctx, List cmds, RootCommand callback) { - if (ctx == null) { - return false; + public static void applySavedIptablesRules(Context ctx, boolean showErrors, RootCommand callback) { + synchronized (GLOBAL_STATUS_LOCK) { + if (globalStatus) { + // Don't report "busy" as a failure: callers treat failures as a broken firewall. + Log.i(TAG, "Apply already in progress; queued to run after it finishes"); + pendingApplyCallbacks.add(callback != null ? callback : new RootCommand()); + pendingApplyGenerations.add(purgeGeneration); + pendingApplyShowErrors |= showErrors; + return; + } + globalStatus = true; } + // status notification: "applying rules" + Notifications.onApplyStarted(ctx); + + final RootCommand completionCallback = wrapApplyCompletionCallback(ctx, callback); try { - Log.i(TAG, "Using applySavedIp6tablesRules"); - callback.setRetryExitCode(IPTABLES_TRY_AGAIN).run(ctx, cmds,true); - return true; + Log.i(TAG, "Starting full firewall rules apply"); + RuleDataSet dataSet = getDataSet(); + // Create thread-safe chain name for this execution + final String chainName = getThreadSafeChainName(); + + // Build both families up front and queue them together: IPv6 still runs after IPv4, + // but nothing else (e.g. a purge) can run in between. + // Each family is loaded with one iptables-restore when possible (atomic, and far faster + // than one full table rewrite per command); the per-command script is the fallback. + List parts = new ArrayList<>(); + List> fallbacks = new ArrayList<>(); + List ipv4cmds = new ArrayList<>(); + List ipv4raw = new ArrayList<>(); + if (!applyIptablesRulesImpl(ctx, dataSet, showErrors, ipv4cmds, false, chainName, ipv4raw)) { + completeRootCommandFailure(ctx, completionCallback, "build IPv4 rules", null); + return; + } + List ipv4restore = buildRestoreCommands(ctx, ipv4raw, false); + parts.add(newApplyPart(completionCallback, ipv4restore != null ? ipv4restore : ipv4cmds, false)); + fallbacks.add(ipv4restore != null ? ipv4cmds : null); + if (G.enableIPv6()) { + List ipv6cmds = new ArrayList<>(); + List ipv6raw = new ArrayList<>(); + if (!applyIptablesRulesImpl(ctx, dataSet, showErrors, ipv6cmds, true, chainName, ipv6raw)) { + completeRootCommandFailure(ctx, completionCallback, "build IPv6 rules", null); + return; + } + List ipv6restore = buildRestoreCommands(ctx, ipv6raw, true); + parts.add(newApplyPart(completionCallback, ipv6restore != null ? ipv6restore : ipv6cmds, true)); + fallbacks.add(ipv6restore != null ? ipv6cmds : null); + } + runApplyParts(ctx, completionCallback, parts, fallbacks); + Log.i(TAG, "Submitted firewall rule command sequence"); } catch (Exception e) { - Log.d(TAG, "Exception while applying rules: " + e.getMessage()); - applyDefaultChains(ctx, callback); - return false; + Log.e(TAG, "Error applying rules", e); + completeRootCommandFailure(ctx, completionCallback, "applySavedIptablesRules", e); } } + private static RuleDataSet getDataSet() { + initSpecial(); + + final String savedPkg_wifi_uid = G.pPrefs.getString(PREF_WIFI_PKG_UIDS, ""); + final String savedPkg_3g_uid = G.pPrefs.getString(PREF_3G_PKG_UIDS, ""); + final String savedPkg_roam_uid = G.pPrefs.getString(PREF_ROAMING_PKG_UIDS, ""); + final String savedPkg_vpn_uid = G.pPrefs.getString(PREF_VPN_PKG_UIDS, ""); + final String savedPkg_tether_uid = G.pPrefs.getString(PREF_TETHER_PKG_UIDS, ""); + final String savedPkg_lan_uid = G.pPrefs.getString(PREF_LAN_PKG_UIDS, ""); + final String savedPkg_tor_uid = G.pPrefs.getString(PREF_TOR_PKG_UIDS, ""); + + + List wifiList = getListFromPref(savedPkg_wifi_uid); + List dataList = getListFromPref(savedPkg_3g_uid); + + + // Warn if no applications are configured - this means no blocking will occur + if (wifiList.isEmpty() && dataList.isEmpty()) { + Log.w(TAG, "WARNING: No applications configured for firewall rules - firewall will not block any traffic!"); + Log.w(TAG, "Please configure applications in AFWall+ main screen and apply rules."); + } + + return new RuleDataSet(wifiList, + dataList, + getListFromPref(savedPkg_roam_uid), + getListFromPref(savedPkg_vpn_uid), + getListFromPref(savedPkg_tether_uid), + getListFromPref(savedPkg_lan_uid), + getListFromPref(savedPkg_tor_uid)); + + } + + /** + * Re-apply only the interface routing rules after a network change. Falls back to a full apply + * when the rules are not known to be up to date, or when another apply is in progress (the + * queued full apply then picks up the new network state). + */ public static boolean fastApply(Context ctx, RootCommand callback) { + boolean fullApply; + synchronized (GLOBAL_STATUS_LOCK) { + fullApply = globalStatus || !getRulesUpToDate(); + if (!fullApply) { + globalStatus = true; + } + } + if (fullApply) { + Log.i(TAG, "Using full Apply"); + applySavedIptablesRules(ctx, true, callback); + return true; + } + + final RootCommand completionCallback = wrapApplyCompletionCallback(ctx, callback); try { - if (!getRulesUpToDate()) { - Log.i(TAG, "Using full Apply"); - applySavedIptablesRules(ctx, true, callback); - } else { - Log.i(TAG, "Using fastApply"); - List out = new ArrayList(); - List cmds; - cmds = new ArrayList(); - applyShortRules(ctx, cmds, false); - iptablesCommands(cmds, out, false); - if (G.enableIPv6()) { - cmds = new ArrayList(); - applyShortRules(ctx, cmds, true); - iptablesCommands(cmds, out, true); - } - callback.setRetryExitCode(IPTABLES_TRY_AGAIN).run(ctx, out); + Log.i(TAG, "Using fastApply"); + List out = new ArrayList(); + List cmds = new ArrayList(); + applyShortRules(ctx, cmds, false); + iptablesCommands(cmds, out, false); + if (G.enableIPv6()) { + cmds = new ArrayList(); + applyShortRules(ctx, cmds, true); + iptablesCommands(cmds, out, true); } + // up-to-date flag is set from the real result by the completion callback + completionCallback.setRetryExitCode(IPTABLES_TRY_AGAIN).run(ctx, out); } catch (Exception e) { - Log.d(TAG, "Exception while applying rules: " + e.getMessage()); - applyDefaultChains(ctx, callback); + Log.e(TAG, "Exception in fastApply: " + e.getMessage(), e); + completeRootCommandFailure(ctx, completionCallback, "fastApply", e); } - setRulesUpToDate(true); return true; } @@ -1214,24 +1967,37 @@ public static RuleDataSet generateRules(Context ctx, List apps, } } - String wifi = android.text.TextUtils.join("|", newpkg_wifi); - String data = android.text.TextUtils.join("|", newpkg_3g); - String roam = android.text.TextUtils.join("|", newpkg_roam); - String vpn = android.text.TextUtils.join("|", newpkg_vpn); - String tether = android.text.TextUtils.join("|", newpkg_tether); - String lan = android.text.TextUtils.join("|", newpkg_lan); - String tor = android.text.TextUtils.join("|", newpkg_tor); // save the new list of UIDs if (store) { + // Only the apps in the list change; UIDs not shown there (other profiles with dual + // apps off, apps without INTERNET with "show all apps" off, ...) keep their rules, + // and so do the lists of connection types that are switched off. Rebuilding the + // lists from the shown apps alone deleted all of those. + Set shown = new HashSet<>(); + for (PackageInfoData app : apps) { + if (app != null) { + shown.add(app.uid); + } + } SharedPreferences prefs = ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); Editor edit = prefs.edit(); - edit.putString(PREF_WIFI_PKG_UIDS, wifi); - edit.putString(PREF_3G_PKG_UIDS, data); - edit.putString(PREF_ROAMING_PKG_UIDS, roam); - edit.putString(PREF_VPN_PKG_UIDS, vpn); - edit.putString(PREF_TETHER_PKG_UIDS, tether); - edit.putString(PREF_LAN_PKG_UIDS, lan); - edit.putString(PREF_TOR_PKG_UIDS, tor); + edit.putString(PREF_WIFI_PKG_UIDS, UidListParser.merge(prefs.getString(PREF_WIFI_PKG_UIDS, ""), shown, newpkg_wifi)); + edit.putString(PREF_3G_PKG_UIDS, UidListParser.merge(prefs.getString(PREF_3G_PKG_UIDS, ""), shown, newpkg_3g)); + if (G.enableRoam()) { + edit.putString(PREF_ROAMING_PKG_UIDS, UidListParser.merge(prefs.getString(PREF_ROAMING_PKG_UIDS, ""), shown, newpkg_roam)); + } + if (G.enableVPN()) { + edit.putString(PREF_VPN_PKG_UIDS, UidListParser.merge(prefs.getString(PREF_VPN_PKG_UIDS, ""), shown, newpkg_vpn)); + } + if (G.enableTether()) { + edit.putString(PREF_TETHER_PKG_UIDS, UidListParser.merge(prefs.getString(PREF_TETHER_PKG_UIDS, ""), shown, newpkg_tether)); + } + if (G.enableLAN()) { + edit.putString(PREF_LAN_PKG_UIDS, UidListParser.merge(prefs.getString(PREF_LAN_PKG_UIDS, ""), shown, newpkg_lan)); + } + if (G.enableTor()) { + edit.putString(PREF_TOR_PKG_UIDS, UidListParser.merge(prefs.getString(PREF_TOR_PKG_UIDS, ""), shown, newpkg_tor)); + } edit.apply(); } else { dataSet = new RuleDataSet(new ArrayList<>(newpkg_wifi), @@ -1256,21 +2022,59 @@ public static RuleDataSet generateRules(Context ctx, List apps, * @return true if the rules were purged */ public static void purgeIptables(Context ctx, boolean showErrors, RootCommand callback) { + synchronized (GLOBAL_STATUS_LOCK) { + purgeGeneration++; + } + // Until the purge's callback has updated the enabled flag, automatic applies (boot, + // network change) must not re-add the rules based on the old "enabled" state. + final RootCommand purgeCallback = callback != null ? callback : new RootCommand(); + final RootCommand.Callback originalCallback = purgeCallback.cb; + disablesInProgress.incrementAndGet(); + purgeCallback.setCallback(new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + try { + if (originalCallback != null) { + originalCallback.cbFunc(state); + } + } finally { + disablesInProgress.decrementAndGet(); + } + } + }); + try { + submitPurge(ctx, purgeCallback); + } catch (Exception e) { + Log.e(TAG, "Unable to build purge commands", e); + completeRootCommandFailure(ctx, purgeCallback, "purgeIptables", e); + } + } + + /** + * @return true while a purge (firewall disable) has been requested but not finished yet + */ + public static boolean isDisableInProgress() { + return disablesInProgress.get() > 0; + } + + private static void submitPurge(Context ctx, RootCommand callback) { String chainName = getThreadSafeChainName(); List cmds = new ArrayList<>(); List cmdsv4 = new ArrayList<>(); List out = new ArrayList<>(); + // The chains don't exist if the rules were never applied (or after a reboot before the + // first apply); a missing chain must not make the purge fail. for (String s : staticChains) { - cmds.add("-F " + chainName + s); + cmds.add("#NOCHK# -F " + chainName + s); } for (String s : dynChains) { - cmds.add("-F " + chainName + s); + cmds.add("#NOCHK# -F " + chainName + s); } if (G.enableTor()) { for (String s : natChains) { - cmdsv4.add("-t nat -F " + chainName + s); + cmdsv4.add("#NOCHK# -t nat -F " + chainName + s); } cmdsv4.add("#NOCHK# -t nat -D OUTPUT -j " + chainName); } else { @@ -1284,13 +2088,54 @@ public static void purgeIptables(Context ctx, boolean showErrors, RootCommand ca //cmds.add("-D OUTPUT -j " + chainName); if (G.enableInbound()) { - cmds.add("-D INPUT -j " + chainName + "-input"); + cmds.add("#NOCHK# -D INPUT -j " + chainName + "-input"); } addCustomRules(Api.PREF_CUSTOMSCRIPT2, cmds); - + + // Execute the purge commands and call the callback + Log.i(TAG, "Executing purge commands for IPv4"); + cmds.addAll(cmdsv4); + iptablesCommands(cmds, out, false); + + if (G.enableIPv6()) { + Log.i(TAG, "Executing purge commands for IPv6"); + List cmdsv6 = new ArrayList<>(); + for (String s : staticChains) { + cmdsv6.add("#NOCHK# -F " + chainName + s); + } + for (String s : dynChains) { + cmdsv6.add("#NOCHK# -F " + chainName + s); + } + cmdsv6.add("#NOCHK# -D OUTPUT -j " + chainName); + cmdsv6.add("-P OUTPUT ACCEPT"); + if (G.enableInbound()) { + cmdsv6.add("#NOCHK# -D INPUT -j " + chainName + "-input"); + } + iptablesCommands(cmdsv6, out, true); + } + + Log.i(TAG, "Submitted purge commands"); + callback.setRetryExitCode(IPTABLES_TRY_AGAIN).run(ctx, out); } + private static void addTetherDhcpReplyRule(List cmds, String chain, String action) { + // dnsmasq can run under device-specific app UIDs, so the special tethering entry + // must allow DHCP replies by port instead of relying only on a fixed UID list. + cmds.add("-A " + chain + " -p udp --sport=67 --dport=68" + action); + } + + private static void addIpv6ControlTrafficRules(List cmds, String chainName) { + // IPv6 connectivity depends on router and neighbor discovery before app UID rules match. + String[] icmpv6Types = {"133", "134", "135", "136"}; + for (String type : icmpv6Types) { + cmds.add("-A " + chainName + " -p ipv6-icmp --icmpv6-type " + type + " -j RETURN"); + if (G.enableInbound()) { + cmds.add("-A " + chainName + "-input -p ipv6-icmp --icmpv6-type " + type + " -j RETURN"); + } + } + } + /** * Add DNS-specific iptables rules for identified DNS servers instead of broad LAN access */ @@ -1390,8 +2235,12 @@ public static void updateLogRules(Context ctx, RootCommand callback) { } String chainName = getThreadSafeChainName(); List cmds = new ArrayList(); - cmds.add("#NOCHK# -N " + chainName + "-reject"); - cmds.add("-F " + chainName + "-reject"); + // addRejectRules() fills all reject chains, so all of them have to be flushed first; + // flushing only the main one piled up NFLOG/REJECT pairs on every log setting change. + for (String suffix : REJECT_CHAIN_SUFFIXES) { + cmds.add("#NOCHK# -N " + chainName + suffix); + cmds.add("-F " + chainName + suffix); + } addRejectRules(cmds, chainName); apply46(ctx, cmds, callback); } @@ -1437,60 +2286,51 @@ public static List fetchLogs() { * @param callback Callback for completion status */ public static void runIfconfig(Context ctx, RootCommand callback) { - // Android 16+ fallback: try system ifconfig first, then busybox - if (Build.VERSION.SDK_INT >= 35) { // Android 16+ - callback.run(ctx, "ifconfig -a || " + getBusyBoxPath(ctx, true) + " ifconfig -a"); - } else { - callback.run(ctx, getBusyBoxPath(ctx, true) + " ifconfig -a"); - } + // Try system ifconfig first, then busybox for all versions + callback.run(ctx, "ifconfig -a || " + getBusyBoxPath(ctx, true) + " ifconfig -a"); } public static void runNetworkInterface(Context ctx, RootCommand callback) { - // Android 16+ fallback: try multiple methods for network interface detection - if (Build.VERSION.SDK_INT >= 35) { // Android 16+ - // First try Android API method as fallback - try { - StringBuilder result = new StringBuilder(); - java.util.Enumeration interfaces = java.net.NetworkInterface.getNetworkInterfaces(); - while (interfaces.hasMoreElements()) { - java.net.NetworkInterface networkInterface = interfaces.nextElement(); - result.append(networkInterface.getName()).append("\n"); - } - if (result.length() > 0) { - // Create a mock RootCommand with API results - RootCommand apiResult = new RootCommand(); - apiResult.res = result; - apiResult.exitCode = 0; - apiResult.done = true; - if (callback.cb != null) { - callback.cb.cbFunc(apiResult); - } - return; + // Try Android API method first for all versions + try { + StringBuilder result = new StringBuilder(); + java.util.Enumeration interfaces = java.net.NetworkInterface.getNetworkInterfaces(); + while (interfaces.hasMoreElements()) { + java.net.NetworkInterface networkInterface = interfaces.nextElement(); + result.append(networkInterface.getName()).append("\n"); + } + if (result.length() > 0) { + // Create a mock RootCommand with API results + RootCommand apiResult = new RootCommand(); + apiResult.res = result; + apiResult.exitCode = 0; + apiResult.done = true; + if (callback.cb != null) { + callback.cb.cbFunc(apiResult); } - } catch (Exception e) { - Log.d(TAG, "Android API network interface detection failed: " + e.getMessage()); + return; } - - // Fallback to shell commands - String cmd = "ls /sys/class/net 2>/dev/null || " + - getBusyBoxPath(ctx, true) + " ls /sys/class/net 2>/dev/null || " + - "ip link show 2>/dev/null"; - callback.run(ctx, cmd); - } else { - callback.run(ctx, getBusyBoxPath(ctx, true) + " ls /sys/class/net"); + } catch (Exception e) { + Log.d(TAG, "Android API network interface detection failed: " + e.getMessage()); } + + // Fallback to shell commands with multiple options + String cmd = "ls /sys/class/net 2>/dev/null || " + + getBusyBoxPath(ctx, true) + " ls /sys/class/net 2>/dev/null || " + + "ip link show 2>/dev/null"; + callback.run(ctx, cmd); } public static void fixFolderPermissionsAsync(Context mContext) { AsyncTask.execute(() -> { try { - mContext.getFilesDir().setExecutable(true, false); - mContext.getFilesDir().setReadable(true, false); + mContext.getFilesDir().setExecutable(true, true); + mContext.getFilesDir().setReadable(true, true); File sharedPrefsFolder = new File(mContext.getFilesDir().getAbsolutePath() + "/../shared_prefs"); - sharedPrefsFolder.setExecutable(true, false); - sharedPrefsFolder.setReadable(true, false); + sharedPrefsFolder.setExecutable(true, true); + sharedPrefsFolder.setReadable(true, true); } catch (Exception e) { Log.e(Api.TAG, e.getMessage(), e); } @@ -1508,6 +2348,12 @@ public static List getApps(Context ctx, GetAppList appList) { // return cached instance return applications; } + // system UIDs that use the network get entries (root; rate limited) + if (android.os.Looper.myLooper() == android.os.Looper.getMainLooper()) { + new Thread(() -> SystemUids.discover(ctx), "AFWall-SystemUids").start(); + } else { + SystemUids.discover(ctx); + } SharedPreferences prefs = ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); @@ -1567,6 +2413,15 @@ public static List getApps(Context ctx, GetAppList appList) { } } } + // getUserProfiles() can leave out profiles (e.g. a Private Space), so also take the + // profiles root reports (#1485, #1482) + Map profileTypes = G.supportDual() ? getProfileTypes() : new HashMap<>(); + for (Map.Entry profile : profileTypes.entrySet()) { + if (profile.getKey() > 0 && profile.getValue().startsWith("profile.") + && !listOfUids.contains(profile.getKey())) { + listOfUids.add(profile.getKey()); + } + } //use pm list packages -f -U --user 10 int pkgManagerFlags = PackageManager.GET_META_DATA; // it's useless to iterate over uninstalled packages if we don't support multi-profile apps @@ -1574,7 +2429,14 @@ public static List getApps(Context ctx, GetAppList appList) { pkgManagerFlags |= PackageManager.GET_UNINSTALLED_PACKAGES; } PackageManager pkgmanager = ctx.getPackageManager(); + // Load the app list purely through PackageManager. The previous root-shell + // supplementation ("pm list packages -U" + per-package dumpsys INTERNET checks) + // made every scan run dozens of shell round-trips, which dominated load time. List installed = pkgmanager.getInstalledApplications(pkgManagerFlags); + if (appList != null) { + appList.doMaxProgress(installed.size()); + } + SparseArray syncMap = new SparseArray<>(); Editor edit = cachePrefs.edit(); boolean changed = false; @@ -1589,8 +2451,12 @@ public static List getApps(Context ctx, GetAppList appList) { SparseArray multiUserAppsMap = new SparseArray<>(); HashMap packagesForUser = new HashMap<>(); + HashMap profileMarkers = new HashMap<>(); + HashMap internetPermissionCache = new HashMap<>(); if(G.supportDual()) { - packagesForUser = getPackagesForUser(listOfUids); + packagesForUser = getPackagesForUser(listOfUids); + profileMarkers = getUserProfileMarkers(listOfUids, profileTypes); + lastProfileMarkers = profileMarkers; } for (int i = 0; i < installed.size(); i++) { @@ -1613,7 +2479,12 @@ public static List getApps(Context ctx, GetAppList appList) { name = prefs.getString(cachekey, ""); if (name.length() == 0 || isRecentlyInstalled(apinfo.packageName)) { // get label and put on cache - name = pkgmanager.getApplicationLabel(apinfo).toString(); + try { + name = pkgmanager.getApplicationLabel(apinfo).toString(); + } catch (Exception e) { + // For apps invisible to PackageManager, use package name as label + name = apinfo.packageName; + } edit.putString(cachekey, name); changed = true; firstseen = true; @@ -1621,7 +2492,21 @@ public static List getApps(Context ctx, GetAppList appList) { if (app == null) { app = new PackageInfoData(); app.uid = apinfo.uid; - app.installTime = new File(apinfo.sourceDir).lastModified(); + + // Handle null sourceDir to prevent NullPointerException + if (apinfo.sourceDir != null) { + app.installTime = new File(apinfo.sourceDir).lastModified(); + } else { + // Try to get install time from PackageInfo as fallback + try { + PackageInfo pkgInfo = pkgmanager.getPackageInfo(apinfo.packageName, 0); + app.installTime = pkgInfo.firstInstallTime; + } catch (PackageManager.NameNotFoundException e) { + // Shell-discovered apps invisible to PackageManager — use 0 + app.installTime = 0; + } + } + app.names = new ArrayList(); app.names.add(name); app.appinfo = apinfo; @@ -1663,11 +2548,20 @@ public static List getApps(Context ctx, GetAppList appList) { app.selected_tor = true; } if (G.supportDual()) { - checkPartOfMultiUser(apinfo, name, listOfUids, packagesForUser, multiUserAppsMap); + checkPartOfMultiUser(apinfo, name, listOfUids, packagesForUser, profileMarkers, multiUserAppsMap); } } + // Headless system apps (CaptivePortalLogin, eSIM/euicc, sync adapters, ...) are invisible + // to PackageManager without QUERY_ALL_PACKAGES, so they were missing from the list while + // their traffic was blocked (#1476, #1490, #1499). Find them with one root call. + addPackagesHiddenFromPackageManager(pkgmanager, installed, syncMap, selected_wifi, selected_3g, + selected_roam, selected_vpn, selected_tether, selected_lan, selected_tor); + if (G.supportDual()) { + addProfileOnlyPackages(pkgmanager, packagesForUser, profileMarkers, syncMap, + selected_wifi, selected_3g, selected_roam, selected_vpn, + selected_tether, selected_lan, selected_tor, internetPermissionCache); //run through multi user map for (int i = 0; i < multiUserAppsMap.size(); i++) { app = multiUserAppsMap.valueAt(i); @@ -1746,7 +2640,12 @@ public static List getApps(Context ctx, GetAppList appList) { } catch (Exception e) { Log.i(TAG, "Exception in getting app list", e); } - return new ArrayList<>(); + // Never leave the cache null after a run, otherwise UI callers that route a + // cold cache to the async loader could loop on a persistent scan failure. + if (applications == null) { + applications = Collections.synchronizedList(new ArrayList()); + } + return applications; } /* public boolean isSuPackage(PackageManager pm, String suPackage) { @@ -1762,7 +2661,26 @@ public static List getApps(Context ctx, GetAppList appList) { return found; }*/ + /** + * @return the special entries plus the system UIDs found to use the network (see SystemUids) + */ public static List getSpecialData() { + List specialData = getFixedSpecialData(); + for (Map.Entry e : SystemUids.known().entrySet()) { + specialData.add(new PackageInfoData(e.getKey(), + "(" + e.getValue() + ") - " + ctx.getString(R.string.system_uid_item), + SYSTEM_UID_PKG_PREFIX + e.getKey())); + } + return specialData; + } + + // package name of a discovered system UID's entry; the UID is the same on devices of a vendor + public static final String SYSTEM_UID_PKG_PREFIX = "dev.afwall.special.uid"; + + /** + * @return the fixed special entries (any, kernel, tethering, NTP, mDNS, system accounts) + */ + public static List getFixedSpecialData() { List specialData = new ArrayList<>(); specialData.add(new PackageInfoData(SPECIAL_UID_ANY, ctx.getString(R.string.all_item), "dev.afwall.special.any")); specialData.add(new PackageInfoData(SPECIAL_UID_KERNEL, ctx.getString(R.string.kernel_item), "dev.afwall.special.kernel")); @@ -1789,30 +2707,30 @@ public static List getSpecialData() { return specialData; } - private static void checkPartOfMultiUser(ApplicationInfo apinfo, String name, List uid1, HashMap pkgs, SparseArray syncMap) { + private static void checkPartOfMultiUser(ApplicationInfo apinfo, String name, List uid1, + HashMap pkgs, + HashMap profileMarkers, + SparseArray syncMap) { try { for (Integer integer : uid1) { - int appUid = Integer.parseInt(integer + "" + apinfo.uid + ""); - try{ - //String[] pkgs = pkgmanager.getPackagesForUid(appUid); + int appUid = UidResolver.createMultiUserUid(integer, UidResolver.getAppId(apinfo.uid)); + try { if (packagesExistForUserUid(pkgs, appUid)) { PackageInfoData app = new PackageInfoData(); app.uid = appUid; - app.installTime = new File(apinfo.sourceDir).lastModified(); + app.installTime = getInstallTime(null, apinfo, apinfo.packageName); app.names = new ArrayList(); - app.names.add(name + "(M)"); + app.names.add(name + getProfileMarker(profileMarkers, integer)); app.appinfo = apinfo; if (app.appinfo != null && (app.appinfo.flags & ApplicationInfo.FLAG_SYSTEM) == 0) { - //user app app.appType = 1; } else { - //system app app.appType = 0; } app.pkgName = apinfo.packageName; syncMap.put(appUid, app); } - }catch (Exception e) { + } catch (Exception e) { Log.e(TAG, e.getMessage(), e); } } @@ -1821,30 +2739,370 @@ private static void checkPartOfMultiUser(ApplicationInfo apinfo, String name, Li } } - private static boolean packagesExistForUserUid(HashMap pkgs, int appUid) { - if(pkgs.containsKey(appUid)){ - return true; + /** + * Add the main user's packages that PackageManager doesn't show us (package visibility), as + * listed by root "pm list packages -f -U". They can't be queried through PackageManager; their + * label and icon are read from their APK (else they are shown by package name as system apps). Like the visible apps, the ones without INTERNET + * are left out unless "show all apps" is enabled. + */ + private static void addPackagesHiddenFromPackageManager(PackageManager pkgmanager, + List visible, + SparseArray syncMap, + List selectedWifi, List selected3g, + List selectedRoam, List selectedVpn, + List selectedTether, List selectedLan, + List selectedTor) { + Set visiblePackages = new HashSet<>(); + for (ApplicationInfo info : visible) { + visiblePackages.add(info.packageName); + } + List out; + try { + Shell.Result result = Shell.cmd("pm list packages -f -U").exec(); + if (!result.isSuccess()) { + return; + } + out = result.getOut(); + } catch (Exception e) { + Log.w(TAG, "Unable to list packages hidden from PackageManager: " + e.getMessage()); + return; } - return false; + // null = unknown (then nothing is filtered out) + Set withInternet = showAllApps() ? null : packagesHoldingInternet(); + // updated system apps (WebView, ...) have their APK in /data too: ask which are third-party + Set thirdParty = new HashSet<>(); + try { + for (String line : Shell.cmd("pm list packages -3").exec().getOut()) { + if (line.startsWith("package:")) { + thirdParty.add(line.substring("package:".length()).trim()); + } + } + } catch (Exception ignored) { + } + int added = 0; + for (String line : out) { + ApkInfo.Line parsed = ApkInfo.parse(line); + if (parsed == null) { + continue; + } + String pkg = parsed.packageName; + if (withInternet != null && !withInternet.contains(pkg)) { + continue; + } + int uid = parsed.uid; + // only app UIDs; shared system UIDs (1000, 1073, ...) are listed as special entries + if (visiblePackages.contains(pkg) || uid % 100000 < android.os.Process.FIRST_APPLICATION_UID) { + continue; + } + PackageInfoData app = syncMap.get(uid); + if (app != null) { + if (!app.names.contains(pkg)) { + app.names.add(pkg); // another package sharing the UID + } + continue; + } + app = new PackageInfoData(); + app.uid = uid; + app.pkgName = pkg; + app.names = new ArrayList<>(); + app.names.add(pkg); + app.appType = 0; // system + ApplicationInfo apk = ApkInfo.load(pkgmanager, parsed.apkPath, uid); + if (apk != null) { + app.appinfo = apk; + String label = getApplicationLabel(pkgmanager, apk, pkg); + if (label != null && !label.trim().isEmpty()) { + app.names.set(0, label); + } + if (thirdParty.contains(pkg)) { + apk.flags &= ~ApplicationInfo.FLAG_SYSTEM; + app.appType = 1; + } else { + apk.flags |= ApplicationInfo.FLAG_SYSTEM; + } + } + app.selected_wifi = Collections.binarySearch(selectedWifi, uid) >= 0; + app.selected_3g = Collections.binarySearch(selected3g, uid) >= 0; + app.selected_roam = G.enableRoam() && Collections.binarySearch(selectedRoam, uid) >= 0; + app.selected_vpn = G.enableVPN() && Collections.binarySearch(selectedVpn, uid) >= 0; + app.selected_tether = G.enableTether() && Collections.binarySearch(selectedTether, uid) >= 0; + app.selected_lan = G.enableLAN() && Collections.binarySearch(selectedLan, uid) >= 0; + app.selected_tor = G.enableTor() && Collections.binarySearch(selectedTor, uid) >= 0; + syncMap.put(uid, app); + added++; + } + if (added > 0) { + Log.i(TAG, "Added " + added + " app(s) hidden from PackageManager"); + } + } + + /** + * @return packages granted INTERNET, from one root "dumpsys package packages" pass (checking + * each package separately made list loading far too slow), or null if it can't be determined + */ + private static Set packagesHoldingInternet() { + try { + Shell.Result result = Shell.cmd("dumpsys package packages | grep -E '^ Package \\[|android.permission.INTERNET: granted=true'").exec(); + if (!result.isSuccess() && result.getOut().isEmpty()) { + return null; + } + Pattern packageLine = Pattern.compile("^ Package \\[([^\\]]+)\\]"); + Set packages = new HashSet<>(); + String current = null; + for (String line : result.getOut()) { + Matcher m = packageLine.matcher(line); + if (m.find()) { + current = m.group(1); + } else if (current != null && line.contains("android.permission.INTERNET: granted=true")) { + packages.add(current); + } + } + return packages.isEmpty() ? null : packages; + } catch (Exception e) { + Log.w(TAG, "Unable to read INTERNET permission grants: " + e.getMessage()); + return null; + } + } + + private static boolean packagesExistForUserUid(HashMap pkgs, int appUid) { + return pkgs != null && pkgs.containsKey(appUid); } + /** + * APK of the packages of other users (work profile, Private Space, ...), from + * {@link #getPackagesForUser}: the label and icon of an app PackageManager doesn't show us. + */ + private static final Map apkPaths = new java.util.concurrent.ConcurrentHashMap<>(); + public static HashMap getPackagesForUser(List userProfile) { - HashMap listApps = new HashMap<>(); - for(Integer integer: userProfile) { - Shell.Result result = Shell.cmd("pm list packages -U --user " + integer).exec(); - List out = result.getOut(); - Matcher matcher; - for (String item : out) { - matcher = dual_pattern.matcher(item); - if (matcher.find() && matcher.groupCount() > 0) { - String packageName = matcher.group(1); - String packageId = matcher.group(2); - Log.i(TAG, packageId + " " + packageName); - listApps.put(Integer.parseInt(packageId), packageName); + HashMap listApps = new HashMap<>(); + for (Integer integer : userProfile) { + try { + Shell.Result result = Shell.cmd("pm list packages -f -U --user " + integer).exec(); + List out = result.getOut(); + int userPackageCount = 0; + for (String item : out) { + ApkInfo.Line parsed = ApkInfo.parse(item); + if (parsed != null) { + listApps.put(parsed.uid, parsed.packageName); + if (parsed.apkPath != null) { + apkPaths.put(parsed.packageName, parsed.apkPath); + } + userPackageCount++; + } + } + Log.i(TAG, "Discovered " + userPackageCount + " package(s) for user " + integer); + } catch (java.util.concurrent.RejectedExecutionException e) { + Log.w(TAG, "Package listing rejected for user " + integer + ": " + e.getMessage()); + break; + } catch (Exception e) { + Log.e(TAG, "Failed to list packages for user " + integer + ": " + e.getMessage()); + } + } + return listApps; + } + + private static void addProfileOnlyPackages(PackageManager pkgmanager, + HashMap packagesForUser, + HashMap profileMarkers, + SparseArray syncMap, + List selectedWifi, + List selected3g, + List selectedRoam, + List selectedVpn, + List selectedTether, + List selectedLan, + List selectedTor, + HashMap internetPermissionCache) { + if (packagesForUser == null || packagesForUser.isEmpty()) { + return; + } + int addedPackages = 0; + for (Map.Entry entry : packagesForUser.entrySet()) { + int uid = entry.getKey(); + String packageName = entry.getValue(); + // Already discovered via PackageManager in the main scan — skip. + if (syncMap.get(uid) != null || packageName == null || packageName.trim().isEmpty()) { + continue; + } + if (!showAllApps() && !hasInternetPermission(pkgmanager, packageName, internetPermissionCache)) { + continue; + } + ApplicationInfo apinfo = getApplicationInfoForPackage(pkgmanager, packageName, uid); + PackageInfoData app = new PackageInfoData(); + app.uid = uid; + app.installTime = getInstallTime(pkgmanager, apinfo, packageName); + app.names = new ArrayList(); + app.names.add(getApplicationLabel(pkgmanager, apinfo, packageName) + + getProfileMarker(profileMarkers, UidResolver.getUserId(uid))); + app.appinfo = apinfo; + app.appType = (apinfo.flags & ApplicationInfo.FLAG_SYSTEM) == 0 ? 1 : 0; + app.pkgName = packageName; + // Apply selection state using the same sorted-list binary-search pattern as the main scan. + if (Collections.binarySearch(selectedWifi, uid) >= 0) app.selected_wifi = true; + if (Collections.binarySearch(selected3g, uid) >= 0) app.selected_3g = true; + if (G.enableRoam() && Collections.binarySearch(selectedRoam, uid) >= 0) app.selected_roam = true; + if (G.enableVPN() && Collections.binarySearch(selectedVpn, uid) >= 0) app.selected_vpn = true; + if (G.enableTether() && Collections.binarySearch(selectedTether, uid) >= 0) app.selected_tether = true; + if (G.enableLAN() && Collections.binarySearch(selectedLan, uid) >= 0) app.selected_lan = true; + if (G.enableTor() && Collections.binarySearch(selectedTor, uid) >= 0) app.selected_tor = true; + syncMap.put(uid, app); + addedPackages++; + } + if (addedPackages > 0) { + Log.i(TAG, "Added " + addedPackages + " profile-only package(s) to app list"); + } + } + + private static ApplicationInfo getApplicationInfoForPackage(PackageManager pkgmanager, + String packageName, int uid) { + try { + ApplicationInfo apinfo = pkgmanager.getApplicationInfo(packageName, + PackageManager.GET_META_DATA | PackageManager.GET_UNINSTALLED_PACKAGES); + apinfo.uid = uid; + return apinfo; + } catch (Exception ignored) { + ApplicationInfo apk = ApkInfo.load(pkgmanager, apkPaths.get(packageName), uid); + if (apk != null) { + return apk; + } + ApplicationInfo apinfo = new ApplicationInfo(); + apinfo.packageName = packageName; + apinfo.uid = uid; + // Profile-only packages can be invisible to PackageManager — keep a minimal + // entry so firewall rules can still target the pm-reported UID. + apinfo.flags = ApplicationInfo.FLAG_INSTALLED; + return apinfo; + } + } + + private static boolean hasInternetPermission(PackageManager pkgmanager, String packageName, + HashMap internetPermissionCache) { + try { + if (PackageManager.PERMISSION_GRANTED == pkgmanager.checkPermission( + Manifest.permission.INTERNET, packageName)) { + if (internetPermissionCache != null) internetPermissionCache.put(packageName, true); + return true; + } + } catch (Exception e) { + Log.w(TAG, "PackageManager permission check failed for " + packageName + ": " + e.getMessage()); + } + return hasInternetPermissionViaShell(packageName, internetPermissionCache); + } + + private static String getApplicationLabel(PackageManager pkgmanager, + ApplicationInfo apinfo, String packageName) { + try { + return pkgmanager.getApplicationLabel(apinfo).toString(); + } catch (Exception ignored) { + return packageName; + } + } + + private static long getInstallTime(PackageManager pkgmanager, ApplicationInfo apinfo, + String packageName) { + if (apinfo != null && apinfo.sourceDir != null) { + return new File(apinfo.sourceDir).lastModified(); + } + if (pkgmanager != null) { + try { + return pkgmanager.getPackageInfo(packageName, 0).firstInstallTime; + } catch (Exception ignored) { + } + } + return 0; + } + + /** + * @return user id -> user type (e.g. "profile.MANAGED", "profile.PRIVATE", "full.SYSTEM") as + * reported by root "cmd user list -v"; empty if unavailable (older Android) + */ + public static Map getProfileTypes() { + Map types = new HashMap<>(); + try { + Shell.Result result = Shell.cmd("cmd user list -v").exec(); + Pattern userPattern = Pattern.compile("id=(\\d+),.*?type=([\\w.]+)"); + for (String line : result.getOut()) { + Matcher m = userPattern.matcher(line); + if (m.find()) { + types.put(Integer.parseInt(m.group(1)), m.group(2)); } } + } catch (Exception e) { + Log.w(TAG, "Unable to list user types: " + e.getMessage()); + } + return types; + } + + // markers of the last app list: "(W)" work profile, "(P)" Private Space, "(M)" other users + private static volatile Map lastProfileMarkers = new HashMap<>(); + private static volatile boolean profileMarkersLookedUp; + + /** + * @return the marker of the user of {@code uid} as the app list shows it ("(W)", "(P)", "(M)") + */ + public static String profileMarker(int uid) { + int userId = uid / 100000; + String marker = lastProfileMarkers.get(userId); + if (marker == null && userId > 0 && !profileMarkersLookedUp + && Looper.myLooper() != Looper.getMainLooper()) { + // dual apps off: the list didn't look up the profiles (root; not on the main thread) + profileMarkersLookedUp = true; + try { + lastProfileMarkers = getUserProfileMarkers(getListOfUids(), getProfileTypes()); + marker = lastProfileMarkers.get(userId); + } catch (Exception ignored) { + } } - return listApps.size() > 0 ? listApps : null; + return marker != null ? marker : "(M)"; + } + + private static HashMap getUserProfileMarkers(List userProfile, + Map profileTypes) { + HashMap profileMarkers = new HashMap<>(); + for (Integer userId : userProfile) { + // the user type is reliable; profile names are localized or chosen by apps (Shelter) + String type = profileTypes.get(userId); + String marker = markerForProfileType(type); + profileMarkers.put(userId, marker != null ? marker : "(M)"); + } + try { + Shell.Result result = Shell.cmd("pm list users").exec(); + Pattern userInfoPattern = Pattern.compile("UserInfo\\{(\\d+):([^:}]*)"); + for (String line : result.getOut()) { + Matcher matcher = userInfoPattern.matcher(line); + if (matcher.find()) { + int userId = Integer.parseInt(matcher.group(1)); + if (profileMarkers.containsKey(userId) && markerForProfileType(profileTypes.get(userId)) == null) { + profileMarkers.put(userId, markerForProfileName(matcher.group(2))); + } + } + } + } catch (Exception e) { + Log.w(TAG, "Failed to label user profiles: " + e.getMessage()); + } + return profileMarkers; + } + + /** @return marker for a known profile type, or null */ + private static String markerForProfileType(String type) { + if ("profile.MANAGED".equals(type)) return "(W)"; + if ("profile.PRIVATE".equals(type)) return "(P)"; + return null; + } + + private static String markerForProfileName(String profileName) { + String name = profileName == null ? "" : profileName.toLowerCase(Locale.US); + if (name.contains("work")) return "(W)"; + if (name.contains("private")) return "(P)"; + return "(M)"; + } + + private static String getProfileMarker(HashMap profileMarkers, int userId) { + if (profileMarkers != null && profileMarkers.containsKey(userId)) { + return profileMarkers.get(userId); + } + return "(M)"; } private static boolean isRecentlyInstalled(String packageName) { @@ -1857,17 +3115,8 @@ private static boolean isRecentlyInstalled(String packageName) { } private static List getListFromPref(String savedPkg_uid) { - StringTokenizer tok = new StringTokenizer(savedPkg_uid, "|"); - List listUids = new ArrayList<>(); - while (tok.hasMoreTokens()) { - String uid = tok.nextToken(); - if (!uid.equals("")) { - listUids.add(Integer.parseInt(uid)); - } - } - // Sort the array to allow using "Arrays.binarySearch" later - Collections.sort(listUids); - return listUids; + // Sorted to allow using "Arrays.binarySearch" later + return UidListParser.parse(savedPkg_uid); } /*public static boolean isAppAllowed(Context context, ApplicationInfo applicationInfo, SharedPreferences sharedPreferences, SharedPreferences pPrefs) { @@ -1968,13 +3217,28 @@ public static int runScriptAsRoot(Context ctx, List script, StringBuilde } try { - returnCode = new RunCommand().execute(script, res, ctx).get(); + RunCommand runCommand = new RunCommand(); + returnCode = runCommand.execute(script, res, ctx).get(); } catch (RejectedExecutionException r) { - Log.e(TAG, "runScript failed: " + r.getLocalizedMessage()); + Log.w(TAG, "Shell execution rejected, likely due to app shutdown: " + r.getLocalizedMessage()); + returnCode = -1; } catch (InterruptedException e) { - Log.e(TAG, "Caught InterruptedException"); + Log.w(TAG, "Shell execution was interrupted: " + e.getLocalizedMessage()); + Thread.currentThread().interrupt(); // Restore interrupted status + returnCode = -1; + } catch (java.util.concurrent.ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof java.io.InterruptedIOException) { + Log.w(TAG, "Shell execution interrupted (IO): " + cause.getMessage()); + } else if (cause instanceof java.util.concurrent.RejectedExecutionException) { + Log.w(TAG, "Shell execution rejected in wrapped exception: " + cause.getMessage()); + } else { + Log.e(TAG, "Shell execution failed with ExecutionException: " + e.getLocalizedMessage()); + } + returnCode = -1; } catch (Exception e) { - Log.e(TAG, "runScript failed: " + e.getLocalizedMessage()); + Log.e(TAG, "Unexpected error during shell execution: " + e.getLocalizedMessage()); + returnCode = -1; } return returnCode; @@ -1982,73 +3246,116 @@ public static int runScriptAsRoot(Context ctx, List script, StringBuilde private static boolean installBinary(Context ctx, int resId, String filename) { try { - File f = new File(ctx.getDir("bin", 0), filename); + File binDir = ctx.getDir("bin", 0); + File f = new File(binDir, filename); + + Log.d(TAG, "Installing binary: " + filename + " to " + f.getAbsolutePath()); + if (f.exists()) { - f.delete(); + Log.d(TAG, "Removing existing binary: " + filename); + if (!f.delete()) { + Log.w(TAG, "Failed to delete existing binary: " + filename); + } } + copyRawFile(ctx, resId, f, "0755"); + + // Verify the binary was installed correctly + if (!f.exists()) { + Log.e(TAG, "Binary installation failed - file does not exist: " + filename); + return false; + } + + if (!f.canExecute()) { + Log.w(TAG, "Binary installed but not executable: " + filename); + // Try to fix permissions manually + try { + f.setExecutable(true, false); + Log.d(TAG, "Fixed permissions for: " + filename); + } catch (Exception e) { + Log.e(TAG, "Failed to fix permissions for: " + filename + " - " + e.getMessage()); + } + } + + Log.d(TAG, "Successfully installed binary: " + filename + + " (size: " + f.length() + " bytes, executable: " + f.canExecute() + ")"); return true; + } catch (Exception e) { - Log.e(TAG, "installBinary failed: " + e.getLocalizedMessage()); + Log.e(TAG, "installBinary failed for " + filename + ": " + e.getClass().getSimpleName() + + " - " + e.getLocalizedMessage(), e); return false; } } - private static boolean installBinariesX86() { + /** + * Install binary if the resource exists, using reflection to check for resource availability + * @param ctx Context + * @param resourceName Name of the resource (e.g., "busybox_arm64") + * @param filename Target filename + * @return true if installed successfully or resource doesn't exist, false on installation error + */ + private static boolean installBinaryIfExists(Context ctx, String resourceName, String filename) { + try { + // Use reflection to check if the resource exists + Class rawClass = R.raw.class; + java.lang.reflect.Field field = rawClass.getDeclaredField(resourceName); + int resId = field.getInt(null); + + // Resource exists, try to install it + return installBinary(ctx, resId, filename); + } catch (NoSuchFieldException e) { + // Resource doesn't exist - this is expected when binaries are not yet added + Log.d(TAG, "Resource " + resourceName + " not found - this is expected if binary is not yet available"); + return false; + } catch (Exception e) { + Log.e(TAG, "Error checking/installing binary " + resourceName + ": " + e.getMessage()); + return false; + } + } + + private static boolean installBinariesX86(Context ctx) { if (!installBinary(ctx, R.raw.busybox_x86, "busybox")) return false; if (!installBinary(ctx, R.raw.iptables_x86, "iptables")) return false; if (!installBinary(ctx, R.raw.ip6tables_x86, "ip6tables")) return false; if (!installBinary(ctx, R.raw.nflog_x86, "nflog")) return false; - //if (!installBinary(ctx, R.raw.run_pie_x86, "run_pie")) return false; + + return true; } - private static boolean installBinariesMips() { - if (!installBinary(ctx, R.raw.busybox_mips, "busybox")) return false; - if (!installBinary(ctx, R.raw.iptables_mips, "iptables")) return false; - if (!installBinary(ctx, R.raw.ip6tables_mips, "ip6tables")) return false; - if (!installBinary(ctx, R.raw.nflog_mips, "nflog")) return false; - //if (!installBinary(ctx, R.raw.run_pie_mips, "run_pie")) return false; - return true; - } - private static boolean installBinariesArm64() { - // ARM64 devices use system binaries for iptables/busybox, only install nflog - try { - if (!installBinary(ctx, R.raw.nflog_arm64, "nflog")) { - Log.e(TAG, "Failed to install ARM64 nflog binary"); - return false; - } - Log.i(TAG, "Successfully installed ARM64 binaries"); - return true; - } catch (Exception e) { - Log.e(TAG, "Error installing ARM64 binaries: " + e.getMessage()); - return false; - } + private static boolean installBinariesArm64(Context ctx) { + if (!installBinary(ctx, R.raw.busybox_arm64, "busybox")) return false; + if (!installBinary(ctx, R.raw.iptables_arm64, "iptables")) return false; + if (!installBinary(ctx, R.raw.ip6tables_arm64, "ip6tables")) return false; + if (!installBinary(ctx, R.raw.nflog_arm64, "nflog")) return false; + + + return true; } - private static boolean installBinariesArm() { + private static boolean installBinariesArm(Context ctx) { if (!installBinary(ctx, R.raw.busybox_arm, "busybox")) return false; if (!installBinary(ctx, R.raw.iptables_arm, "iptables")) return false; if (!installBinary(ctx, R.raw.ip6tables_arm, "ip6tables")) return false; if (!installBinary(ctx, R.raw.nflog_arm, "nflog")) return false; - //if (!installBinary(ctx, R.raw.run_pie_arm, "run_pie")) return false; + + return true; } - private static boolean installBinariesForAbi(String abi) { + private static boolean installBinariesForAbi(Context ctx, String abi) { if (abi.startsWith("x86")) { - return installBinariesX86(); - } else if (abi.startsWith("mips")) { - return installBinariesMips(); + return installBinariesX86(ctx); } else if (abi.startsWith("arm64")) { - return installBinariesArm64(); + return installBinariesArm64(ctx); } else { - return installBinariesArm(); + return installBinariesArm(ctx); } } - private static int getPackageVersion() { + private static int getPackageVersion(Context ctx) { try { return ctx.getPackageManager().getPackageInfo(ctx.getPackageName(), 0).versionCode; } catch (NameNotFoundException e) { @@ -2065,6 +3372,9 @@ private static String getAbi() { } } + // Static lock object for synchronizing binary installation + private static final Object BINARY_INSTALL_LOCK = new Object(); + /** * Asserts that the binary files are installed in the cache directory. * @@ -2073,19 +3383,30 @@ private static String getAbi() { * @return false if the binary files could not be installed */ public static boolean assertBinaries(Context ctx, boolean showErrors) { - - int currentVer = getPackageVersion(); - - if (G.appVersion() == currentVer) { - // The version hasn't changed: Use the previously installed binaries. - return true; + synchronized (BINARY_INSTALL_LOCK) { + Log.d(TAG, "assertBinaries() called - Entry point"); + + int currentVer = getPackageVersion(ctx); + boolean wasAlreadyInstalled = (G.appVersion() == currentVer); + Log.d(TAG, "assertBinaries() - currentVer=" + currentVer + ", storedVer=" + G.appVersion() + ", wasAlreadyInstalled=" + wasAlreadyInstalled); + + if (wasAlreadyInstalled) { + // The version hasn't changed: Check if binaries are still functional + Log.d(TAG, "assertBinaries() - Verifying existing binaries..."); + if (verifyBinaries(ctx)) { + Log.d(TAG, "assertBinaries() - Verification passed, returning true (no reinstall needed)"); + return true; + } else { + Log.w(TAG, "Binaries verification failed, forcing reinstallation"); + } } String abi = getAbi(); - Log.d(TAG, "Installing binaries for " + abi + "..."); + Log.d(TAG, "Installing binaries for " + abi + " (currentVer=" + currentVer + + ", storedVer=" + G.appVersion() + ", wasAlreadyInstalled=" + wasAlreadyInstalled + ")..."); - if (!installBinariesForAbi(abi)) + if (!installBinariesForAbi(ctx, abi)) { Log.e(TAG, "Installation of the binaries for " + abi + " failed!"); toast(ctx, ctx.getString(R.string.error_binary), Toast.LENGTH_LONG); @@ -2101,11 +3422,119 @@ public static boolean assertBinaries(Context ctx, boolean showErrors) { } Log.d(TAG, "Installed binaries for " + abi + "."); - toast(ctx, ctx.getString(R.string.toast_bin_installed), Toast.LENGTH_SHORT); + + // Only show toast for actual new installations (not verification failures) + if (!wasAlreadyInstalled) { + Log.d(TAG, "New installation completed - showing toast"); + toast(ctx, ctx.getString(R.string.toast_bin_installed), Toast.LENGTH_SHORT); + } else { + Log.d(TAG, "Binaries reinstalled (wasAlreadyInstalled=true) - no toast shown"); + } G.appVersion(currentVer); // This indicates that the installation of the binaries for this version was successful. return true; + } // End synchronized block + } + + /** + * Force reinstallation of binaries regardless of version + * + * @param ctx Context + * @param showErrors indicates if errors should be alerted + * @return true if installation successful + */ + public static boolean forceReinstallBinaries(Context ctx, boolean showErrors) { + Log.i(TAG, "Forcing binary reinstallation..."); + + // Clear the version to force reinstallation + G.appVersion(-1); + + return assertBinaries(ctx, showErrors); + } + + /** + * Verify that installed binaries are functional + * + * @param ctx Context + * @return true if binaries are functional, false if they need reinstallation + */ + private static boolean verifyBinaries(Context ctx) { + Log.d(TAG, "verifyBinaries() called - Starting verification"); + String dir = ctx.getDir("bin", 0).getAbsolutePath(); + Log.d(TAG, "verifyBinaries() - Binary directory: " + dir); + + // Check if busybox exists and is executable + File busybox = new File(dir, "busybox"); + boolean exists = busybox.exists(); + boolean canExecute = busybox.canExecute(); + boolean canRead = busybox.canRead(); + long size = busybox.length(); + Log.d(TAG, "verifyBinaries() - Checking busybox: exists=" + exists + ", canExecute=" + canExecute + ", canRead=" + canRead + ", size=" + size + " bytes"); + if (!exists || !canExecute) { + Log.w(TAG, "Busybox binary missing or not executable"); + return false; + } + + // Test busybox functionality by running a simple command + // Note: On modern Android, binaries in app private directories may not be executable + // from the app context, but they will work when executed with root privileges + try { + Log.d(TAG, "verifyBinaries() - Testing busybox functionality with 'echo test'"); + ProcessBuilder pb = new ProcessBuilder(busybox.getAbsolutePath(), "echo", "test"); + pb.environment().clear(); + Process process = pb.start(); + int exitCode = process.waitFor(); + Log.d(TAG, "verifyBinaries() - Busybox test exitCode: " + exitCode); + + if (exitCode != 0) { + Log.w(TAG, "Busybox test command failed with exit code: " + exitCode); + return false; + } + + // Read and verify output + java.util.Scanner scanner = new java.util.Scanner(process.getInputStream()); + if (scanner.hasNextLine()) { + String output = scanner.nextLine().trim(); + Log.d(TAG, "verifyBinaries() - Busybox test output: '" + output + "'"); + scanner.close(); + if (!"test".equals(output)) { + Log.w(TAG, "Busybox test output unexpected: " + output); + return false; + } + } else { + scanner.close(); + Log.w(TAG, "Busybox test produced no output"); + return false; + } + + } catch (Exception e) { + String errorMsg = e.getMessage(); + if (errorMsg != null && (errorMsg.contains("Permission denied") || errorMsg.contains("error=13"))) { + Log.w(TAG, "Busybox execution test failed due to Android security restrictions (expected behavior)"); + Log.w(TAG, "Binary will be available for root execution. Skipping direct execution test."); + // Don't fail verification for permission denied - the binary will work with root + // Just log the issue and continue with other checks + } else { + Log.w(TAG, "Busybox verification failed: " + errorMsg); + return false; + } + } + + // Check other critical binaries exist + Log.d(TAG, "verifyBinaries() - Checking other required binaries"); + String[] requiredBinaries = {"iptables", "ip6tables"}; + for (String binary : requiredBinaries) { + File binaryFile = new File(dir, binary); + Log.d(TAG, "verifyBinaries() - Checking " + binary + ": exists=" + binaryFile.exists() + ", canExecute=" + binaryFile.canExecute()); + if (!binaryFile.exists() || !binaryFile.canExecute()) { + Log.w(TAG, "Required binary missing or not executable: " + binary); + return false; + } + } + + Log.d(TAG, "Binary verification successful - All checks passed"); + return true; } /** @@ -2140,135 +3569,65 @@ public static void setEnabled(Context ctx, boolean enabled, boolean showErrors) return; } - //addNotification(); - Intent myService = new Intent(ctx, FirewallService.class); - ctx.stopService(myService); - ctx.startService(myService); + if (FirewallService.isInstanceRunning()) { + // the notification is rebuilt from the new enabled state + FirewallService.refreshNotification(); + } else { + // may be refused from the background; MainActivity starts it again when opened + FirewallService.ensureRunning(ctx); + } /* notify */ Intent message = new Intent(ctx, StatusWidget.class); message.setAction(STATUS_CHANGED_MSG); message.putExtra(Api.STATUS_EXTRA, enabled); ctx.sendBroadcast(message); - } - - - public static void errorNotification(Context ctx) { - - String NOTIFICATION_CHANNEL_ID = "firewall.error"; - String channelName = ctx.getString(R.string.firewall_error_notify); - - NotificationManager manager = (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE); - manager.cancel(ERROR_NOTIFICATION_ID); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - NotificationChannel notificationChannel = new NotificationChannel(NOTIFICATION_CHANNEL_ID, channelName, NotificationManager.IMPORTANCE_DEFAULT); - notificationChannel.setLockscreenVisibility(Notification.VISIBILITY_PRIVATE); - if (G.getNotificationPriority() == 0) { - notificationChannel.setImportance(NotificationManager.IMPORTANCE_DEFAULT); - } - notificationChannel.setSound(null, null); - notificationChannel.setShowBadge(false); - notificationChannel.enableLights(false); - notificationChannel.enableVibration(false); - manager.createNotificationChannel(notificationChannel); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + dev.ukanth.ufirewall.service.ToggleTileService.requestRefresh(ctx); } - - - Intent appIntent = new Intent(ctx, MainActivity.class); - appIntent.setAction(Intent.ACTION_MAIN); - appIntent.addCategory(Intent.CATEGORY_LAUNCHER); - appIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); - - // Artificial stack so that navigating backward leads back to the Home screen - TaskStackBuilder stackBuilder = TaskStackBuilder.create(ctx) - .addParentStack(MainActivity.class) - .addNextIntent(new Intent(ctx, MainActivity.class)); - - PendingIntent notifyPendingIntent = PendingIntent.getActivity(ctx, 0, appIntent, PendingIntent.FLAG_IMMUTABLE); - NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(ctx, NOTIFICATION_CHANNEL_ID); - notificationBuilder.setContentIntent(notifyPendingIntent); - - Notification notification = notificationBuilder.setOngoing(false) - .setCategory(NotificationCompat.CATEGORY_ERROR) - .setVisibility(NotificationCompat.VISIBILITY_SECRET) - .setContentTitle(ctx.getString(R.string.error_notification_title)) - .setContentText(ctx.getString(R.string.error_notification_text)) - .setTicker(ctx.getString(R.string.error_notification_ticker)) - .setSmallIcon(R.drawable.notification_warn) - .setAutoCancel(true) - .setContentIntent(notifyPendingIntent) - .build(); - - manager.notify(ERROR_NOTIFICATION_ID, notification); } - public static void updateNotification(boolean status, Context ctx) { - - String NOTIFICATION_CHANNEL_ID = "firewall.service"; - String channelName = ctx.getString(R.string.firewall_service); - - NotificationManager manager = (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE); - manager.cancel(NOTIFICATION_ID); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - NotificationChannel notificationChannel = new NotificationChannel(NOTIFICATION_CHANNEL_ID, channelName, NotificationManager.IMPORTANCE_LOW); - notificationChannel.setLockscreenVisibility(Notification.VISIBILITY_PRIVATE); - if (G.getNotificationPriority() == 0) { - notificationChannel.setImportance(NotificationManager.IMPORTANCE_DEFAULT); - } - notificationChannel.setSound(null, null); - notificationChannel.setShowBadge(false); - notificationChannel.enableLights(false); - notificationChannel.enableVibration(false); - manager.createNotificationChannel(notificationChannel); - } - - Intent appIntent = new Intent(ctx, MainActivity.class); - appIntent.setAction(Intent.ACTION_MAIN); - appIntent.addCategory(Intent.CATEGORY_LAUNCHER); - appIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); - - int icon = status ? R.drawable.notification : R.drawable.notification_error; - String notificationText = status ? getNotificationText(ctx) : ctx.getString(R.string.inactive); + /** + * Custom script lines no longer abort an apply when they fail; report them instead. + */ + public static void customScriptWarningNotification(Context ctx, List failures) { + for (String failure : failures) { + Log.w(TAG, "Custom script line failed: " + failure); + } + String text = ctx.getResources().getQuantityString(R.plurals.custom_script_warning_text, + failures.size(), failures.size(), failures.get(0)); + showNotification(ctx, CUSTOM_SCRIPT_WARNING_NOTIFICATION_ID, + ctx.getString(R.string.custom_script_warning_title), text); + } - PendingIntent notifyPendingIntent = PendingIntent.getActivity(ctx, 0, appIntent, PendingIntent.FLAG_IMMUTABLE); - NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(ctx, NOTIFICATION_CHANNEL_ID); - notificationBuilder.setContentIntent(notifyPendingIntent); + /** + * An error-channel notification that opens the app. + */ + public static void showNotification(Context ctx, int id, String title, String text) { + Notifications.show(ctx, id, title, text, text, null); + } - Notification notification = notificationBuilder.setOngoing(true) - .setContentTitle(ctx.getString(R.string.app_name)) - .setTicker(ctx.getString(R.string.app_name)) - .setSound(null) - .setPriority(NotificationCompat.PRIORITY_LOW) - .setCategory(NotificationCompat.CATEGORY_SERVICE) - .setVisibility(NotificationCompat.VISIBILITY_SECRET) - .setContentText(notificationText) - .setSmallIcon(icon) - .build(); + /** + * An error-channel notification that opens {@code target}. + */ + public static void showNotification(Context ctx, int id, String title, String text, Intent target) { + Notifications.show(ctx, id, title, text, text, target); + } - notification.flags |= Notification.FLAG_ONGOING_EVENT | Notification.FLAG_FOREGROUND_SERVICE | Notification.FLAG_NO_CLEAR; - manager.notify(NOTIFICATION_ID, notification); + /** + * "Error applying firewall rules", with the failing command of the last apply. Cleared by the + * next successful apply. + */ + public static void errorNotification(Context ctx) { + Notifications.showApplyError(ctx); } - private static String getNotificationText(Context ctx) { - if (G.enableMultiProfile()) { - String storedProfile = G.storedProfile(); - switch (storedProfile) { - case "AFWallPrefs": - return ctx.getString(R.string.active) + " (" + G.gPrefs.getString("default", ctx.getString(R.string.defaultProfile)) + ")"; - case "AFWallProfile1": - return ctx.getString(R.string.active) + " (" + G.gPrefs.getString("profile1", ctx.getString(R.string.profile1)) + ")"; - case "AFWallProfile2": - return ctx.getString(R.string.active) + " (" + G.gPrefs.getString("profile2", ctx.getString(R.string.profile2)) + ")"; - case "AFWallProfile3": - return ctx.getString(R.string.active) + " (" + G.gPrefs.getString("profile3", ctx.getString(R.string.profile3)) + ")"; - default: - return ctx.getString(R.string.active) + " (" + storedProfile + ")"; - } - } else { - return ctx.getString(R.string.active); - } + /** + * Redraw the status notification (owned by FirewallService). + */ + public static void updateNotification(boolean status, Context ctx) { + Notifications.refreshStatus(ctx); } @@ -2372,28 +3731,6 @@ public static PackageInfo getPackageDetails(Context ctx, HashMap installedApplications = packageManager.getInstalledApplications(PackageManager.GET_UNINSTALLED_PACKAGES); - uidToApplicationInfoMap = new HashMap<>(); - for (ApplicationInfo applicationInfo : installedApplications) { - if (!uidToApplicationInfoMap.containsKey(applicationInfo.uid)) { - uidToApplicationInfoMap.put(applicationInfo.uid, applicationInfo); - } - } - } - - ApplicationInfo applicationInfo = uidToApplicationInfoMap.get(appUid); - if (applicationInfo != null) { - PackageManager packageManager = context.getPackageManager(); - return applicationInfo.loadIcon(packageManager); // The application icon. - } else { - return context.getDrawable(R.drawable.ic_unknown); // The default icon. - } - } - /** * Called when an application in removed (un-installed) from the system. * This will look for that application in the selected list and update the persisted values if necessary @@ -2434,11 +3771,19 @@ public static void applicationRemoved(Context ctx, int pkgRemoved, RootCommand c } } + // direct (per-app) rules are keyed by UID; drop them so a new app reusing the UID + // does not inherit them + if (AppRuleHelper.deleteRulesForUidInAllProfiles(pkgRemoved) > 0) { + isRuleChanged = true; + } + if (isRuleChanged) { editor.apply(); - if (isEnabled(ctx)) { - applySavedIptablesRules(ctx, false, new RootCommand()); - } + } + if (isRuleChanged && isEnabled(ctx)) { + applySavedIptablesRules(ctx, false, callback); + } else if (callback != null) { + deliverRootCommandResult(ctx, callback, 0, false); } } @@ -2495,6 +3840,159 @@ public static void exportAllPreferencesToFileConfirm(final Context ctx) { } } + public static void exportRulesToFileWithPicker(final Context ctx) { + showExportFileDialog(ctx, false); + } + + public static void exportAllPreferencesToFileWithPicker(final Context ctx) { + showExportFileDialog(ctx, true); + } + + private static void showExportFileDialog(final Context ctx, final boolean exportAll) { + try { + File defaultPath; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + File extDir = ctx.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS); + if (extDir != null) { + extDir.mkdirs(); + defaultPath = extDir; + } else { + defaultPath = new File(ctx.getExternalFilesDir(null), "/"); + } + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + defaultPath = new File(ctx.getExternalFilesDir(null), "/"); + } else { + defaultPath = new File(Environment.getExternalStorageDirectory().getAbsolutePath() + "/afwall/"); + defaultPath.mkdirs(); + } + + dev.ukanth.ufirewall.util.FileDialog fileDialog = new dev.ukanth.ufirewall.util.FileDialog((Activity) ctx, defaultPath, true); + fileDialog.setSelectDirectoryOption(true); + fileDialog.addDirectoryListener(directory -> { + String fileName = "afwall-backup" + (exportAll ? "-all" : "") + "-" + + new SimpleDateFormat("yyyy-MM-dd-HH-mm-ss").format(new Date()) + ".json"; + File fullPath = new File(directory, fileName); + + boolean success; + if (exportAll) { + success = exportAllToFile(ctx, fullPath); + } else { + success = exportRulesToFile(ctx, fullPath); + } + + if (success) { + Api.toast(ctx, ctx.getString(R.string.export_rules_success) + " " + fullPath.getAbsolutePath()); + } else { + Api.toast(ctx, ctx.getString(R.string.export_rules_fail)); + } + }); + fileDialog.showDialog(); + } catch (Exception e) { + // Fallback to original method if file dialog fails + if (exportAll) { + exportAllPreferencesToFileConfirm(ctx); + } else { + exportRulesToFileConfirm(ctx); + } + } + } + + private static boolean exportRulesToFile(Context ctx, File file) { + boolean res = false; + try (FileOutputStream fOut = new FileOutputStream(file); + OutputStreamWriter myOutWriter = new OutputStreamWriter(fOut)) { + + JSONObject obj = new JSONObject(getCurrentRulesAsMap(ctx)); + JSONArray jArray = new JSONArray("[" + obj.toString() + "]"); + JSONObject exportObject = new JSONObject(); + exportObject.put("rules", jArray); + String mode = G.pPrefs.getString(Api.PREF_MODE, Api.MODE_WHITELIST); + exportObject.put("mode", mode); + // portable rules; older versions ignore this key and read "rules" + exportObject.put(BackupHelper.V2_KEY, BackupHelper.exportRules(ctx, PREFS_NAME)); + + myOutWriter.write(exportObject.toString()); + myOutWriter.flush(); // Ensure data is written + res = true; + Log.i(TAG, "Successfully exported rules to: " + file.getAbsolutePath()); + } catch (Exception e) { + Log.e(TAG, "Error exporting rules to file: " + file.getAbsolutePath(), e); + } + return res; + } + + /** + * @return rules of every profile, keyed by profile name ("AFWallPrefs" for the default profile) + */ + private static JSONObject getAllProfileRules(Context ctx) throws JSONException { + JSONObject profileObject = new JSONObject(); + profileObject.put(DEFAULT_PREFS_NAME, new JSONObject(getRulesForProfile(ctx, DEFAULT_PREFS_NAME))); + for (ProfileData profile : ProfileHelper.getProfiles()) { + // rules are stored under the identifier, which differs from the name (e.g. spaces removed) + profileObject.put(profile.getName(), new JSONObject(getRulesForProfile(ctx, profile.getIdentifier()))); + } + return profileObject; + } + + /** + * Full backup. The v1 sections keep their layout so older versions can still import the file; + * "v2" holds the complete, portable data and is what this version imports. + */ + private static JSONObject buildFullExport(Context ctx) throws JSONException { + JSONObject exportObject = new JSONObject(); + Map prefOverrides = new HashMap<>(); + if (G.enableMultiProfile()) { + exportObject.put("_profiles", getAllProfileRules(ctx)); + // versions that still use the old profile model read these instead: keys are the + // preference file names, custom profiles are listed in "plusprofiles" + JSONObject legacyProfiles = new JSONObject(); + JSONObject legacyAdditional = new JSONObject(); + List additional = new ArrayList<>(); + legacyProfiles.put(DEFAULT_PREFS_NAME, new JSONObject(getRulesForProfile(ctx, DEFAULT_PREFS_NAME))); + for (ProfileData profile : ProfileHelper.getProfiles()) { + String identifier = profile.getIdentifier(); + JSONObject rules = new JSONObject(getRulesForProfile(ctx, identifier)); + if (identifier.matches("AFWallProfile[123]")) { + legacyProfiles.put(identifier, rules); + prefOverrides.put("profile" + identifier.charAt(identifier.length() - 1), profile.getName()); + } else { + legacyAdditional.put(identifier, rules); + additional.add(identifier); + } + } + exportObject.put("profiles", legacyProfiles); + exportObject.put("additional_profiles", legacyAdditional); + prefOverrides.put("plusprofiles", TextUtils.join(",", additional)); + } else { + exportObject.put("default", new JSONObject(getCurrentRulesAsMap(ctx))); + } + + exportObject.put("prefs", BackupHelper.exportV1Prefs(G.gPrefs, prefOverrides)); + // profile-specific preferences (mode, custom scripts, ...) of the active profile + if (G.pPrefs != null) { + exportObject.put("profilePrefs", BackupHelper.exportV1Prefs(G.pPrefs)); + exportObject.put("mode", G.pPrefs.getString(Api.PREF_MODE, Api.MODE_WHITELIST)); + } + exportObject.put(BackupHelper.V2_KEY, BackupHelper.exportFull(ctx)); + return exportObject; + } + + private static boolean exportAllToFile(Context ctx, File file) { + boolean res = false; + try (FileOutputStream fOut = new FileOutputStream(file); + OutputStreamWriter myOutWriter = new OutputStreamWriter(fOut)) { + + JSONObject exportObject = buildFullExport(ctx); + myOutWriter.write(exportObject.toString()); + myOutWriter.flush(); // Ensure data is written + res = true; + Log.i(TAG, "Successfully exported all preferences to: " + file.getAbsolutePath()); + } catch (Exception e) { + Log.e(TAG, "Error exporting all preferences to file: " + file.getAbsolutePath(), e); + } + return res; + } + private static void updateExportPackage(Map exportMap, String packageName, boolean isChecked, int identifier) throws JSONException { if (!isChecked) { return; @@ -2513,13 +4011,9 @@ private static void updateExportPackage(Map exportMap, Strin } private static void updatePackage(Context ctx, String savedPkg_uid, Map exportMap, int identifier) throws JSONException { - StringTokenizer tok = new StringTokenizer(savedPkg_uid, "|"); - while (tok.hasMoreTokens()) { - String uid = tok.nextToken(); - if (!uid.isEmpty()) { - String packageName = ctx.getPackageManager().getNameForUid(Integer.parseInt(uid)); - updateExportPackage(exportMap, packageName, /*is_checked=*/ true, identifier); - } + for (int uid : UidListParser.parse(savedPkg_uid)) { + String packageName = ctx.getPackageManager().getNameForUid(uid); + updateExportPackage(exportMap, packageName, /*is_checked=*/ true, identifier); } } @@ -2548,57 +4042,23 @@ public static boolean exportAll(Context ctx, final String fileName) { boolean res = false; try { File file; - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // Android 11+ (API 30+): Use scoped storage + file = new File(ctx.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), fileName); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Android 10 (API 29): Use app-specific directory + file = new File(ctx.getExternalFilesDir(null), fileName); + } else { + // Android 9 and below: Use legacy external storage File dir = new File(Environment.getExternalStorageDirectory().getAbsolutePath() + File.separator + "afwall"); dir.mkdirs(); - file = new File(dir, fileName); - } else { - file = new File(ctx.getExternalFilesDir(null), fileName); + file = new File(dir, fileName); } try (FileOutputStream fOut = new FileOutputStream(file); OutputStreamWriter myOutWriter = new OutputStreamWriter(fOut)) { - JSONObject exportObject = new JSONObject(); - if (G.enableMultiProfile()) { - if (!G.isProfileMigrated()) { - JSONObject profileObject = new JSONObject(); - for (String profile : G.profiles) { - profileObject.put(profile, new JSONObject(getRulesForProfile(ctx, profile))); - } - exportObject.put("profiles", profileObject); - - JSONObject addProfileObject = new JSONObject(); - for (String profile : G.getAdditionalProfiles()) { - addProfileObject.put(profile, new JSONObject(getRulesForProfile(ctx, profile))); - } - exportObject.put("additional_profiles", addProfileObject); - } else { - JSONObject profileObject = new JSONObject(); - String profileName = "AFWallPrefs"; - profileObject.put(profileName, new JSONObject(getRulesForProfile(ctx, profileName))); - - List profileDataList = ProfileHelper.getProfiles(); - for (ProfileData profile : profileDataList) { - profileName = profile.getName(); - if (profile.getIdentifier().startsWith("AFWallProfile")) { - profileName = profile.getIdentifier(); - } - profileObject.put(profile.getName(), new JSONObject(getRulesForProfile(ctx, profileName))); - } - exportObject.put("_profiles", profileObject); - } - } else { - JSONObject obj = new JSONObject(getCurrentRulesAsMap(ctx)); - exportObject.put("default", obj); - } - - exportObject.put("prefs", getAllAppPreferences(ctx, G.gPrefs)); - - String mode = G.pPrefs.getString(Api.PREF_MODE, Api.MODE_WHITELIST); - exportObject.put("mode", mode); - - myOutWriter.append(exportObject.toString()); + myOutWriter.append(buildFullExport(ctx).toString()); res = true; } @@ -2623,27 +4083,21 @@ private static Map getRulesForProfile(Context ctx, String pr return exportMap; } - private static JSONArray getAllAppPreferences(Context ctx, SharedPreferences gPrefs) throws JSONException { - Map keys = gPrefs.getAll(); - JSONArray arr = new JSONArray(); - for (Map.Entry entry : keys.entrySet()) { - JSONObject obj = new JSONObject(); - obj.put(entry.getKey(), entry.getValue().toString()); - arr.put(obj); - } - return arr; - } - public static boolean exportRules(Context ctx, final String fileName) { boolean res = false; File file; - if(Build.VERSION.SDK_INT < Build.VERSION_CODES.Q ){ - File dir = new File(Environment.getExternalStorageDirectory().getAbsolutePath() + "/afwall/" ); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // Android 11+ (API 30+): Use scoped storage + file = new File(ctx.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), fileName); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Android 10 (API 29): Use app-specific directory + file = new File(ctx.getExternalFilesDir(null), fileName); + } else { + // Android 9 and below: Use legacy external storage + File dir = new File(Environment.getExternalStorageDirectory().getAbsolutePath() + "/afwall/"); dir.mkdirs(); file = new File(dir, fileName); - } else{ - file = new File(ctx.getExternalFilesDir(null) + "/" + fileName) ; } try { @@ -2660,6 +4114,8 @@ public static boolean exportRules(Context ctx, final String fileName) { String mode = G.pPrefs.getString(Api.PREF_MODE, Api.MODE_WHITELIST); exportObject.put("mode", mode); + // portable rules; older versions ignore this key and read "rules" + exportObject.put(BackupHelper.V2_KEY, BackupHelper.exportRules(ctx, PREFS_NAME)); myOutWriter.append(exportObject.toString()); res = true; @@ -2683,29 +4139,19 @@ private static boolean importRulesRoot(Context ctx, File file, StringBuilder ms boolean returnVal = false; BufferedReader br = null; try { - com.topjohnwu.superuser.Shell.Result result = com.topjohnwu.superuser.Shell.cmd("cat " + file.getAbsolutePath()).exec(); + // Use shell-safe quoting to prevent path injection + String safePath = "'" + file.getAbsolutePath().replace("'", "'\\''" ) + "'"; + com.topjohnwu.superuser.Shell.Result result = com.topjohnwu.superuser.Shell.cmd("cat " + safePath).exec(); List out = result.getOut(); String data = TextUtils.join("", out); - - try { - //old export format - JSONArray array = new JSONArray(data); - updateRulesFromJson(ctx, (JSONObject) array.get(0), PREFS_NAME); - } catch (JSONException e) { - //new exported format - JSONObject jsonObject = new JSONObject(data); - //save mode - if(jsonObject.get("mode") != null) { - G.pPrefs.edit().putString(PREF_MODE, jsonObject.getString("mode")).apply(); - } - JSONArray array = (JSONArray) jsonObject.get("rules"); - updateRulesFromJson(ctx, (JSONObject) array.get(0), PREFS_NAME); - } + importRulesData(ctx, data, msg); returnVal = true; + } catch (java.util.concurrent.RejectedExecutionException e) { + Log.w(TAG, "Import rules file read rejected: " + e.getMessage()); } catch (JSONException e) { - Log.e(TAG, e.getLocalizedMessage()); + Log.e(TAG, "JSON parsing error during import: " + e.getLocalizedMessage()); } catch (Exception e) { - Log.e(TAG, e.getLocalizedMessage()); + Log.e(TAG, "Failed to import rules from file: " + e.getLocalizedMessage()); } finally { if (br != null) { try { @@ -2727,17 +4173,12 @@ private static boolean importRules(Context ctx, File file, StringBuilder msg) { text.append(line); } String data = text.toString(); - JSONObject jsonObject = new JSONObject(data); - if (jsonObject.has("mode")) { - G.pPrefs.edit().putString(PREF_MODE, jsonObject.getString("mode")).apply(); - } - JSONArray array = jsonObject.optJSONArray("rules"); - if (array != null) { - updateRulesFromJson(ctx, (JSONObject) array.get(0), PREFS_NAME); - } else { - updateRulesFromJson(ctx, jsonObject, PREFS_NAME); + if (data.trim().isEmpty()) { + msg.append("Import file contains no data"); + return false; } - + + importRulesData(ctx, data, msg); returnVal = true; } catch (FileNotFoundException e) { if (e.getMessage().contains("EACCES")) { @@ -2753,6 +4194,77 @@ private static boolean importRules(Context ctx, File file, StringBuilder msg) { } + /** + * Import a "rules only" backup into the current profile. Handles every format: the oldest + * (a bare array), v1 ({"rules": [...], "mode"}) and v2 (a "v2" section next to the v1 keys). + */ + private static void importRulesData(Context ctx, String data, StringBuilder msg) throws JSONException { + Object parsed = new JSONTokener(data).nextValue(); + if (parsed instanceof JSONArray) { + updateRulesFromJson(ctx, ((JSONArray) parsed).getJSONObject(0), PREFS_NAME); + return; + } + if (!(parsed instanceof JSONObject)) { + throw new JSONException("Not an AFWall+ rules backup"); + } + JSONObject jsonObject = (JSONObject) parsed; + JSONObject v2 = jsonObject.optJSONObject(BackupHelper.V2_KEY); + if (v2 != null && v2.optJSONArray("rules") != null) { + reportSkipped(ctx, BackupHelper.importRules(ctx, v2, PREFS_NAME), msg); + return; + } + if (jsonObject.has("mode")) { + G.pPrefs.edit().putString(PREF_MODE, jsonObject.getString("mode")).apply(); + } + JSONArray array = jsonObject.optJSONArray("rules"); + if (array != null) { + updateRulesFromJson(ctx, (JSONObject) array.get(0), PREFS_NAME); + } else { + updateRulesFromJson(ctx, jsonObject, PREFS_NAME); + } + } + + private static void reportSkipped(Context ctx, BackupHelper.ImportStats stats, StringBuilder msg) { + int skipped = stats.skippedApps(); + if (skipped > 0) { + msg.append(ctx.getResources().getQuantityString(R.plurals.import_skipped_apps, skipped, skipped)); + } + } + + /** + * UID on this device of a v1 rule key: a package name, a special entry + * ("dev.afwall.special..."), or "sharedUserId:uid" as written for shared UIDs. v1 has no + * Android user, so entries map to the main user. + * + * @return null if it doesn't exist on this device + */ + private static Integer resolveV1RuleUid(String key, BackupHelper.DeviceUidMapper mapper) { + if (key.startsWith(SYSTEM_UID_PKG_PREFIX)) { + try { + return Integer.parseInt(key.substring(SYSTEM_UID_PKG_PREFIX.length())); + } catch (NumberFormatException e) { + return null; + } + } + if (key.startsWith("dev.afwall.special")) { + return mapper.specialUid(key); + } + int sep = key.lastIndexOf(':'); + if (sep > 0) { + String name = key.substring(0, sep); + try { + int srcUid = Integer.parseInt(key.substring(sep + 1)); + if (srcUid >= 0 && srcUid % 100000 < android.os.Process.FIRST_APPLICATION_UID) { + return srcUid % 100000; // system UIDs are the same on every device + } + } catch (NumberFormatException ignored) { + } + Integer appId = mapper.appIdForSharedUser(name); + return appId != null ? appId : mapper.appIdForPackage(name); + } + return mapper.appIdForPackage(key); + } + private static void updateRulesFromJson(Context ctx, JSONObject object, String preferenceName) throws JSONException { final StringBuilder[] uidBuilders = new StringBuilder[7]; uidBuilders[WIFI_EXPORT] = new StringBuilder(); @@ -2764,34 +4276,30 @@ private static void updateRulesFromJson(Context ctx, JSONObject object, String p uidBuilders[TOR_EXPORT] = new StringBuilder(); Map json = JsonHelper.toMap(object); - final PackageManager pm = ctx.getPackageManager(); + BackupHelper.DeviceUidMapper mapper = new BackupHelper.DeviceUidMapper(ctx); for (Map.Entry entry : json.entrySet()) { - String pkgName = entry.getKey(); - if (pkgName.contains(":")) { - pkgName = pkgName.split(":")[0]; + Integer uid = resolveV1RuleUid(entry.getKey(), mapper); + if (uid == null) { + continue; // not on this device } - JSONObject jsonObj = (JSONObject) JsonHelper.toJSON(entry.getValue()); Iterator keys = jsonObj.keys(); while (keys.hasNext()) { - String key = (String) keys.next(); - int exportType = Integer.parseInt(key); + int exportType; + try { + exportType = Integer.parseInt((String) keys.next()); + } catch (NumberFormatException e) { + continue; + } + if (exportType < 0 || exportType >= uidBuilders.length) { + continue; + } StringBuilder uidBuilder = uidBuilders[exportType]; - if (uidBuilder.length() != 0) { uidBuilder.append('|'); } - - if (pkgName.startsWith("dev.afwall.special")) { - uidBuilder.append(specialApps.get(pkgName)); - } else { - try { - uidBuilder.append(pm.getApplicationInfo(pkgName, 0).uid); - } catch (NameNotFoundException e) { - // Handle exception if needed - } - } + uidBuilder.append(uid); } } @@ -2808,28 +4316,39 @@ private static void updateRulesFromJson(Context ctx, JSONObject object, String p edit.apply(); } - private static boolean shouldIgnoreKey(String key) { - String[] ignore = {"appVersion", "fixLeak", "enableLogService", "sort", "storedProfile", "hasRoot", "logChains", "kingDetect", "fingerprintEnabled"}; - return Arrays.asList(ignore).contains(key); - } - - private static boolean isIntType(String key) { - String[] intType = {"logPingTime", "customDelay", "patternMax", "widgetX", "widgetY", "notification_priority"}; - return Arrays.asList(intType).contains(key); + /** + * Import a "_profiles" section: keys are profile names. Profiles that don't exist yet are + * created. + */ + private static void importProfilesByName(Context ctx, JSONObject profileObject) throws JSONException { + Iterator keys = profileObject.keys(); + while (keys.hasNext()) { + String name = keys.next(); + String identifier = DEFAULT_PREFS_NAME.equals(name) ? name : ProfileHelper.ensureProfileNamed(name); + importProfileRules(ctx, profileObject, name, identifier); + } } - private static void importProfiles(Context ctx, JSONObject profileObject) throws JSONException { + /** + * Import a "profiles" / "additional_profiles" section of the old profile model: keys are the + * preference file names (AFWallPrefs, AFWallProfile1-3, or the custom profile name). + */ + private static void importLegacyProfiles(Context ctx, JSONObject profileObject) throws JSONException { Iterator keys = profileObject.keys(); while (keys.hasNext()) { - String key = keys.next(); - try { - JSONObject obj = profileObject.getJSONObject(key); - updateRulesFromJson(ctx, obj, key); - } catch (JSONException e) { - if (e.getMessage().contains("No value")) { - // continue; - } + String identifier = keys.next(); + if (!DEFAULT_PREFS_NAME.equals(identifier)) { + ProfileHelper.ensureProfile(ctx, identifier); } + importProfileRules(ctx, profileObject, identifier, identifier); + } + } + + private static void importProfileRules(Context ctx, JSONObject profileObject, String key, String identifier) { + try { + updateRulesFromJson(ctx, profileObject.getJSONObject(key), identifier); + } catch (JSONException e) { + Log.w(TAG, "Skipping profile " + key + " on import: " + e.getMessage()); } } private static boolean importAll(Context ctx, File file, StringBuilder msg) { @@ -2842,52 +4361,45 @@ private static boolean importAll(Context ctx, File file, StringBuilder msg) { text.append(line); } String data = text.toString(); + if (data.trim().isEmpty()) { + msg.append("Import file contains no data"); + return false; + } + JSONObject object = new JSONObject(data); - // Allow/deny rule - if (object.has("mode")) { - G.pPrefs.edit().putString(PREF_MODE, object.getString("mode")).apply(); + // backups from this version on: the complete, portable section + JSONObject v2 = object.optJSONObject(BackupHelper.V2_KEY); + if (v2 != null && v2.optJSONArray("profiles") != null) { + reportSkipped(ctx, BackupHelper.importFull(ctx, v2), msg); + return true; } - JSONArray prefArray = object.getJSONArray("prefs"); - for (int i = 0; i < prefArray.length(); i++) { - JSONObject prefObj = prefArray.getJSONObject(i); - Iterator keys = prefObj.keys(); + // Basic validation of expected JSON structure + if (!object.has("prefs") && !object.has("profiles") && !object.has("_profiles") && !object.has("default")) { + msg.append("Import file does not contain valid AFWall+ data"); + Log.w(TAG, "Invalid import file structure - missing expected keys"); + return false; + } - while (keys.hasNext()) { - String key = keys.next(); - String value = prefObj.getString(key); - if (shouldIgnoreKey(key)) { - continue; - } - if (value.equals("true") || value.equals("false")) { - G.gPrefs.edit().putBoolean(key, Boolean.parseBoolean(value)); - } else { - try { - if (key.equals("multiUserId")) { - G.gPrefs.edit().putLong(key, Long.parseLong(value)); - } else if (isIntType(key)) { - G.gPrefs.edit().putString(key, value); - } else { - int intValue = Integer.parseInt(value); - G.gPrefs.edit().putInt(key, intValue); - } - } catch (NumberFormatException e) { - G.gPrefs.edit().putString(key, value); - } - } - } + // Allow/deny rule + if (object.has("mode")) { + G.pPrefs.edit().putString(PREF_MODE, object.getString("mode")).apply(); } - if (G.enableMultiProfile()) { - if (G.isProfileMigrated()) { - JSONObject profileObject = object.getJSONObject("_profiles"); - importProfiles(ctx, profileObject); - } else { - JSONObject profileObject = object.getJSONObject("profiles"); - importProfiles(ctx, profileObject); - JSONObject customProfileObject = object.getJSONObject("additional_profiles"); - importProfiles(ctx, customProfileObject); + // v1 stored every preference as a string; write them with the types they are read with + BackupHelper.importV1Prefs(object.optJSONArray("prefs"), G.gPrefs); + // profile-specific preferences (mode, custom scripts, ...) of the exported profile + BackupHelper.importV1Prefs(object.optJSONArray("profilePrefs"), G.pPrefs, true); + + // pick the rules section by what the file contains, not by the current settings + if (object.has("_profiles")) { + importProfilesByName(ctx, object.getJSONObject("_profiles")); + } else if (object.has("profiles")) { + // backup from the old profile model: keys are the preference file names + importLegacyProfiles(ctx, object.getJSONObject("profiles")); + if (object.has("additional_profiles")) { + importLegacyProfiles(ctx, object.getJSONObject("additional_profiles")); } } else { JSONObject defaultRules = object.getJSONObject("default"); @@ -2896,8 +4408,8 @@ private static boolean importAll(Context ctx, File file, StringBuilder msg) { returnVal = true; } catch (FileNotFoundException e) { msg.append(ctx.getString(R.string.import_rules_missing)); - } catch (IOException | JSONException e) { - Log.e(TAG, e.getLocalizedMessage()); + } catch (Exception e) { + Log.e(TAG, "Unable to import " + file, e); } return returnVal; @@ -2907,11 +4419,32 @@ public static boolean loadSharedPreferencesFromFile(Context ctx, StringBuilder b boolean res = false; File file = new File(fileName); if (file.exists()) { + // Basic file validation + if (file.length() == 0) { + builder.append("Import file is empty"); + Log.w(TAG, "Import file is empty: " + fileName); + return false; + } + if (file.length() > 50 * 1024 * 1024) { // 50MB limit + builder.append("Import file is too large (>50MB)"); + Log.w(TAG, "Import file is too large: " + fileName + " (" + file.length() + " bytes)"); + return false; + } + + Log.i(TAG, "Importing from file: " + fileName + " (loadAll: " + loadAll + ")"); if (loadAll) { res = importAll(ctx, file, builder); } else { res = importRules(ctx, file, builder); } + if (res) { + // settings such as multi-profile or the active profile's file may have changed + G.reloadPrefs(); + applications = null; + } + } else { + builder.append("Import file does not exist: " + fileName); + Log.w(TAG, "Import file does not exist: " + fileName); } return res; } @@ -2938,8 +4471,58 @@ public static void showInstalledAppDetails(Context context, String packageName) public static boolean isNetfilterSupported() { boolean netfiler_exists = new File("/proc/net/netfilter").exists(); - Shell.Result result = Shell.cmd("cat /proc/net/ip_tables_targets").exec(); - return netfiler_exists && result.isSuccess(); + try { + Shell.Result result = Shell.cmd("cat /proc/net/ip_tables_targets").exec(); + return netfiler_exists && result.isSuccess(); + } catch (java.util.concurrent.RejectedExecutionException e) { + Log.w(TAG, "Netfilter check rejected: " + e.getMessage()); + return false; + } catch (Exception e) { + Log.e(TAG, "Failed to check netfilter support: " + e.getMessage()); + return false; + } + } + + /** + * Check if a package has android.permission.INTERNET via shell. + * Used for packages invisible to PackageManager due to package visibility restrictions. + */ + private static boolean hasInternetPermissionViaShell(String packageName) { + return hasInternetPermissionViaShell(packageName, null); + } + + private static boolean hasInternetPermissionViaShell(String packageName, + HashMap internetPermissionCache) { + if (internetPermissionCache != null && internetPermissionCache.containsKey(packageName)) { + return internetPermissionCache.get(packageName); + } + boolean hasPermission = false; + try { + // Fetch full dumpsys output and search in-process (avoids a second shell fork for grep). + Shell.Result result = Shell.cmd("dumpsys package " + packageName).exec(); + if (result.isSuccess()) { + for (String line : result.getOut()) { + if (line.contains("android.permission.INTERNET")) { + hasPermission = true; + break; + } + } + } else { + Log.w(TAG, "dumpsys package failed while checking INTERNET permission for " + packageName); + } + } catch (Exception e) { + Log.w(TAG, "Failed to check INTERNET permission for " + packageName + ": " + e.getMessage()); + } + if (internetPermissionCache != null) internetPermissionCache.put(packageName, hasPermission); + return hasPermission; + } + + /** + * @return special entry name ("dev.afwall.special...") -> UID on this device + */ + public static Map getSpecialAppUids() { + initSpecial(); + return new HashMap<>(specialApps); } private static void initSpecial() { @@ -2965,7 +4548,7 @@ public static void updateLanguage(Context context, String lang) { Resources res = context.getResources(); Configuration conf = res.getConfiguration(); conf.locale = defaultLocale; - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.N) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { context.createConfigurationContext(conf); } else { context.getResources().updateConfiguration(conf, context.getResources().getDisplayMetrics()); @@ -2979,7 +4562,7 @@ public static void updateLanguage(Context context, String lang) { Resources res = context.getResources(); Configuration conf = res.getConfiguration(); conf.locale = locale; - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.N) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { context.createConfigurationContext(conf); } else { context.getResources().updateConfiguration(conf, context.getResources().getDisplayMetrics()); @@ -3034,12 +4617,15 @@ public static String loadData(final Context context, } /** - * Encrypt the password + * Encrypt the password - DEPRECATED: Use SecureCrypto.encryptSecure() for new code + * This method is kept for backward compatibility only * * @param key * @param data * @return + * @deprecated Use SecureCrypto.encryptSecure() instead for better security */ + @Deprecated public static String hideCrypt(String key, String data) { if (key == null || data == null) return null; @@ -3060,12 +4646,15 @@ public static String hideCrypt(String key, String data) { } /** - * Decrypt the password + * Decrypt the password - DEPRECATED: Use SecureCrypto.decryptSecure() for new code + * This method is kept for backward compatibility only * * @param key * @param data * @return + * @deprecated Use SecureCrypto.decryptSecure() instead for better security */ + @Deprecated public static String unhideCrypt(String key, String data) { if (key == null || data == null) return null; @@ -3139,24 +4728,59 @@ public static int getConnectivityStatus(Context context) { * @param ctx */ public static void applyDefaultChains(Context ctx, RootCommand callback) { + List v4 = new ArrayList<>(); + v4.add(G.ipv4Input() ? "-P INPUT ACCEPT" : "-P INPUT DROP"); + v4.add(G.ipv4Fwd() ? "-P FORWARD ACCEPT" : "-P FORWARD DROP"); + v4.add(G.ipv4Output() ? "-P OUTPUT ACCEPT" : "-P OUTPUT DROP"); + + // One script and one submission: submitting the same RootCommand twice (as this used to do + // for IPv4 and IPv6) replaces the first command list before it runs, so the IPv4 policies + // were never set, and the callback fired twice. + List out = new ArrayList<>(); + iptablesCommands(v4, out, false); + if (G.controlIPv6()) { + iptablesCommands(defaultChainsv6Commands(), out, true); + } else if (G.enableIPv6() || G.fixLeak()) { + //related to #511, disable ipv6 but use startup leak. + iptablesCommands(v4, out, true); + } + callback.setRetryExitCode(IPTABLES_TRY_AGAIN).run(ctx, out); + } + + /** + * The fix leak boot script sets INPUT/OUTPUT/FORWARD to DROP (IPv4 and IPv6) until AFWall+ + * applies its rules. When the firewall is disabled or inactive at boot no rules are applied, + * so the policies have to be opened again explicitly, or the device stays offline. + */ + public static void liftBootLeakProtection(Context ctx) { + if (G.initPath() == null) { + return; // no fix leak script configured + } List cmds = new ArrayList<>(); - cmds.add(G.ipv4Input() ? "-P INPUT ACCEPT" : "-P INPUT DROP"); - cmds.add(G.ipv4Fwd() ? "-P FORWARD ACCEPT" : "-P FORWARD DROP"); - cmds.add(G.ipv4Output() ? "-P OUTPUT ACCEPT" : "-P OUTPUT DROP"); - applyQuick(ctx, cmds, callback); - applyDefaultChainsv6(ctx, callback); + cmds.add("-P INPUT ACCEPT"); + cmds.add("-P OUTPUT ACCEPT"); + cmds.add("-P FORWARD ACCEPT"); + List out = new ArrayList<>(); + iptablesCommands(cmds, out, false); + iptablesCommands(cmds, out, true); + Log.i(TAG, "Firewall not active at boot; opening chain policies set by the fix leak script"); + new RootCommand().setRetryExitCode(IPTABLES_TRY_AGAIN).run(ctx, out); } public static void applyDefaultChainsv6(Context ctx, RootCommand callback) { if (G.controlIPv6()) { - List cmds = new ArrayList<>(); - cmds.add(G.ipv6Input() ? "-P INPUT ACCEPT" : "-P INPUT DROP"); - cmds.add(G.ipv6Fwd() ? "-P FORWARD ACCEPT" : "-P FORWARD DROP"); - cmds.add(G.ipv6Output() ? "-P OUTPUT ACCEPT" : "-P OUTPUT DROP"); - applyIPv6Quick(ctx, cmds, callback); + applyIPv6Quick(ctx, defaultChainsv6Commands(), callback); } } + private static List defaultChainsv6Commands() { + List cmds = new ArrayList<>(); + cmds.add(G.ipv6Input() ? "-P INPUT ACCEPT" : "-P INPUT DROP"); + cmds.add(G.ipv6Fwd() ? "-P FORWARD ACCEPT" : "-P FORWARD DROP"); + cmds.add(G.ipv6Output() ? "-P OUTPUT ACCEPT" : "-P OUTPUT DROP"); + return cmds; + } + /** * Delete all firewall rules. For diagnostic purposes only. * @@ -3200,40 +4824,86 @@ public static boolean isFixPathFileExist(String fileName) { return false; } + /** + * @param mountType "RW" before writing to {@code path}, "RO" afterwards. Already writable file + * systems (e.g. /data) are left alone. Blocking. + */ public static boolean mountDir(Context context, String path, String mountType) { - if (path != null) { - String busyboxPath = Api.getBusyBoxPath(context, true); - if (!busyboxPath.trim().isEmpty()) { - return RootTools.remount(path, mountType, busyboxPath); - } else { - return false; - } + if (path == null) { + return false; } - return false; + return RootFiles.remount(path, "RW".equalsIgnoreCase(mountType), Api.getBusyBoxPath(context, true)); } public static void checkAndCopyFixLeak(final Context context, final String fileName) { - if (G.initPath() != null && G.fixLeak() && !isFixPathFileExist(fileName)) { - final String srcPath = new File(ctx.getDir("bin", 0), fileName) - .getAbsolutePath(); - - new Thread(() -> { - String path = G.initPath(); - if (path != null) { - File f = new File(path); - if (mountDir(context, getFixLeakPath(fileName), "RW")) { - //make sure it's executable - new RootCommand() - .setReopenShell(true) - .setLogging(true) - .run(ctx, "chmod 755 " + f.getAbsolutePath()); - RootTools.copyFile(srcPath, (f.getAbsolutePath() + "/" + fileName), - true, false); - mountDir(context, getFixLeakPath(fileName), "RO"); - } - } - }).start(); + if (G.initPath() != null && G.fixLeak()) { + new Thread(() -> installFixLeakScript(context, fileName)).start(); + } + } + + /** + * Install (or update) the fix leak script in the configured startup directory. Blocking: call + * it off the main thread. + * + * @return true if the installed script is up to date + */ + public static boolean installFixLeakScript(final Context context, final String fileName) { + String path = G.initPath(); + String dest = getFixLeakPath(fileName); + if (path == null || dest == null) { + return false; + } + final String srcPath = new File(ctx.getDir("bin", 0), fileName).getAbsolutePath(); + // Refresh our copy from the APK: bundled scripts are otherwise only re-extracted + // when the version code changes. + if (!installBinary(ctx, R.raw.afwallstart, fileName)) { + Log.w(TAG, "Unable to refresh the fix leak script"); + return false; + } + // Replace the installed script when it is missing or outdated (older versions did + // nothing on devices with file-based encryption). The init dir is usually + // root-only, so compare as root. + if (com.topjohnwu.superuser.Shell.cmd("cmp -s '" + srcPath + "' '" + dest + "'").exec().isSuccess()) { + return true; + } + Log.i(TAG, "Installing fix leak script to " + dest); + File f = new File(path); + if (!mountDir(context, dest, "RW")) { + Log.w(TAG, "Unable to mount " + path + " read-write"); + return false; + } + //make sure it's executable + com.topjohnwu.superuser.Shell.cmd("chmod 755 '" + f.getAbsolutePath() + "'").exec(); + RootFiles.copy(srcPath, f.getAbsolutePath() + "/" + fileName); + // init only runs executable scripts; synchronous, before remounting read-only + com.topjohnwu.superuser.Shell.cmd("chmod 755 '" + dest + "'").exec(); + mountDir(context, dest, "RO"); + return com.topjohnwu.superuser.Shell.cmd("cmp -s '" + srcPath + "' '" + dest + "'").exec().isSuccess(); + } + + /** + * Remove the fix leak script from the configured startup directory. Blocking. + * + * @return true if it is not installed anymore + */ + public static boolean removeFixLeakScript(final Context context, final String fileName) { + return removeFixLeakScriptAt(context, getFixLeakPath(fileName)); + } + + /** + * @param dest full path of the installed script (e.g. in a previously used directory) + */ + public static boolean removeFixLeakScriptAt(final Context context, final String dest) { + if (dest == null || !RootFiles.exists(dest)) { + return true; } + if (!mountDir(context, dest, "RW")) { + Log.w(TAG, "Unable to mount " + G.initPath() + " read-write"); + return false; + } + boolean removed = com.topjohnwu.superuser.Shell.cmd("rm -f '" + dest + "'").exec().isSuccess(); + mountDir(context, dest, "RO"); + return removed; } public static Context updateBaseContextLocale(Context context) { @@ -3268,61 +4938,45 @@ private static Context updateResourcesLocaleLegacy(Context context, Locale local return context; } - public static void setDefaultPermission(ApplicationInfo applicationInfo) { - - boolean isModified = false; + /** + * Apply the "default connections for new apps" (donate) to a newly installed app, in the + * active profile. The lists hold allowed apps in allow-list mode and blocked apps in block-list + * mode, so the same columns mean "allowed" resp. "blocked". + * + * @return true if the app was added to any list + */ + public static boolean setDefaultPermission(Context ctx, int uid) { + int modeType = G.pPrefs.getString(Api.PREF_MODE, Api.MODE_WHITELIST).equals(Api.MODE_WHITELIST) ? 0 : 1; + List columns = G.readDefaultConnection(modeType); + if (columns.isEmpty()) { + return false; + } + // column (as in the setting) -> rule list + String[] lists = {PREF_LAN_PKG_UIDS, PREF_WIFI_PKG_UIDS, PREF_3G_PKG_UIDS, PREF_ROAMING_PKG_UIDS, + PREF_TOR_PKG_UIDS, PREF_VPN_PKG_UIDS, PREF_TETHER_PKG_UIDS}; SharedPreferences prefs = ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); Editor edit = prefs.edit(); - - // Get the mode type - int modeType = G.pPrefs.getString(Api.PREF_MODE, Api.MODE_WHITELIST).equals(Api.MODE_WHITELIST) ? 0 : 1; - - // Get the preference list - List list = SQLite.select().from(DefaultConnectionPref.class) - .where(DefaultConnectionPref_Table.modeType.eq(modeType)) - .queryList(); - - for (DefaultConnectionPref pref : list) { - if (pref.isState()) { - int uid = applicationInfo.uid; - switch (pref.getUid()) { - case 0: - edit.putString(PREF_LAN_PKG_UIDS, prefs.getString(PREF_LAN_PKG_UIDS, "") + "|" + uid); - isModified = true; - break; - case 1: - edit.putString(PREF_WIFI_PKG_UIDS, prefs.getString(PREF_WIFI_PKG_UIDS, "") + "|" + uid); - isModified = true; - break; - case 2: - edit.putString(PREF_3G_PKG_UIDS, prefs.getString(PREF_3G_PKG_UIDS, "") + "|" + uid); - isModified = true; - break; - case 3: - edit.putString(PREF_ROAMING_PKG_UIDS, prefs.getString(PREF_ROAMING_PKG_UIDS, "") + "|" + uid); - isModified = true; - break; - case 4: - edit.putString(PREF_TOR_PKG_UIDS, prefs.getString(PREF_TOR_PKG_UIDS, "") + "|" + uid); - isModified = true; - break; - case 5: - edit.putString(PREF_VPN_PKG_UIDS, prefs.getString(PREF_VPN_PKG_UIDS, "") + "|" + uid); - isModified = true; - break; - case 6: - edit.putString(PREF_TETHER_PKG_UIDS, prefs.getString(PREF_TETHER_PKG_UIDS, "") + "|" + uid); - isModified = true; - break; - } + boolean isModified = false; + for (int column : columns) { + if (column < 0 || column >= lists.length) { + continue; + } + Set uids = new TreeSet<>(UidListParser.parse(prefs.getString(lists[column], ""))); + if (uids.add(uid)) { + edit.putString(lists[column], TextUtils.join("|", uids)); + isModified = true; } } if (isModified) { edit.apply(); - // Make sure rules are modified flag is set - Api.setRulesUpToDate(false); - fastApply(ctx, new RootCommand()); + applications = null; + if (isEnabled(ctx)) { + // the per-app rules changed: a full apply + setRulesUpToDate(false); + fastApply(ctx, new RootCommand()); + } } + return isModified; } static class RuleDataSet { @@ -3369,6 +5023,22 @@ public String toString() { } } + /** + * Run a command on libsu's main shell (a new one is started if it died). + * + * @return the output, or null if it could not be run + */ + private static List executeSafeShellCommand(String command) { + try { + Shell.Result result = Shell.cmd(command).exec(); + return result != null ? result.getOut() : null; + } catch (Exception e) { + // e.g. RejectedExecutionException / interrupted I/O while the app shuts down + Log.w(TAG, "Root command could not be run: " + e.getMessage()); + return null; + } + } + private static class RunCommand extends AsyncTask, Integer> { private int exitCode = -1; @@ -3386,10 +5056,23 @@ protected Integer doInBackground(Object... params) { StringBuilder res = (StringBuilder) params[1]; Log.i(TAG, "Executing root commands of" + commands.size()); try { + // Check if task is cancelled before proceeding + if (isCancelled()) { + Log.d(TAG, "RunCommand task was cancelled, aborting execution"); + return -1; + } + if (Shell.getShell().isRoot() && !Shell.isAppGrantedRoot()) return -1; if (commands != null && commands.size() > 0) { - List output = Shell.cmd(String.valueOf(commands)).exec().getOut(); + // Check again before executing shell command + if (isCancelled()) { + Log.d(TAG, "RunCommand task was cancelled before shell execution"); + return -1; + } + + // Use a safe shell execution wrapper + List output = executeSafeShellCommand(String.valueOf(commands)); if (output != null) { exitCode = 0; if (output.size() > 0) { @@ -3402,13 +5085,49 @@ protected Integer doInBackground(Object... params) { exitCode = 1; } } + } catch (java.util.concurrent.RejectedExecutionException e) { + Log.w(TAG, "Shell execution rejected, likely due to app shutdown: " + e.getMessage()); + exitCode = -1; + } catch (RuntimeException ex) { + // Check if this is a wrapped ExecutionException with InterruptedIOException + Throwable cause = ex.getCause(); + if (cause instanceof java.util.concurrent.ExecutionException) { + java.util.concurrent.ExecutionException execEx = (java.util.concurrent.ExecutionException) cause; + if (execEx.getCause() instanceof java.io.InterruptedIOException) { + Log.w(TAG, "Shell command execution was interrupted: " + execEx.getCause().getMessage()); + exitCode = -1; + return exitCode; + } + } else if (ex.getCause() instanceof java.io.InterruptedIOException) { + Log.w(TAG, "Shell command execution was interrupted: " + ex.getCause().getMessage()); + exitCode = -1; + return exitCode; + } + Log.e(TAG, "Shell command execution failed: " + ex.getMessage()); + if (res != null) + res.append("\n").append(ex); + exitCode = -1; } catch (Exception ex) { + Log.e(TAG, "Shell command execution failed with unexpected exception: " + ex.getMessage()); if (res != null) res.append("\n").append(ex); + exitCode = -1; } return exitCode; } + @Override + protected void onCancelled() { + Log.d(TAG, "RunCommand task was cancelled"); + super.onCancelled(); + } + + @Override + protected void onCancelled(Integer result) { + Log.d(TAG, "RunCommand task was cancelled with result: " + result); + super.onCancelled(result); + } + } @@ -3560,6 +5279,25 @@ public String toStringWithUID() { return tostr; } + public String toStringForList(boolean includeUid, boolean includePackageName) { + StringBuilder s = new StringBuilder(); + if (includeUid) { + s.append("[ "); + s.append(uid); + s.append(" ] "); + } + for (int i = 0; i < names.size(); i++) { + if (i != 0) s.append(", "); + s.append(names.get(i)); + } + if (includePackageName && pkgName != null && !pkgName.startsWith("dev.afwall.special.")) { + s.append("\n"); + s.append(pkgName); + } + s.append("\n"); + return s.toString(); + } + } public static void copySharedPreferences(SharedPreferences fromPreferences, SharedPreferences.Editor toEditor) { diff --git a/app/src/main/java/dev/ukanth/ufirewall/InterfaceDetails.java b/app/src/main/java/dev/ukanth/ufirewall/InterfaceDetails.java index 28f6197f2..f0408b3ed 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/InterfaceDetails.java +++ b/app/src/main/java/dev/ukanth/ufirewall/InterfaceDetails.java @@ -35,17 +35,18 @@ public class InterfaceDetails { public boolean isUsbTethered = false; public boolean tetherUsbStatusKnown = false; - public String lanMaskV4 = ""; - public String lanMaskV6 = ""; + // Support multiple LAN subnets (Issue #1362) + public java.util.List lanMaskV4 = new java.util.ArrayList<>(); + public java.util.List lanMaskV6 = new java.util.ArrayList<>(); // DNS servers for targeted rules instead of opening port 53 to all LAN hosts public java.util.List dnsServersV4 = new java.util.ArrayList<>(); public java.util.List dnsServersV6 = new java.util.ArrayList<>(); // supplementary info - String wifiName = ""; - boolean netEnabled = false; - boolean noIP = false; + public String wifiName = ""; + public boolean netEnabled = false; + public boolean noIP = false; public int netType = -1; public boolean equals(InterfaceDetails that) { diff --git a/app/src/main/java/dev/ukanth/ufirewall/InterfaceTracker.java b/app/src/main/java/dev/ukanth/ufirewall/InterfaceTracker.java index 02b42a0ac..5da39c6e6 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/InterfaceTracker.java +++ b/app/src/main/java/dev/ukanth/ufirewall/InterfaceTracker.java @@ -29,8 +29,11 @@ import android.content.Context; import android.content.pm.PackageManager; import android.net.ConnectivityManager; +import android.net.LinkProperties; import android.net.NetworkInfo; +import android.net.RouteInfo; import android.net.wifi.WifiManager; +import android.os.Build; import java.lang.reflect.Method; import java.net.Inet4Address; @@ -57,8 +60,11 @@ public final class InterfaceTracker { "wwan+", "cdma_rmnet+", "clat4+", "cc2mni+", "bond1+", "rmnet_smux+", "ccinet+", "v4-rmnet+", "seth_w+", "v4-rmnet_data+", "rmnet_ipa+", "rmnet_data+", "r_rmnet_data+"}; - public static final String[] ITFS_VPN = {"tun+", "ppp+", "tap+"}; + // wg+ covers WireGuard kernel interfaces so VPN-bound apps are matched correctly. + public static final String[] ITFS_VPN = {"tun+", "ppp+", "tap+", "wg+"}; + // Note: Bluetooth PAN (bnep+) is already covered by ITFS_WIFI above, so it is intentionally + // not duplicated here to avoid an interface matching both the wifi and tether chains. public static final String[] ITFS_TETHER = {"bt-pan", "usb+", "rndis+", "rmnet_usb+"}; public static final String BOOT_COMPLETED = "BOOT_COMPLETED"; @@ -229,7 +235,7 @@ private static InterfaceDetails getInterfaceDetails(Context context) { } catch (Exception e) { Log.i(Api.TAG, "Exception in getInterfaceDetails.checkTether" + e.getLocalizedMessage()); } - NewInterfaceScanner.populateLanMasks(ret); + NewInterfaceScanner.populateLanMasks(context, ret); getDnsServers(context, ret); return ret; } @@ -334,13 +340,13 @@ public static boolean checkForNewCfg(Context context) { "usb-tethered: " + (newCfg.isUsbTethered ? "yes" : "no") + ")"); } - if (!newCfg.lanMaskV4.equals("")) { - Log.i(TAG, "IPv4 LAN netmask on " + newCfg.wifiName + ": " + newCfg.lanMaskV4); + if (!newCfg.lanMaskV4.isEmpty()) { + Log.i(TAG, "IPv4 LAN netmasks on " + newCfg.wifiName + ": " + String.join(", ", newCfg.lanMaskV4)); } - if (!newCfg.lanMaskV6.equals("")) { - Log.i(TAG, "IPv6 LAN netmask on " + newCfg.wifiName + ": " + newCfg.lanMaskV6); + if (!newCfg.lanMaskV6.isEmpty()) { + Log.i(TAG, "IPv6 LAN netmasks on " + newCfg.wifiName + ": " + String.join(", ", newCfg.lanMaskV6)); } - if (newCfg.lanMaskV6.equals("") && newCfg.lanMaskV4.equals("")) { + if (newCfg.lanMaskV6.isEmpty() && newCfg.lanMaskV4.isEmpty()) { Log.i(TAG, "No ipaddress found"); } } @@ -363,6 +369,9 @@ public static void applyRulesOnChange(Context context, final String reason) { } else if (!Api.isEnabled(ctx)) { Log.d(TAG, reason + ": firewall is disabled, ignoring"); return; + } else if (Api.isDisableInProgress()) { + Log.d(TAG, reason + ": firewall is being disabled, ignoring"); + return; } Log.d(TAG, reason + " applying rules"); // update Api.PREFS_NAME so we pick up the right profile @@ -371,34 +380,39 @@ public static void applyRulesOnChange(Context context, final String reason) { if (reason.equals(InterfaceTracker.BOOT_COMPLETED) || reason.startsWith(InterfaceTracker.BOOT_COMPLETED)) { Log.i(TAG, "Applying boot-specific rules for reason: " + reason); - applyBootRules(reason); + applyBootRules(ctx, reason); } else { Log.i(TAG, "Applying regular rules for reason: " + reason); - applyRules(reason); + applyRules(ctx, reason); } } - public static void applyRules(final String reason) { - Api.fastApply(ctx, new RootCommand() + public static void applyRules(final Context appCtx, final String reason) { + final Context safeCtx = appCtx != null ? appCtx : ctx; + if (safeCtx == null) { + Log.e(TAG, "Cannot apply rules: no context available"); + return; + } + Api.fastApply(safeCtx, new RootCommand() .setFailureToast(R.string.error_apply) .setCallback(new RootCommand.Callback() { @Override public void cbFunc(RootCommand state) { if (state.exitCode == 0) { Log.i(TAG, reason + ": applied rules at " + System.currentTimeMillis()); - Api.applyDefaultChains(ctx, new RootCommand() + Api.applyDefaultChains(safeCtx, new RootCommand() .setCallback(new RootCommand.Callback() { @Override public void cbFunc(RootCommand state) { if (state.exitCode != 0) { - Api.errorNotification(ctx); + Api.errorNotification(safeCtx); } } })); } else { //lets try applying all rules Api.setRulesUpToDate(false); - Api.fastApply(ctx, new RootCommand() + Api.fastApply(safeCtx, new RootCommand() .setCallback(new RootCommand.Callback() { @Override public void cbFunc(RootCommand state) { @@ -406,15 +420,15 @@ public void cbFunc(RootCommand state) { Log.i(TAG, reason + ": applied rules at " + System.currentTimeMillis()); } else { Log.e(TAG, reason + ": applySavedIptablesRules() returned an error"); - Api.errorNotification(ctx); + Api.errorNotification(safeCtx); } - Api.applyDefaultChains(ctx, new RootCommand() + Api.applyDefaultChains(safeCtx, new RootCommand() .setFailureToast(R.string.error_apply) .setCallback(new RootCommand.Callback() { @Override public void cbFunc(RootCommand state) { if (state.exitCode != 0) { - Api.errorNotification(ctx); + Api.errorNotification(safeCtx); } } })); @@ -425,27 +439,32 @@ public void cbFunc(RootCommand state) { })); } - public static void applyBootRules(final String reason) { - Api.applySavedIptablesRules(ctx, true, new RootCommand() + public static void applyBootRules(final Context appCtx, final String reason) { + final Context safeCtx = appCtx != null ? appCtx : ctx; + if (safeCtx == null) { + Log.e(TAG, "Cannot apply boot rules: no context available"); + return; + } + Api.applySavedIptablesRules(safeCtx, true, new RootCommand() .setFailureToast(R.string.error_apply) .setCallback(new RootCommand.Callback() { @Override public void cbFunc(RootCommand state) { if (state.exitCode == 0) { Log.i(TAG, reason + ": applied rules at " + System.currentTimeMillis()); - Api.applyDefaultChains(ctx, new RootCommand() + Api.applyDefaultChains(safeCtx, new RootCommand() .setCallback(new RootCommand.Callback() { @Override public void cbFunc(RootCommand state) { if (state.exitCode != 0) { - Api.errorNotification(ctx); + Api.errorNotification(safeCtx); } } })); } else { //lets try applying all rules Api.setRulesUpToDate(false); - Api.applySavedIptablesRules(ctx, true, new RootCommand() + Api.applySavedIptablesRules(safeCtx, true, new RootCommand() .setCallback(new RootCommand.Callback() { @Override public void cbFunc(RootCommand state) { @@ -453,15 +472,15 @@ public void cbFunc(RootCommand state) { Log.i(TAG, reason + ": applied rules at " + System.currentTimeMillis()); } else { Log.e(TAG, reason + ": applySavedIptablesRules() returned an error"); - Api.errorNotification(ctx); + Api.errorNotification(safeCtx); } - Api.applyDefaultChains(ctx, new RootCommand() + Api.applyDefaultChains(safeCtx, new RootCommand() .setFailureToast(R.string.error_apply) .setCallback(new RootCommand.Callback() { @Override public void cbFunc(RootCommand state) { if (state.exitCode != 0) { - Api.errorNotification(ctx); + Api.errorNotification(safeCtx); } } })); @@ -474,7 +493,74 @@ public void cbFunc(RootCommand state) { private static class NewInterfaceScanner { - public static void populateLanMasks(InterfaceDetails ret) { + private static boolean isWifiLikeInterface(String name) { + if (name == null) { + return false; + } + for (String pattern : ITFS_WIFI) { + if (name.startsWith(truncAfter(pattern, "\\+"))) { + return true; + } + } + return false; + } + + private static void addMask(InterfaceDetails ret, InetAddress ip, int prefixLength) { + if (ip == null) { + return; + } + String mask = truncAfter(ip.getHostAddress(), "%") + "/" + prefixLength; + if (ip instanceof Inet4Address) { + if (!ret.lanMaskV4.contains(mask)) { + ret.lanMaskV4.add(mask); + } + } else if (ip instanceof Inet6Address) { + if (!ret.lanMaskV6.contains(mask)) { + ret.lanMaskV6.add(mask); + } + } + } + + private static void populateLanMasksFromRoutes(Context context, InterfaceDetails ret) { + if (context == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + return; + } + try { + ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + if (cm == null) { + return; + } + for (android.net.Network network : cm.getAllNetworks()) { + LinkProperties linkProperties = cm.getLinkProperties(network); + if (linkProperties == null) { + continue; + } + if (!isWifiLikeInterface(linkProperties.getInterfaceName())) { + continue; + } + for (RouteInfo route : linkProperties.getRoutes()) { + if (route == null || route.isDefaultRoute() || route.getDestination() == null) { + continue; + } + String mask = route.getDestination().toString(); + InetAddress addr = route.getDestination().getAddress(); + if (addr instanceof Inet4Address) { + if (!ret.lanMaskV4.contains(mask)) { + ret.lanMaskV4.add(mask); + } + } else if (addr instanceof Inet6Address) { + if (!ret.lanMaskV6.contains(mask)) { + ret.lanMaskV6.add(mask); + } + } + } + } + } catch (Exception e) { + Log.i(TAG, "Error fetching LAN routes: " + android.util.Log.getStackTraceString(e)); + } + } + + public static void populateLanMasks(Context context, InterfaceDetails ret) { try { Enumeration en = NetworkInterface.getNetworkInterfaces(); @@ -486,35 +572,23 @@ public static void populateLanMasks(InterfaceDetails ret) { continue; } - for (String pattern : ITFS_WIFI) { - if (intf.getName().startsWith(truncAfter(pattern, "\\+"))) { - match = true; - break; - } - } + match = isWifiLikeInterface(intf.getName()); if (!match) continue; ret.wifiName = intf.getName(); + // Collect ALL subnets from this interface (Issue #1362) Iterator addrList = intf.getInterfaceAddresses().iterator(); while (addrList.hasNext()) { InterfaceAddress addr = addrList.next(); InetAddress ip = addr.getAddress(); - String mask = truncAfter(ip.getHostAddress(), "%") + "/" + - addr.getNetworkPrefixLength(); - - if(ret.lanMaskV4.isEmpty() || ret.lanMaskV6.isEmpty()) { - if (ip instanceof Inet4Address) { - ret.lanMaskV4 = mask; - } else if (ip instanceof Inet6Address) { - ret.lanMaskV6 = mask; - } - } + addMask(ret, ip, addr.getNetworkPrefixLength()); } - if (ret.lanMaskV4.equals("") && ret.lanMaskV6.equals("")) { + if (ret.lanMaskV4.isEmpty() && ret.lanMaskV6.isEmpty()) { ret.noIP = true; } } + populateLanMasksFromRoutes(context, ret); } catch (Exception e) { Log.i(TAG, "Error fetching network interface list: " + android.util.Log.getStackTraceString(e)); } diff --git a/app/src/main/java/dev/ukanth/ufirewall/MainActivity.java b/app/src/main/java/dev/ukanth/ufirewall/MainActivity.java index 2ec8b4b8a..808afad4f 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/MainActivity.java +++ b/app/src/main/java/dev/ukanth/ufirewall/MainActivity.java @@ -113,6 +113,7 @@ import dev.ukanth.ufirewall.util.G; import dev.ukanth.ufirewall.util.PackageComparator; import dev.ukanth.ufirewall.util.SecurityUtil; +import dev.ukanth.ufirewall.util.ThemeHelper; import haibison.android.lockpattern.utils.AlpSettings; import kotlin.Suppress; @@ -135,6 +136,14 @@ public class MainActivity extends AppCompatActivity implements AdapterView.OnIte public static boolean dirty = false; + public static void requireFullApply() { + dirty = true; + } + + public static void addToQueue(@NonNull Api.PackageInfoData data) { + dirty = true; + } + private Menu mainMenu; private ListView listview = null; @@ -148,7 +157,6 @@ public class MainActivity extends AppCompatActivity implements AdapterView.OnIte private Spinner mSpinner; private TextWatcher filterTextWatcher; private MaterialDialog runProgress; - private AlertDialog dialogLegend = null; private BroadcastReceiver uiProgressReceiver4, uiProgressReceiver6, toastReceiver, themeRefreshReceiver, uiRefreshReceiver; private IntentFilter uiFilter4, uiFilter6; @@ -203,10 +211,6 @@ public void onCreate(Bundle savedInstanceState) { Toolbar toolbar = findViewById(R.id.main_toolbar); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { - getWindow().setFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS, - WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); - } setSupportActionBar(toolbar); @@ -221,7 +225,10 @@ public void onCreate(Bundle savedInstanceState) { AlpSettings.Display.setStealthMode(getApplicationContext(), G.enableStealthPattern()); AlpSettings.Display.setMaxRetries(getApplicationContext(), G.getMaxPatternTry()); - Api.assertBinaries(this, true); + // Move binary assertion to background thread to avoid blocking main thread + // This includes file I/O and process execution (waitFor) which can cause ANR + final Context appContext = getApplicationContext(); + AsyncTask.execute(() -> Api.assertBinaries(appContext, true)); initDone = 0; mSwipeLayout = findViewById(R.id.swipe_container); @@ -235,6 +242,7 @@ public void onCreate(Bundle savedInstanceState) { startRootShell(); new SecurityUtil(MainActivity.this).passCheck(); registerNetworkObserver(); + // FirewallService is started in onResume() } registerUIbroadcast4(); registerUIbroadcast6(); @@ -254,15 +262,53 @@ private void checkPermissions() { PERMISSION_BLUETOOTH); } } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) - != PackageManager.PERMISSION_GRANTED) { - // permissions have not been granted. - ActivityCompat.requestPermissions(MainActivity.this, - new String[]{Manifest.permission.POST_NOTIFICATIONS}, - PERMISSION_NOTIFICATION); - } + checkNotificationPermission(); + } + + // shown at most once per app start + private static boolean notificationsOffReminderShown; + + /** + * Ask for the notification permission once, explaining why; afterwards, if notifications are + * off, remind (once per start, until "don't remind me") instead of asking on every launch. + */ + private void checkNotificationPermission() { + final String asked = "notifPermissionAsked"; + final String dontRemind = "notifOffDontRemind"; + boolean granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU + || ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + == PackageManager.PERMISSION_GRANTED; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !granted + && !G.gPrefs.getBoolean(asked, false)) { + new MaterialDialog.Builder(this) + .title(R.string.notif_permission_title) + .content(R.string.notif_permission_rationale) + .positiveText(R.string.OK) + .negativeText(R.string.notif_not_now) + .onPositive((dialog, which) -> { + G.gPrefs.edit().putBoolean(asked, true).apply(); + ActivityCompat.requestPermissions(MainActivity.this, + new String[]{Manifest.permission.POST_NOTIFICATIONS}, PERMISSION_NOTIFICATION); + }) + .onNegative((dialog, which) -> G.gPrefs.edit().putBoolean(asked, true).apply()) + .show(); + return; } + boolean enabled = granted && androidx.core.app.NotificationManagerCompat.from(this).areNotificationsEnabled(); + if (enabled || notificationsOffReminderShown || G.gPrefs.getBoolean(dontRemind, false)) { + return; + } + notificationsOffReminderShown = true; + new MaterialDialog.Builder(this) + .title(R.string.notif_off_title) + .content(R.string.notif_off_text) + .positiveText(R.string.notif_open_settings) + .negativeText(R.string.notif_not_now) + .neutralText(R.string.notif_dont_remind) + .onPositive((dialog, which) -> + dev.ukanth.ufirewall.preferences.UIPreferenceFragment.openNotificationSettings(MainActivity.this)) + .onNeutral((dialog, which) -> G.gPrefs.edit().putBoolean(dontRemind, true).apply()) + .show(); } private void updateSelectedColumns() { @@ -277,8 +323,9 @@ private void updateSelectedColumns() { private void registerLogService() { if (G.enableLogService()) { Log.i(G.TAG, "Starting Log Service"); - final Intent logIntent = new Intent(getBaseContext(), LogService.class); - startService(logIntent); + // guarded foreground start: a plain startService() throws when the activity is + // created while the screen is off / locked (the app counts as background then) + LogService.ensureRunning(getApplicationContext()); } } @@ -353,17 +400,7 @@ public void cbFunc(RootCommand state) { } private void initTheme() { - switch (G.getSelectedTheme()) { - case "D": - setTheme(R.style.AppDarkTheme); - break; - case "L": - setTheme(R.style.AppLightTheme); - break; - case "B": - setTheme(R.style.AppBlackTheme); - break; - } + ThemeHelper.applyTheme(this); } private void initTextWatcher() { @@ -387,11 +424,8 @@ public void onTextChanged(CharSequence s, int start, int before, private void registerNetworkObserver() { - startService(new Intent(getBaseContext(), FirewallService.class)); //start log service - if (G.enableLogService()) { - startService(new Intent(getBaseContext(), LogService.class)); - } + LogService.ensureRunning(getApplicationContext()); } @Override @@ -533,9 +567,16 @@ private void selectFilterGroup() { * @param i */ private void filterApps(int i) { + // Never run the (slow) app scan on the UI thread. If the cache was never + // built, load it asynchronously and let onPostExecute re-trigger the filter. + // (A non-null but empty cache means a scan already ran - show it, don't re-loop.) + if (Api.applications == null) { + showOrLoadApplications(); + return; + } Set returnList = new HashSet<>(); List inputList; - List allApps = Api.getApps(getApplicationContext(), null); + List allApps = Api.applications; if (i >= 0) { for (PackageInfoData infoData : allApps) { if (infoData != null) { @@ -556,7 +597,6 @@ private void filterApps(int i) { try { Collections.sort(inputList, new PackageComparator()); } catch (Exception e) { - Log.d(Api.TAG, "Exception in filter Sorting"); } ArrayAdapter appAdapter; if (selectedColumns <= DEFAULT_VIEW_LIMIT) { @@ -569,7 +609,6 @@ private void filterApps(int i) { // restore this.listview.setSelectionFromTop(index, top); } else { - Log.d(Api.TAG, "Input list is empty"); } } @@ -644,6 +683,10 @@ public void onResume() { LocalBroadcastManager.getInstance(getApplicationContext()).registerReceiver(uiProgressReceiver4, uiFilter4); LocalBroadcastManager.getInstance(getApplicationContext()).registerReceiver(uiProgressReceiver6, uiFilter6); + // FirewallService owns the network-change receiver. Its background starts (boot on + // Android 15 after a force-stop, widget, Tasker) can be refused, so start it while in front. + FirewallService.ensureRunning(getApplicationContext()); + G.activityResumed(); //getSupportActionBar().setBackgroundDrawable(new ColorDrawable(G.primaryColor())); @@ -725,15 +768,22 @@ private void reloadPreferences() { } else { hideColumns(R.id.img_tor); } - - updateRadioFilter(); if (G.enableMultiProfile()) { setupMultiProfile(); } - selectFilterGroup(); + // Use async loading to avoid blocking the main thread + // If the app list is already cached, filterApps will use the cache (fast path) + // If not cached, showOrLoadApplications will load asynchronously with a progress dialog + if (Api.applications != null && Api.applications.size() > 0) { + // Cache is warm - use it directly (fast, non-blocking) + selectFilterGroup(); + } else { + // Cache is cold - load asynchronously to avoid ANR + showOrLoadApplications(); + } } private void clearNotification() { @@ -793,32 +843,13 @@ private void setupMultiProfile() { mSpinner.setOnItemSelectedListener(this); String currentProfile = G.storedProfile(); if (currentProfile != null) { - if (!G.isProfileMigrated()) { - switch (currentProfile) { - case Api.DEFAULT_PREFS_NAME: - mSpinner.setSelection(0); - break; - case "AFWallProfile1": - mSpinner.setSelection(1); - break; - case "AFWallProfile2": - mSpinner.setSelection(2); - break; - case "AFWallProfile3": - mSpinner.setSelection(3); - break; - default: - mSpinner.setSelection(spinnerAdapter.getPosition(currentProfile), false); + if (!currentProfile.equals(Api.DEFAULT_PREFS_NAME)) { + ProfileData data = ProfileHelper.getProfileByIdentifier(currentProfile); + if (data != null) { + mSpinner.setSelection(spinnerAdapter.getPosition(data.getName()), false); } } else { - if (!currentProfile.equals(Api.DEFAULT_PREFS_NAME)) { - ProfileData data = ProfileHelper.getProfileByIdentifier(currentProfile); - if (data != null) { - mSpinner.setSelection(spinnerAdapter.getPosition(data.getName()), false); - } - } else { - mSpinner.setSelection(spinnerAdapter.getPosition(currentProfile), false); - } + mSpinner.setSelection(0, false); } } } @@ -830,21 +861,9 @@ private void reloadProfileList(boolean reset) { mlocalList.add(G.gPrefs.getString("default", getString(R.string.defaultProfile))); - if (!G.isProfileMigrated()) { - mlocalList.add(G.gPrefs.getString("profile1", getString(R.string.profile1))); - mlocalList.add(G.gPrefs.getString("profile2", getString(R.string.profile2))); - mlocalList.add(G.gPrefs.getString("profile3", getString(R.string.profile3))); - List profilesList = G.getAdditionalProfiles(); - for (String profiles : profilesList) { - if (profiles != null && profiles.length() > 0) { - mlocalList.add(profiles); - } - } - } else { - List profilesList = ProfileHelper.getProfiles(); - for (ProfileData data : profilesList) { - mlocalList.add(data.getName()); - } + List profilesList = ProfileHelper.getProfiles(); + for (ProfileData data : profilesList) { + mlocalList.add(data.getName()); } } @@ -907,7 +926,7 @@ private void checkPreferences() { changed = true; } if (changed) - editor.commit(); + editor.apply(); // Use apply() instead of commit() to avoid blocking main thread } /** @@ -950,40 +969,15 @@ public void onItemSelected(AdapterView parent, View view, int position, long if (initDone > 1) { Spinner spinner = findViewById(R.id.profileGroup); String profileName = spinner.getSelectedItem().toString(); - if (!G.isProfileMigrated()) { - switch (position) { - case 0: - G.setProfile(true, "AFWallPrefs"); - break; - case 1: - G.setProfile(true, "AFWallProfile1"); - break; - case 2: - G.setProfile(true, "AFWallProfile2"); - break; - case 3: - G.setProfile(true, "AFWallProfile3"); - break; - default: - if (profileName != null) { - G.setProfile(true, profileName); - } - - } - setDirty(true); - } else { - switch (position) { - case 0: - G.setProfile(true, "AFWallPrefs"); - break; - default: - if (profileName != null) { - ProfileData data = ProfileHelper.getProfileByName(profileName); - G.setProfile(true, data.getIdentifier()); - } + if (position == 0) { + G.setProfile(true, Api.DEFAULT_PREFS_NAME); + } else if (profileName != null) { + ProfileData data = ProfileHelper.getProfileByName(profileName); + if (data != null) { + G.setProfile(true, data.getIdentifier()); } - setDirty(true); } + setDirty(true); G.reloadProfile(); refreshHeader(); showOrLoadApplications(); @@ -1022,6 +1016,13 @@ private void showApplications(final String searchStr) { } } } + // Package names are stored separately from labels, so include them in search. + if (!unique.contains(app.uid) && app.pkgName != null + && app.pkgName.toLowerCase().contains(searchStr.toLowerCase())) { + searchApp.add(app); + unique.add(app.uid); + isResultsFound = true; + } } } @@ -1030,6 +1031,8 @@ private void showApplications(final String searchStr) { apps2 = apps; } else if (isResultsFound || searchApp.size() > 0) { apps2 = searchApp; + } else { + apps2 = new ArrayList<>(); } // Sort applications - selected first, then alphabetically try { @@ -1046,7 +1049,6 @@ private void showApplications(final String searchStr) { this.listview.setSelectionFromTop(index, top); } } catch (Exception e) { - Log.d(Api.TAG, "Exception on Sorting"); } } @@ -1148,36 +1150,21 @@ private void disableOrEnable() { public boolean onOptionsItemSelected(MenuItem item) { super.onOptionsItemSelected(item); int selectedItem = item.getItemId(); - if (selectedItem == R.id.menu_legend) { - LayoutInflater inflater = LayoutInflater.from(this); - View view = inflater.inflate(R.layout.legend, null, false); - dialogLegend = new AlertDialog.Builder(this) - .setView(view) - .setCancelable(true) - .setOnDismissListener(new DialogInterface.OnDismissListener() { - @Override - public void onDismiss(DialogInterface dialogInterface) { - dialogLegend = null; - } - }) - .create(); - dialogLegend.show(); - return true; - } else if (selectedItem == R.id.menu_toggle) { + if (selectedItem == R.id.menu_toggle) { disableOrEnable(); return true; } else if (selectedItem == R.id.allowmode) { item.setChecked(true); Editor editor = getSharedPreferences(Api.PREFS_NAME, 0).edit(); editor.putString(Api.PREF_MODE, Api.MODE_WHITELIST); - editor.commit(); + editor.apply(); refreshHeader(); return true; } else if (selectedItem == R.id.blockmode) { item.setChecked(true); Editor editor2 = getSharedPreferences(Api.PREFS_NAME, 0).edit(); editor2.putString(Api.PREF_MODE, Api.MODE_BLACKLIST); - editor2.commit(); + editor2.apply(); refreshHeader(); return true; } else if (selectedItem == R.id.sort_default) { @@ -1240,9 +1227,8 @@ public void onDismiss(DialogInterface dialogInterface) { return true; } else if (selectedItem == R.id.menu_import) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - // Do some stuff + // Copy old data and show import dialog when complete copyOldExportedData(); - showImportDialog(); } else { if (ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { @@ -1263,16 +1249,81 @@ public void onDismiss(DialogInterface dialogInterface) { private void copyOldExportedData() { if (!G.hasCopyOld()) { - //using root to copy existing data to current directory on A11 - String existingDir = Environment.getExternalStorageDirectory() + "//afwall//"; - String targetDir = ctx.getExternalFilesDir(null) + "/"; - String command = "cp -R " + existingDir + " " + targetDir; - Log.i(TAG, "Invoking migration script " + command); - com.topjohnwu.superuser.Shell.Result result = com.topjohnwu.superuser.Shell.cmd(command).exec(); - G.hasCopyOldExports(true); + copyOldExportedDataAsync(() -> { + // On completion, show import dialog + runOnUiThread(() -> { + showImportDialog(); + }); + }); + } else { + // Already copied, show dialog immediately + showImportDialog(); } } + private void copyOldExportedDataAsync(Runnable onComplete) { + // Show progress dialog + MaterialDialog progressDialog = null; + try { + progressDialog = new MaterialDialog.Builder(this) + .title("Migrating Files") + .content("Copying backup files to new location...") + .progress(true, 0) + .cancelable(false) + .show(); + } catch (Exception e) { + Log.w(TAG, "Could not show progress dialog due to MaterialDialog compatibility issue", e); + // Fallback: Show toast notification + Api.toast(this, "Migrating backup files to new location..."); + } + + final MaterialDialog finalProgressDialog = progressDialog; + + // Run file copy operation in background thread + new Thread(() -> { + try { + //using root to copy existing data to current directory on A11+ + String existingDir = Environment.getExternalStorageDirectory() + "//afwall//"; + File targetFile = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R + ? ctx.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS) + : ctx.getExternalFilesDir(null); + String targetDir = (targetFile != null ? targetFile.getAbsolutePath() : ctx.getExternalFilesDir(null).getAbsolutePath()) + "/"; + String command = "cp -R " + existingDir + " " + targetDir; + Log.i(TAG, "Invoking migration script " + command); + + com.topjohnwu.superuser.Shell.Result result = com.topjohnwu.superuser.Shell.cmd(command).exec(); + + if (result.getCode() == 0) { + Log.i(TAG, "Migration script completed successfully"); + G.hasCopyOldExports(true); + } else { + Log.w(TAG, "Migration script failed with code: " + result.getCode()); + Log.w(TAG, "Migration output: " + result.getOut()); + } + + } catch (java.util.concurrent.RejectedExecutionException e) { + Log.w(TAG, "File migration rejected: " + e.getMessage()); + } catch (Exception e) { + // Check if the cause is an InterruptedIOException + if (e.getCause() instanceof java.io.InterruptedIOException) { + Log.w(TAG, "File migration interrupted: " + e.getCause().getMessage()); + } else { + Log.e(TAG, "Error during file migration", e); + } + } finally { + // Dismiss progress dialog and run completion callback on UI thread + runOnUiThread(() -> { + if (finalProgressDialog != null && finalProgressDialog.isShowing()) { + finalProgressDialog.dismiss(); + } + if (onComplete != null) { + onComplete.run(); + } + }); + } + }).start(); + } + private void search(MenuItem item) { item.setActionView(R.layout.searchbar); final EditText filterText = item.getActionView().findViewById( @@ -1302,22 +1353,31 @@ public boolean onMenuItemActionExpand(MenuItem item) { } private void showImportDialog() { - new MaterialDialog.Builder(this) - .title(R.string.imports) - .cancelable(false) - .items(new String[]{ - getString(R.string.import_rules), - getString(R.string.import_all)}) - .itemsCallbackSingleChoice(-1, (dialog, view, which, text) -> { + try { + new MaterialDialog.Builder(this) + .title(R.string.imports) + .cancelable(false) + .items(new String[]{ + getString(R.string.import_rules), + getString(R.string.import_all) + (G.isDoKey(getApplicationContext()) || isDonate() ? "" : " (" + getString(R.string.donate_only_short) + ")")}) + .itemsCallbackSingleChoice(-1, (dialog, view, which, text) -> { switch (which) { case 0: //Intent intent = new Intent(MainActivity.this, FileChooserActivity.class); //startActivityForResult(intent, FILE_CHOOSER_LOCAL); File mPath = null; - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - mPath = new File(Environment.getExternalStorageDirectory() + "//afwall//"); - } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + File extDir = ctx.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS); + if (extDir != null) { + extDir.mkdirs(); + mPath = extDir; + } else { + mPath = new File(ctx.getExternalFilesDir(null), "/"); + } + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { mPath = new File(ctx.getExternalFilesDir(null) + "/"); + } else { + mPath = new File(Environment.getExternalStorageDirectory() + "//afwall//"); } FileDialog fileDialog = new FileDialog(MainActivity.this, mPath, true); @@ -1329,8 +1389,12 @@ private void showImportDialog() { StringBuilder builder = new StringBuilder(); if (Api.loadSharedPreferencesFromFile(MainActivity.this, builder, fileSelected, false)) { Api.applications = null; + // imported rules take effect on the next apply; prompt for it + setDirty(true); showOrLoadApplications(); - Api.toast(MainActivity.this, getString(R.string.import_rules_success) + fileSelected); + // skipped-apps note first: the long path gets truncated + Api.toast(MainActivity.this, (builder.length() > 0 ? builder + "\n" : "") + + getString(R.string.import_rules_success) + fileSelected); } else { if (builder.toString().equals("")) { Api.toast(MainActivity.this, getString(R.string.import_rules_fail)); @@ -1346,10 +1410,18 @@ private void showImportDialog() { if (G.isDoKey(getApplicationContext()) || isDonate()) { File mPath2 = null; - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - mPath2 = new File(Environment.getExternalStorageDirectory() + "//afwall//"); - } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + File extDir = ctx.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS); + if (extDir != null) { + extDir.mkdirs(); + mPath2 = extDir; + } else { + mPath2 = new File(ctx.getExternalFilesDir(null), "/"); + } + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { mPath2 = new File(ctx.getExternalFilesDir(null), "/"); + } else { + mPath2 = new File(Environment.getExternalStorageDirectory() + "//afwall//"); } FileDialog fileDialog2 = new FileDialog(MainActivity.this, mPath2, false); fileDialog2.addFileListener(file -> { @@ -1357,8 +1429,12 @@ private void showImportDialog() { StringBuilder builder = new StringBuilder(); if (Api.loadSharedPreferencesFromFile(MainActivity.this, builder, fileSelected, true)) { Api.applications = null; + // imported rules take effect on the next apply; prompt for it + setDirty(true); showOrLoadApplications(); - Api.toast(MainActivity.this, getString(R.string.import_rules_success) + fileSelected); + // skipped-apps note first: the long path gets truncated + Api.toast(MainActivity.this, (builder.length() > 0 ? builder + "\n" : "") + + getString(R.string.import_rules_success) + fileSelected); Intent intent = getIntent(); finish(); startActivity(intent); @@ -1381,28 +1457,61 @@ private void showImportDialog() { .positiveText(R.string.imports) .negativeText(R.string.Cancel) .show(); + } catch (Exception e) { + Log.e(TAG, "MaterialDialog failed, likely due to cursor tinting issue on newer Android versions", e); + // Fallback: Show a simple toast message and try alternative approach + Api.toast(this, "Import dialog unavailable due to Android compatibility issue. Please use file manager to manually copy backup files to AFWall directory."); + } } private void showExportDialog() { - new MaterialDialog.Builder(this) - .title(R.string.exports) - .cancelable(false) - .items(new String[]{ - getString(R.string.export_rules), - getString(R.string.export_all)}) - .itemsCallbackSingleChoice(-1, (dialog, view, which, text) -> { - switch (which) { - case 0: - Api.exportRulesToFileConfirm(MainActivity.this); - break; - case 1: - Api.exportAllPreferencesToFileConfirm(MainActivity.this); - break; - } - return true; - }).positiveText(R.string.exports) - .negativeText(R.string.Cancel) - .show(); + try { + new MaterialDialog.Builder(this) + .title(R.string.exports) + .cancelable(false) + .items(new String[]{ + getString(R.string.export_rules), + getString(R.string.export_all) + (G.isDoKey(getApplicationContext()) || isDonate() ? "" : " (" + getString(R.string.donate_only_short) + ")")}) + .itemsCallbackSingleChoice(-1, (dialog, view, which, text) -> { + switch (which) { + case 0: + Api.exportRulesToFileWithPicker(MainActivity.this); + break; + case 1: + if (G.isDoKey(getApplicationContext()) || isDonate()) { + Api.exportAllPreferencesToFileWithPicker(MainActivity.this); + } else { + showExportAllWarningDialog(); + } + break; + } + return true; + }).positiveText(R.string.exports) + .negativeText(R.string.Cancel) + .show(); + } catch (Exception e) { + Log.e(TAG, "MaterialDialog failed, likely due to cursor tinting issue on newer Android versions", e); + Api.toast(this, "Export dialog unavailable due to Android compatibility issue. Please use Settings > Export to access export functionality."); + } + } + + private void showExportAllWarningDialog() { + try { + new MaterialDialog.Builder(this) + .title(R.string.export_all) + .content(R.string.export_all_warning) + .positiveText(R.string.exports) + .negativeText(R.string.Cancel) + .onPositive((dialog, which) -> { + Api.exportAllPreferencesToFileWithPicker(MainActivity.this); + }) + .show(); + } catch (Exception e) { + Log.e(TAG, "MaterialDialog failed, likely due to cursor tinting issue on newer Android versions", e); + // Fallback: Just show the export directly with a toast warning + Api.toast(this, getString(R.string.export_all_warning)); + Api.exportAllPreferencesToFileWithPicker(MainActivity.this); + } } private void showPreferences() { @@ -2284,10 +2393,6 @@ protected boolean isSuPackage(PackageManager pm, String suPackage) { @Override public void onDestroy() { super.onDestroy(); - if (dialogLegend != null) { - dialogLegend.dismiss(); - dialogLegend = null; - } if (getAppList != null) { getAppList.cancel(true); } @@ -2308,6 +2413,16 @@ public void onDestroy() { if (uiRefreshReceiver != null) { unregisterReceiver(uiRefreshReceiver); } + + // Clean up shell instances to prevent interruption crashes + try { + // Force close any existing shell instances + com.topjohnwu.superuser.Shell shell = com.topjohnwu.superuser.Shell.getCachedShell(); + if (shell != null && !shell.isAlive()) { + shell.close(); + } + } catch (Exception e) { + } } @Override @@ -2328,7 +2443,7 @@ private class PurgeTask extends AsyncTask { protected void onPreExecute() { progress = new MaterialDialog.Builder(activityReference.get()) .title(R.string.working) - .cancelable(false) + .cancelable(true) .content(R.string.purging_rules) .progress(true, 0) .show(); @@ -2376,6 +2491,10 @@ protected void onPostExecute(Boolean aVoid) { progress.dismiss(); progress = null; } catch (Exception ex) { + } finally { + assert progress != null; + progress.dismiss(); + progress = null; } } } @@ -2391,9 +2510,11 @@ public class GetAppList extends AsyncTask { @Override protected void onPreExecute() { + // Don't enumerate packages here - getInstalledApplications() on the UI thread + // blocks the main thread before the dialog even shows. Start with a placeholder + // max and update it from the background scan via doMaxProgress(). plsWait = new MaterialDialog.Builder(activityReference.get()).cancelable(false). - title(getString(R.string.reading_apps)).progress(false, getPackageManager().getInstalledApplications(0) - .size(), true).show(); + title(getString(R.string.reading_apps)).progress(false, 1, true).show(); doProgress(0); } @@ -2401,6 +2522,10 @@ public void doProgress(int value) { publishProgress(value); } + public void doMaxProgress(int value) { + publishProgress(Integer.MIN_VALUE, Math.max(1, value)); + } + @Override protected Void doInBackground(Void... params) { Api.getApps(activityReference.get(), this); @@ -2440,7 +2565,11 @@ protected void onPostExecute(Void result) { @Override protected void onProgressUpdate(Integer... progress) { - if (progress[0] == 0 || progress[0] == -1) { + if (progress[0] == Integer.MIN_VALUE && progress.length > 1) { + if (plsWait != null && plsWait.isShowing()) { + plsWait.setMaxProgress(Math.max(1, progress[1])); + } + } else if (progress[0] == 0 || progress[0] == -1) { //do nothing } else { if (plsWait != null) { @@ -2484,25 +2613,34 @@ protected Boolean doInBackground(Void... params) { .setFailureToast(R.string.error_apply) .setCallback(new RootCommand.Callback() { public void cbFunc(RootCommand state) { - try { - if (runProgress != null) { - runProgress.dismiss(); - } - } catch (Exception ex) { - } - if (state.exitCode == 0) { - setDirty(false); + final Context appCtx = getApplicationContext(); + if (state.exitCode != 0) { + // Keep the enabled state and the unsaved changes so the user can retry: + // flipping to "disabled" here left rules loaded while the UI said off. + Api.errorNotification(appCtx); } - //queue.clear(); runOnUiThread(() -> { - setDirty(false); - if (state.exitCode != 0) { - Api.errorNotification(activityReference.get()); - menuSetApplyOrSave(activityReference.get().mainMenu, false); - Api.setEnabled(activityReference.get(), false, true); - } else { - menuSetApplyOrSave(activityReference.get().mainMenu, enabled); - Api.setEnabled(activityReference.get(), enabled, true); + try { + if (runProgress != null) { + runProgress.dismiss(); + } + } catch (Exception ex) { + Log.w(Api.TAG, "Unable to dismiss apply dialog: " + ex.getMessage()); + } + MainActivity activity = activityReference.get(); + if (activity == null || activity.isFinishing() || activity.isDestroyed()) { + if (state.exitCode == 0) { + Api.setEnabled(appCtx, enabled, true); + } + return; + } + if (state.exitCode == 0) { + setDirty(false); + menuSetApplyOrSave(activity.mainMenu, enabled); + Api.setEnabled(activity, enabled, true); + if (enabled && G.enableLogService()) { + LogService.ensureRunning(activity); + } } refreshHeader(); }); @@ -2608,6 +2746,10 @@ public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) startRootShell(); new SecurityUtil(MainActivity.this).passCheck(); registerNetworkObserver(); + // Ensure FirewallService is started if firewall is enabled + if (Api.isEnabled(MainActivity.this)) { + Api.setEnabled(MainActivity.this, true, false); + } } } } diff --git a/app/src/main/java/dev/ukanth/ufirewall/activity/AppDetailActivity.java b/app/src/main/java/dev/ukanth/ufirewall/activity/AppDetailActivity.java index 40877bc8a..ac7f1870c 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/activity/AppDetailActivity.java +++ b/app/src/main/java/dev/ukanth/ufirewall/activity/AppDetailActivity.java @@ -16,9 +16,7 @@ import androidx.appcompat.widget.Toolbar; import com.raizlabs.android.dbflow.sql.language.SQLite; -import com.stericson.rootshell.execution.Command; -import com.stericson.rootshell.execution.Shell; -import com.stericson.roottools.RootTools; +import com.topjohnwu.superuser.Shell; import java.io.File; import java.util.Arrays; @@ -30,6 +28,7 @@ import dev.ukanth.ufirewall.log.LogPreference; import dev.ukanth.ufirewall.log.LogPreference_Table; import dev.ukanth.ufirewall.util.G; +import dev.ukanth.ufirewall.util.ThemeHelper; public class AppDetailActivity extends AppCompatActivity { public static final String TAG = "AFWall"; @@ -138,18 +137,7 @@ protected void onCreate(Bundle savedInstanceState) { } private void initTheme() { - switch(G.getSelectedTheme()) { - case "D": - setTheme(R.style.AppDarkTheme); - break; - case "L": - setTheme(R.style.AppLightTheme); - //set other colors - break; - case "B": - setTheme(R.style.AppBlackTheme); - break; - } + ThemeHelper.applyTheme(this); } private void setTotalBytesManual(TextView down, TextView up, int localUid) { @@ -178,25 +166,19 @@ private void setTotalBytesManual(TextView down, TextView up, int localUid) { String textSent = "0"; try { if (uidActualFileReceived.exists() && uidActualFileSent.exists()) { - Command command = new Command(0, "cat " + uidActualFileReceived.getAbsolutePath()) - { - @Override - public void commandOutput(int id, String line) { - down.setText(" : " + humanReadableByteCount(Long.parseLong(line), false)); - super.commandOutput(id, line); - } - }; - Command command1 = new Command(1, "cat " + uidActualFileSent.getAbsolutePath()) - { - @Override - public void commandOutput(int id, String line) { - up.setText(" : " + humanReadableByteCount(Long.parseLong(line), false)); - super.commandOutput(id, line); + // read as root; the result is delivered on the main thread + Shell.cmd("cat " + uidActualFileReceived.getAbsolutePath(), + "cat " + uidActualFileSent.getAbsolutePath()).submit(result -> { + java.util.List out = result.getOut(); + try { + if (out.size() >= 2) { + down.setText(" : " + humanReadableByteCount(Long.parseLong(out.get(0).trim()), false)); + up.setText(" : " + humanReadableByteCount(Long.parseLong(out.get(1).trim()), false)); + } + } catch (NumberFormatException e) { + Log.e(TAG, "Unexpected traffic counter: " + out); } - }; - Shell shell = RootTools.getShell(true); - shell.add(command); - shell.add(command1); + }); } } catch (Exception e) { Log.e(TAG, "Exception while reading tx bytes: " + e.getLocalizedMessage()); diff --git a/app/src/main/java/dev/ukanth/ufirewall/activity/AppRulesActivity.java b/app/src/main/java/dev/ukanth/ufirewall/activity/AppRulesActivity.java new file mode 100644 index 000000000..849207b56 --- /dev/null +++ b/app/src/main/java/dev/ukanth/ufirewall/activity/AppRulesActivity.java @@ -0,0 +1,198 @@ +package dev.ukanth.ufirewall.activity; + +import android.os.Bundle; +import android.text.method.DigitsKeyListener; +import android.text.TextUtils; +import android.view.MenuItem; +import android.view.View; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.Spinner; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; + +import java.util.List; +import java.util.Locale; + +import dev.ukanth.ufirewall.Api; +import dev.ukanth.ufirewall.MainActivity; +import dev.ukanth.ufirewall.R; +import dev.ukanth.ufirewall.customrules.CustomRule; +import dev.ukanth.ufirewall.util.AppRuleHelper; +import dev.ukanth.ufirewall.util.G; +import dev.ukanth.ufirewall.util.ThemeHelper; + +public class AppRulesActivity extends AppCompatActivity { + + public static final String EXTRA_UID = "uid"; + public static final String EXTRA_PACKAGE = "package"; + public static final String EXTRA_LABEL = "label"; + + private int uid; + private String packageName; + private String label; + private EditText destination; + private EditText port; + private Spinner protocol; + private LinearLayout rulesList; + private TextView emptyView; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + ThemeHelper.applyTheme(this); + setContentView(R.layout.app_rules); + setTitle(R.string.direct_rules_title); + + Toolbar toolbar = findViewById(R.id.app_rules_toolbar); + setSupportActionBar(toolbar); + ThemeHelper.apply(this); + if (getSupportActionBar() != null) { + getSupportActionBar().setHomeButtonEnabled(true); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + + uid = getIntent().getIntExtra(EXTRA_UID, Api.SPECIAL_UID_ANY); + packageName = getIntent().getStringExtra(EXTRA_PACKAGE); + label = getIntent().getStringExtra(EXTRA_LABEL); + if (TextUtils.isEmpty(label)) { + label = packageName != null ? packageName : String.valueOf(uid); + } + + if (!AppRuleHelper.supportsUid(uid)) { + // kernel, tethering, NTP, ...: no UID iptables can match + Toast.makeText(this, R.string.direct_rules_not_supported, Toast.LENGTH_LONG).show(); + finish(); + return; + } + + TextView appTitle = findViewById(R.id.app_rules_app); + appTitle.setText(label + " [" + uid + "]"); + + destination = findViewById(R.id.direct_rule_destination); + port = findViewById(R.id.direct_rule_port); + port.setKeyListener(DigitsKeyListener.getInstance("0123456789:")); + protocol = findViewById(R.id.direct_rule_protocol); + rulesList = findViewById(R.id.direct_rules_list); + emptyView = findViewById(R.id.direct_rules_empty); + Button add = findViewById(R.id.direct_rule_add); + Button portSeparator = findViewById(R.id.direct_rule_port_separator); + + ArrayAdapter protocolAdapter = new ArrayAdapter<>( + this, + android.R.layout.simple_spinner_item, + new String[]{"Any", "TCP", "UDP"}); + protocolAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + protocol.setAdapter(protocolAdapter); + + add.setOnClickListener(v -> addRule()); + portSeparator.setOnClickListener(v -> insertPortSeparator()); + refreshRules(); + } + + private void addRule() { + String destinationValue = destination.getText().toString().trim(); + String portValue = port.getText().toString().trim(); + String protocolValue = protocol.getSelectedItem().toString().toLowerCase(Locale.US); + + if (destinationValue.isEmpty() && portValue.isEmpty()) { + Toast.makeText(this, R.string.direct_rules_need_match, Toast.LENGTH_SHORT).show(); + return; + } + + if (!destinationValue.isEmpty() && !AppRuleHelper.isValidDestination(destinationValue)) { + Toast.makeText(this, R.string.direct_rules_invalid_destination, Toast.LENGTH_SHORT).show(); + return; + } + + if (AppRuleHelper.isIpv6Destination(destinationValue) && !G.enableIPv6()) { + Toast.makeText(this, R.string.direct_rules_ipv6_disabled, Toast.LENGTH_LONG).show(); + return; + } + + if (!portValue.isEmpty()) { + if ("any".equals(protocolValue)) { + Toast.makeText(this, R.string.direct_rules_port_needs_protocol, Toast.LENGTH_SHORT).show(); + return; + } + if (!AppRuleHelper.isValidPortRange(portValue)) { + Toast.makeText(this, R.string.direct_rules_invalid_port, Toast.LENGTH_SHORT).show(); + return; + } + } + + String rule = Api.validateCustomRuleForStorage(AppRuleHelper.buildAllowRule(uid, destinationValue, protocolValue, portValue)); + if (rule == null) { + Toast.makeText(this, R.string.direct_rules_invalid_rule, Toast.LENGTH_SHORT).show(); + return; + } + + CustomRule customRule = new CustomRule(AppRuleHelper.buildAllowRuleName(uid, destinationValue, protocolValue, portValue), rule); + customRule.setActive(true); + customRule.save(); + MainActivity.requireFullApply(); + Api.setRulesUpToDate(false); + + destination.setText(""); + port.setText(""); + protocol.setSelection(0); + Toast.makeText(this, R.string.direct_rules_added, Toast.LENGTH_SHORT).show(); + refreshRules(); + } + + private void insertPortSeparator() { + int start = Math.max(port.getSelectionStart(), 0); + int end = Math.max(port.getSelectionEnd(), 0); + port.getText().replace(Math.min(start, end), Math.max(start, end), ":", 0, 1); + } + + private void refreshRules() { + rulesList.removeAllViews(); + List rules = AppRuleHelper.getRulesForUid(uid); + + emptyView.setVisibility(rules.isEmpty() ? View.VISIBLE : View.GONE); + for (CustomRule rule : rules) { + rulesList.addView(createRuleView(rule)); + } + } + + private View createRuleView(CustomRule rule) { + LinearLayout row = new LinearLayout(this); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setPadding(0, 10, 0, 10); + + TextView text = new TextView(this); + String state = rule.isActive() ? "" : "Disabled - "; + text.setText(state + AppRuleHelper.displayNameForRule(uid, rule.getName()) + "\n" + rule.getRule()); + text.setTextSize(13); + LinearLayout.LayoutParams textParams = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1); + row.addView(text, textParams); + + Button remove = new Button(this); + remove.setText(R.string.direct_rules_remove); + remove.setOnClickListener(v -> { + rule.delete(); + MainActivity.requireFullApply(); + Api.setRulesUpToDate(false); + Toast.makeText(this, R.string.direct_rules_removed, Toast.LENGTH_SHORT).show(); + refreshRules(); + }); + row.addView(remove); + + return row; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + onBackPressed(); + return true; + } + return super.onOptionsItemSelected(item); + } +} diff --git a/app/src/main/java/dev/ukanth/ufirewall/activity/CustomScriptActivity.java b/app/src/main/java/dev/ukanth/ufirewall/activity/CustomScriptActivity.java index 3b3f41f62..d74722b86 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/activity/CustomScriptActivity.java +++ b/app/src/main/java/dev/ukanth/ufirewall/activity/CustomScriptActivity.java @@ -31,9 +31,10 @@ import android.view.MenuItem; import android.view.View; import android.view.View.OnClickListener; -import android.widget.EditText; import android.widget.TextView; +import com.google.android.material.textfield.TextInputEditText; + import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.widget.Toolbar; @@ -44,34 +45,34 @@ import dev.ukanth.ufirewall.Api; import dev.ukanth.ufirewall.R; import dev.ukanth.ufirewall.util.G; +import dev.ukanth.ufirewall.util.ThemeHelper; /** * Custom scripts activity. * This screen is displayed to change the custom scripts. */ public class CustomScriptActivity extends AppCompatActivity implements OnClickListener { - private EditText script; - private EditText script2; + private TextInputEditText script; + private TextInputEditText script2; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); initTheme(); - final View view = getLayoutInflater().inflate(R.layout.customscript, null); + setContentView(R.layout.customscript); - view.findViewById(R.id.customscript_ok).setOnClickListener(this); - view.findViewById(R.id.customscript_cancel).setOnClickListener(this); - ((TextView) view.findViewById(R.id.customscript_link)).setMovementMethod(LinkMovementMethod.getInstance()); + findViewById(R.id.customscript_ok).setOnClickListener(this); + findViewById(R.id.customscript_cancel).setOnClickListener(this); + ((TextView) findViewById(R.id.customscript_link)).setMovementMethod(LinkMovementMethod.getInstance()); final SharedPreferences prefs = getSharedPreferences(Api.PREFS_NAME, 0); - this.script = view.findViewById(R.id.customscript); + this.script = findViewById(R.id.customscript); this.script.setText(prefs.getString(Api.PREF_CUSTOMSCRIPT, "")); - this.script2 = view.findViewById(R.id.customscript2); + this.script2 = findViewById(R.id.customscript2); this.script2.setText(prefs.getString(Api.PREF_CUSTOMSCRIPT2, "")); setTitle(R.string.set_custom_script); - setContentView(view); Toolbar toolbar = findViewById(R.id.custom_toolbar); setSupportActionBar(toolbar); @@ -84,17 +85,7 @@ protected void onCreate(Bundle savedInstanceState) { private void initTheme() { - switch(G.getSelectedTheme()) { - case "D": - setTheme(R.style.AppDarkTheme); - break; - case "L": - setTheme(R.style.AppLightTheme); - break; - case "B": - setTheme(R.style.AppBlackTheme); - break; - } + ThemeHelper.applyTheme(this); } @Override diff --git a/app/src/main/java/dev/ukanth/ufirewall/activity/DataDumpActivity.java b/app/src/main/java/dev/ukanth/ufirewall/activity/DataDumpActivity.java index 210fb13c5..92fa62d54 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/activity/DataDumpActivity.java +++ b/app/src/main/java/dev/ukanth/ufirewall/activity/DataDumpActivity.java @@ -24,6 +24,7 @@ package dev.ukanth.ufirewall.activity; import android.Manifest; +import android.annotation.SuppressLint; import android.content.Context; import android.content.pm.PackageManager; import java.util.concurrent.ExecutorService; @@ -45,17 +46,24 @@ import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.widget.Toolbar; import androidx.core.app.ActivityCompat; +import androidx.cardview.widget.CardView; +import androidx.core.widget.NestedScrollView; import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.lang.ref.WeakReference; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; import dev.ukanth.ufirewall.Api; import dev.ukanth.ufirewall.R; import dev.ukanth.ufirewall.log.Log; +import dev.ukanth.ufirewall.util.FileDialog; import dev.ukanth.ufirewall.util.G; +import dev.ukanth.ufirewall.util.ThemeHelper; public abstract class DataDumpActivity extends AppCompatActivity { @@ -70,7 +78,21 @@ public abstract class DataDumpActivity extends AppCompatActivity { protected static final int MENU_ZOOM_IN = 22; protected static final int MENU_ZOOM_OUT = 23; TextView scaleGesture; - ScrollView mScrollView; + View mScrollView; // Can be either ScrollView or NestedScrollView + + // Modern layout components + private TextView rulesTitle; + private TextView rulesStatus; + private TextView rulesContent; + private TextView interfacesContent; + private TextView systemContent; + private TextView preferencesContent; + private TextView logcatContent; + private CardView interfacesCard; + private CardView systemCard; + private CardView preferencesCard; + private CardView logcatCard; + private boolean useModernLayout = true; protected Menu mainMenu; protected static String dataText; @@ -84,22 +106,187 @@ public abstract class DataDumpActivity extends AppCompatActivity { private static final int MY_PERMISSIONS_REQUEST_WRITE_STORAGE = 1; + private void initModernViews() { + rulesTitle = findViewById(R.id.rules_title); + rulesStatus = findViewById(R.id.rules_status); + rulesContent = findViewById(R.id.rules_content); + interfacesContent = findViewById(R.id.interfaces_content); + systemContent = findViewById(R.id.system_content); + preferencesContent = findViewById(R.id.preferences_content); + logcatContent = findViewById(R.id.logcat_content); + interfacesCard = findViewById(R.id.interfaces_card); + systemCard = findViewById(R.id.system_card); + preferencesCard = findViewById(R.id.preferences_card); + logcatCard = findViewById(R.id.logcat_card); + } + protected void setData(final String data) { dataText = data; Handler refresh = new Handler(Looper.getMainLooper()); refresh.post(() -> { - scaleGesture = findViewById(R.id.rules); - scaleGesture.setText(data); - scaleGesture.setTextSize(TypedValue.COMPLEX_UNIT_PX, G.ruleTextSize()); + if (useModernLayout) { + parseAndDisplayModernData(data); + } else { + scaleGesture = findViewById(R.id.rules); + scaleGesture.setText(data); + scaleGesture.setTextSize(TypedValue.COMPLEX_UNIT_PX, G.ruleTextSize()); + } }); } - private void initTheme() { - switch (G.getSelectedTheme()) { - case "D" -> setTheme(R.style.AppDarkTheme); - case "L" -> setTheme(R.style.AppLightTheme); - case "B" -> setTheme(R.style.AppBlackTheme); + @SuppressLint("SetTextI18n") + private void parseAndDisplayModernData(String data) { + // Initialize all sections as empty and hide cards + rulesContent.setText(""); + interfacesContent.setText(""); + systemContent.setText(""); + preferencesContent.setText(""); + logcatContent.setText(""); + + interfacesCard.setVisibility(View.GONE); + systemCard.setVisibility(View.GONE); + preferencesCard.setVisibility(View.GONE); + logcatCard.setVisibility(View.GONE); + + // Parse sections more intelligently by looking for section headers + String[] lines = data.split("\n"); + StringBuilder currentSection = new StringBuilder(); + String currentSectionType = null; + + for (String line : lines) { + // Check if this is a section header (starts with =, contains title, ends with =) + if (line.matches("^=+$")) { + // Skip separator lines + continue; + } + + // Check if this line is a section title + String sectionType = detectSectionType(line.trim()); + + if (sectionType != null) { + // Process previous section if it exists + if (currentSectionType != null && currentSection.length() > 0) { + processSectionContent(currentSectionType, currentSection.toString()); + } + + // Start new section + currentSectionType = sectionType; + currentSection = new StringBuilder(); + continue; + } + + // Add line to current section + if (currentSectionType != null) { + currentSection.append(line).append("\n"); + } + } + + // Process the last section + if (currentSectionType != null && currentSection.length() > 0) { + processSectionContent(currentSectionType, currentSection.toString()); + } + + // Set font sizes for all content views + float textSize = G.ruleTextSize(); + rulesContent.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize); + interfacesContent.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize); + systemContent.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize); + preferencesContent.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize); + logcatContent.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize); + + // Keep the hidden TextView updated for backward compatibility (export, copy functions) + TextView hiddenRules = findViewById(R.id.rules); + hiddenRules.setText(data); + } + + private String detectSectionType(String line) { + String trimmed = line.trim(); + if (trimmed.equals(getString(R.string.ipv4_rules_title))) { + return "ipv4_rules"; + } else if (trimmed.equals(getString(R.string.ipv6_rules_title))) { + return "ipv6_rules"; + } else if (trimmed.contains("Network interfaces")) { + return "interfaces"; + } else if (trimmed.contains("ifconfig")) { + return "ifconfig"; + } else if (trimmed.contains("System info")) { + return "system"; + } else if (trimmed.contains("Preferences")) { + return "preferences"; + } else if (trimmed.contains("Logcat")) { + return "logcat"; } + return null; + } + + private void processSectionContent(String sectionType, String content) { + String trimmedContent = content.trim(); + if (trimmedContent.isEmpty()) return; + + switch (sectionType) { + case "ipv4_rules": + rulesTitle.setText(getString(R.string.ipv4_rules_title)); + rulesStatus.setText(getString(R.string.ready)); + rulesContent.setText(trimmedContent); + break; + + case "ipv6_rules": + rulesTitle.setText(getString(R.string.ipv6_rules_title)); + rulesStatus.setText(getString(R.string.ready)); + rulesContent.setText(trimmedContent); + break; + + case "interfaces": + case "ifconfig": + interfacesCard.setVisibility(View.VISIBLE); + String existingInterfaces = interfacesContent.getText().toString(); + if (!existingInterfaces.isEmpty()) { + interfacesContent.setText(existingInterfaces + "\n\n" + trimmedContent); + } else { + interfacesContent.setText(trimmedContent); + } + break; + + case "system": + systemCard.setVisibility(View.VISIBLE); + systemContent.setText(trimmedContent); + break; + + case "preferences": + preferencesCard.setVisibility(View.VISIBLE); + String existingPrefs = preferencesContent.getText().toString(); + if (!existingPrefs.isEmpty()) { + preferencesContent.setText(existingPrefs + "\n\n" + trimmedContent); + } else { + preferencesContent.setText(trimmedContent); + } + break; + + case "logcat": + logcatCard.setVisibility(View.VISIBLE); + logcatContent.setText(trimmedContent); + break; + } + } + + private void updateModernTextSize(float sizeDelta) { + float currentSize = G.ruleTextSize(); + float newSize = currentSize + sizeDelta; + + if (newSize < 8.0f) newSize = 8.0f; // Minimum size + if (newSize > 30.0f) newSize = 30.0f; // Maximum size + + G.ruleTextSize((int) newSize); + + if (rulesContent != null) rulesContent.setTextSize(TypedValue.COMPLEX_UNIT_PX, newSize); + if (interfacesContent != null) interfacesContent.setTextSize(TypedValue.COMPLEX_UNIT_PX, newSize); + if (systemContent != null) systemContent.setTextSize(TypedValue.COMPLEX_UNIT_PX, newSize); + if (preferencesContent != null) preferencesContent.setTextSize(TypedValue.COMPLEX_UNIT_PX, newSize); + if (logcatContent != null) logcatContent.setTextSize(TypedValue.COMPLEX_UNIT_PX, newSize); + } + + private void initTheme() { + ThemeHelper.applyTheme(this); } @Override @@ -108,7 +295,12 @@ protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); initTheme(); - setContentView(R.layout.rules); + if (useModernLayout) { + setContentView(R.layout.rules_modern); + initModernViews(); + } else { + setContentView(R.layout.rules); + } Toolbar toolbar = findViewById(R.id.rule_toolbar); //toolbar.setTitle(getString(R.string.showrules_title)); @@ -172,15 +364,23 @@ public boolean onOptionsItemSelected(MenuItem item) { return true; } case MENU_ZOOM_IN -> { - newSize = scaleGesture.getTextSize() + 2.0f; - scaleGesture.setTextSize(TypedValue.COMPLEX_UNIT_PX, newSize); - G.ruleTextSize((int) newSize); + if (useModernLayout) { + updateModernTextSize(2.0f); + } else { + newSize = scaleGesture.getTextSize() + 2.0f; + scaleGesture.setTextSize(TypedValue.COMPLEX_UNIT_PX, newSize); + G.ruleTextSize((int) newSize); + } return false; } case MENU_ZOOM_OUT -> { - newSize = scaleGesture.getTextSize() - 2.0f; - scaleGesture.setTextSize(TypedValue.COMPLEX_UNIT_PX, newSize); - G.ruleTextSize((int) newSize); + if (useModernLayout) { + updateModernTextSize(-2.0f); + } else { + newSize = scaleGesture.getTextSize() - 2.0f; + scaleGesture.setTextSize(TypedValue.COMPLEX_UNIT_PX, newSize); + G.ruleTextSize((int) newSize); + } return false; } default -> { @@ -194,10 +394,12 @@ private static class Task implements Runnable { private final Context ctx; private final WeakReference activityReference; private final Handler handler = new Handler(Looper.getMainLooper()); + private final File selectedDirectory; // only retain a weak reference to the activity - Task(DataDumpActivity context) { + Task(DataDumpActivity context, File directory) { this.ctx = context; + this.selectedDirectory = directory; activityReference = new WeakReference<>(context); } @@ -207,13 +409,29 @@ public void run() { boolean res = false; try { + // Generate timestamped filename + String timestamp = new SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.US).format(new Date()); + String baseFileName = sdDumpFile.replace(".log", ""); + String timestampedFileName = baseFileName + "-" + timestamp + ".log"; + File file; - if(Build.VERSION.SDK_INT < Build.VERSION_CODES.Q ){ + if (selectedDirectory != null) { + // Use user-selected directory + if (!selectedDirectory.exists()) { + selectedDirectory.mkdirs(); + } + file = new File(selectedDirectory, timestampedFileName); + } else if(Build.VERSION.SDK_INT < Build.VERSION_CODES.Q ){ File dir = new File(Environment.getExternalStorageDirectory().getAbsolutePath() + "/" ); dir.mkdirs(); - file = new File(dir, sdDumpFile); + file = new File(dir, timestampedFileName); } else{ - file = new File(ctx.getExternalFilesDir(null) + "/" + sdDumpFile) ; + // Use Documents/AFWall directory for user-friendly access + File dir = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), "AFWall"); + if (!dir.exists()) { + dir.mkdirs(); + } + file = new File(dir, timestampedFileName); } output = new FileOutputStream(file); output.write(dataText.getBytes()); @@ -247,20 +465,54 @@ public void run() { } private void exportToSD() { + try { + // Get default path for file dialog + File defaultPath; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + defaultPath = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), "AFWall"); + } else { + defaultPath = new File(Environment.getExternalStorageDirectory().getAbsolutePath() + "/"); + } + if (!defaultPath.exists()) { + defaultPath.mkdirs(); + } - if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ){ - // Do some stuff - ExecutorService executor = Executors.newSingleThreadExecutor(); - executor.execute(new Task(this)); - } else { - if (ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) - != PackageManager.PERMISSION_GRANTED) { - // permissions have not been granted. - ActivityCompat.requestPermissions(DataDumpActivity.this, - new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, - MY_PERMISSIONS_REQUEST_WRITE_STORAGE); - } else{ - new Task(this).run(); + // Show directory picker dialog + FileDialog fileDialog = new FileDialog(this, defaultPath, true); + fileDialog.setSelectDirectoryOption(true); + fileDialog.addDirectoryListener(directory -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ExecutorService executor = Executors.newSingleThreadExecutor(); + executor.execute(new Task(DataDumpActivity.this, directory)); + executor.shutdown(); + } else { + if (ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) + != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(DataDumpActivity.this, + new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, + MY_PERMISSIONS_REQUEST_WRITE_STORAGE); + } else { + new Task(DataDumpActivity.this, directory).run(); + } + } + }); + fileDialog.showDialog(); + } catch (Exception e) { + // Fallback to default behavior if file dialog fails + Log.e(TAG, "FileDialog failed, using default path", e); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ExecutorService executor = Executors.newSingleThreadExecutor(); + executor.execute(new Task(this, null)); + executor.shutdown(); + } else { + if (ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) + != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(DataDumpActivity.this, + new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, + MY_PERMISSIONS_REQUEST_WRITE_STORAGE); + } else { + new Task(this, null).run(); + } } } } diff --git a/app/src/main/java/dev/ukanth/ufirewall/activity/FirewallRuleActions.java b/app/src/main/java/dev/ukanth/ufirewall/activity/FirewallRuleActions.java new file mode 100644 index 000000000..899b98870 --- /dev/null +++ b/app/src/main/java/dev/ukanth/ufirewall/activity/FirewallRuleActions.java @@ -0,0 +1,40 @@ +package dev.ukanth.ufirewall.activity; + +import androidx.appcompat.app.AppCompatActivity; + +import com.afollestad.materialdialogs.MaterialDialog; + +import dev.ukanth.ufirewall.Api; +import dev.ukanth.ufirewall.R; +import dev.ukanth.ufirewall.service.RootCommand; + +public final class FirewallRuleActions { + + private FirewallRuleActions() { + } + + public static void confirmFlushAllRules(final AppCompatActivity activity, final Runnable onComplete) { + new MaterialDialog.Builder(activity) + .title(R.string.confirmation) + .content(R.string.flushRulesConfirm) + .positiveText(R.string.Yes) + .negativeText(R.string.No) + .onPositive((dialog, which) -> { + RootCommand command = new RootCommand() + .setReopenShell(true) + .setSuccessToast(R.string.flushed) + .setFailureToast(R.string.error_purge); + if (onComplete != null) { + command.setCallback(new RootCommand.Callback() { + public void cbFunc(RootCommand state) { + activity.runOnUiThread(onComplete); + } + }); + } + Api.flushAllRules(activity, command); + dialog.dismiss(); + }) + .onNegative((dialog, which) -> dialog.dismiss()) + .show(); + } +} diff --git a/app/src/main/java/dev/ukanth/ufirewall/activity/HelpActivity.java b/app/src/main/java/dev/ukanth/ufirewall/activity/HelpActivity.java index 3346a4380..ef52dc8c6 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/activity/HelpActivity.java +++ b/app/src/main/java/dev/ukanth/ufirewall/activity/HelpActivity.java @@ -1,76 +1,83 @@ package dev.ukanth.ufirewall.activity; +import android.content.ActivityNotFoundException; +import android.content.Intent; +import android.net.Uri; import android.os.Bundle; import android.view.MenuItem; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.widget.Toolbar; -import androidx.core.content.ContextCompat; -import androidx.viewpager.widget.ViewPager; - +import androidx.core.widget.ImageViewCompat; +import dev.ukanth.ufirewall.BuildConfig; import dev.ukanth.ufirewall.R; -import dev.ukanth.ufirewall.ui.about.ViewPagerAdapter; import dev.ukanth.ufirewall.util.G; -import dev.ukanth.ufirewall.util.SlidingTabLayout; +import dev.ukanth.ufirewall.util.ThemeHelper; public class HelpActivity extends AppCompatActivity { - private ViewPager viewPager; - private ViewPagerAdapter adapter; - private SlidingTabLayout tabs; - private final int count = 0; - private final int noOfTabs =2; - - - @Override + @Override public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - String[] viewTitles = { getString(R.string.About), getString(R.string.FAQ) }; - - initTheme(); - - setContentView(R.layout.help_about); + super.onCreate(savedInstanceState); - Toolbar toolbar = findViewById(R.id.help_toolbar); - setSupportActionBar(toolbar); + initTheme(); + setContentView(R.layout.help_about); - // Creating The ViewPagerAdapter and Passing Fragment Manager, Titles fot the Tabs and Number Of Tabs. - adapter = new ViewPagerAdapter(getSupportFragmentManager(), viewTitles, noOfTabs); - - // Initilization - viewPager = findViewById(R.id.pager); - - viewPager.setAdapter(adapter); + Toolbar toolbar = findViewById(R.id.help_toolbar); + setSupportActionBar(toolbar); + if (getSupportActionBar() != null) { + getSupportActionBar().setTitle(R.string.help); getSupportActionBar().setHomeButtonEnabled(true); getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } - // Assiging the Sliding Tab Layout View - tabs = findViewById(R.id.tabs); - tabs.setDistributeEvenly(true); // To make the Tabs Fixed set this true, This makes the tabs Space Evenly in Available width + setupContent(); + } - // Setting Custom Color for the Scroll bar indicator of the Tab View - tabs.setCustomTabColorizer(position -> ContextCompat.getColor(getApplicationContext(),R.color.white)); + private void setupContent() { + String version = BuildConfig.VERSION_NAME; + TextView titleText = findViewById(R.id.afwall_title); + String versionText = getString(R.string.app_name) + " (v" + version + ")"; + if (G.isDonate() || G.isDoKey(this)) { + versionText = versionText + " (Donate) " + getString(R.string.donate_thanks) + " :)"; + } + titleText.setText(versionText); + + // the themes tint images (theme attribute "tint"): show the launcher icon in its colors + ImageView logo = findViewById(R.id.help_logo); + if (logo != null) { + ImageViewCompat.setImageTintList(logo, null); + } + + link(R.id.help_link_wiki_row, "https://github.com/ukanth/afwall/wiki"); + link(R.id.help_link_source_row, "https://github.com/ukanth/afwall"); + link(R.id.help_link_issues_row, "https://github.com/ukanth/afwall/issues"); + } - // Setting the ViewPager For the SlidingTabsLayout - tabs.setViewPager(viewPager); + private void link(int rowId, String url) { + View row = findViewById(rowId); + if (row == null) { + return; + } + row.setOnClickListener(v -> { + try { + startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url))); + } catch (ActivityNotFoundException e) { + Toast.makeText(this, url, Toast.LENGTH_LONG).show(); + } + }); } + private void initTheme() { - switch(G.getSelectedTheme()) { - case "D": - setTheme(R.style.AppDarkTheme); - break; - case "L": - setTheme(R.style.AppLightTheme); - break; - case "B": - setTheme(R.style.AppBlackTheme); - break; - } + ThemeHelper.applyTheme(this); } @Override diff --git a/app/src/main/java/dev/ukanth/ufirewall/activity/LogActivity.java b/app/src/main/java/dev/ukanth/ufirewall/activity/LogActivity.java index 4ba5290af..35d13d104 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/activity/LogActivity.java +++ b/app/src/main/java/dev/ukanth/ufirewall/activity/LogActivity.java @@ -61,8 +61,10 @@ import dev.ukanth.ufirewall.log.LogDatabase; import dev.ukanth.ufirewall.log.LogRecyclerViewAdapter; import dev.ukanth.ufirewall.util.DateComparator; +import dev.ukanth.ufirewall.service.LogService; import dev.ukanth.ufirewall.util.G; import dev.ukanth.ufirewall.util.SecurityUtil; +import dev.ukanth.ufirewall.util.ThemeHelper; import android.os.Handler; import android.os.Looper; import java.util.concurrent.ExecutorService; @@ -78,7 +80,6 @@ public class LogActivity extends AppCompatActivity implements SwipeRefreshLayout protected static final int MENU_TOGGLE = -4; protected static final int MENU_CLEAR = 40; - protected static final int MENU_SWITCH_OLD = 42; @Override protected void onCreate(Bundle savedInstanceState) { @@ -124,22 +125,35 @@ protected void onCreate(Bundle savedInstanceState) { } else { recyclerView.setVisibility(View.GONE); mSwipeLayout.setVisibility(View.GONE); - emptyView.setVisibility(View.VISIBLE); + showEmptyView(); } } - private void initTheme() { - switch(G.getSelectedTheme()) { - case "D": - setTheme(R.style.AppDarkTheme); - break; - case "L": - setTheme(R.style.AppLightTheme); - break; - case "B": - setTheme(R.style.AppBlackTheme); - break; + /** + * Empty log: say why, instead of always asking to enable a log service that may be enabled. + */ + private void showEmptyView() { + String failure = LogService.getLastStartFailure(); + if (!G.enableLogService()) { + emptyView.setText(R.string.log_empty_service_off); + } else if (failure != null) { + emptyView.setText(getString(R.string.log_empty_watcher_failed, failure)); + } else { + emptyView.setText(R.string.log_empty_nothing_yet); } + emptyView.setVisibility(View.VISIBLE); + } + + @Override + protected void onResume() { + super.onResume(); + // the log is being looked at: start the blocked-connections notification afresh + dev.ukanth.ufirewall.util.Notifications.clearBlocked(); + dev.ukanth.ufirewall.util.Notifications.cancel(this, dev.ukanth.ufirewall.util.Notifications.ID_BLOCKED); + } + + private void initTheme() { + ThemeHelper.applyTheme(this); } private void initializeRecyclerView(final Context ctx) { recyclerView.setHasFixedSize(true); @@ -177,6 +191,7 @@ private int getCount() { private class CollectLog implements Runnable { private Context context = null; MaterialDialog loadDialog = null; + private boolean fromSwipeRefresh = false; public CollectLog() { } @@ -186,16 +201,26 @@ public CollectLog setContext(Context context) { return this; } + public CollectLog setFromSwipeRefresh(boolean fromSwipe) { + this.fromSwipeRefresh = fromSwipe; + return this; + } + public void execute() { Handler handler = new Handler(Looper.getMainLooper()); handler.post(() -> { onPreExecute(); ExecutorService executor = Executors.newSingleThreadExecutor(); executor.execute(this); + executor.shutdown(); }); } protected void onPreExecute() { + // Skip modal dialog if triggered by swipe refresh (animation already showing) + if (fromSwipeRefresh) { + return; + } loadDialog = new MaterialDialog.Builder(context).cancelable(false) .title(getString(R.string.working)) .cancelable(false) @@ -240,13 +265,13 @@ protected void onPostExecute(Boolean logPresent) { if (logPresent != null && logPresent) { recyclerViewAdapter.notifyDataSetChanged(); recyclerView.setVisibility(View.VISIBLE); - mSwipeLayout.setVisibility(View.VISIBLE); emptyView.setVisibility(View.GONE); } else { - mSwipeLayout.setVisibility(View.GONE); recyclerView.setVisibility(View.GONE); - emptyView.setVisibility(View.VISIBLE); + showEmptyView(); } + // Keep SwipeRefreshLayout visible to allow pull-to-refresh even when log is empty + mSwipeLayout.setVisibility(View.VISIBLE); recyclerView.getRecycledViewPool().clear(); recyclerView.setRecycledViewPool(new RecyclerView.RecycledViewPool()); @@ -264,7 +289,6 @@ public boolean onCreateOptionsMenu(android.view.Menu menu) { // Common options: Copy, Export to SD Card, Refresh SubMenu sub = menu.addSubMenu(0, MENU_TOGGLE, 0, "").setIcon(R.drawable.ic_flow); sub.add(0, MENU_CLEAR, 0, R.string.clear_log).setIcon(R.drawable.ic_clearlog); - sub.add(0, MENU_SWITCH_OLD, 0, R.string.switch_old).setIcon(R.drawable.ic_log); //populateMenu(sub); sub.getItem().setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS| MenuItem.SHOW_AS_ACTION_WITH_TEXT); super.onCreateOptionsMenu(menu); @@ -345,12 +369,6 @@ public boolean onOptionsItemSelected(MenuItem item) { /*case MENU_EXPORT_LOG: //exportToSD(); return true;*/ - case MENU_SWITCH_OLD: - Intent i = new Intent(this, OldLogActivity.class); - G.oldLogView(true); - startActivity(i); - finish(); - return true; default: return super.onOptionsItemSelected(item); } @@ -384,7 +402,8 @@ public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) @Override public void onRefresh() { - (new CollectLog()).setContext(this).run(); + mSwipeLayout.setRefreshing(true); + (new CollectLog()).setContext(this).setFromSwipeRefresh(true).execute(); } /*@Override diff --git a/app/src/main/java/dev/ukanth/ufirewall/activity/LogDetailActivity.java b/app/src/main/java/dev/ukanth/ufirewall/activity/LogDetailActivity.java index e48642faf..716623e22 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/activity/LogDetailActivity.java +++ b/app/src/main/java/dev/ukanth/ufirewall/activity/LogDetailActivity.java @@ -38,6 +38,8 @@ import android.widget.Toast; import androidx.appcompat.app.AppCompatActivity; +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; import androidx.appcompat.widget.Toolbar; import androidx.core.app.ActivityCompat; import androidx.recyclerview.widget.LinearLayoutManager; @@ -64,8 +66,11 @@ import dev.ukanth.ufirewall.log.LogPreference; import dev.ukanth.ufirewall.log.LogPreference_Table; import dev.ukanth.ufirewall.util.DateComparator; +import dev.ukanth.ufirewall.util.FirewallActions; import dev.ukanth.ufirewall.util.G; +import dev.ukanth.ufirewall.util.Notifications; import dev.ukanth.ufirewall.util.LogNetUtil; +import dev.ukanth.ufirewall.util.ThemeHelper; public class LogDetailActivity extends AppCompatActivity implements SwipeRefreshLayout.OnRefreshListener { @@ -80,6 +85,21 @@ public class LogDetailActivity extends AppCompatActivity implements SwipeRefresh protected Menu mainMenu; private LogData current_selected_logData; private static List logDataList; + private static List fullLogDataList; // Store full dataset + + // Pagination + private static final int PAGE_SIZE = 100; // Load 100 items at a time + private int currentPage = 0; + private boolean isLoading = false; + + // Summary views + private TextView totalBlocks; + private TextView uniqueDestinations; + private TextView timePeriod; + private TextView mostBlockedDestination; + private TextView loadingMoreIndicator; + private View summaryHeader; + private boolean loadMoreListenerAdded; protected static final int MENU_EXPORT_LOG = 100; @@ -90,17 +110,7 @@ public class LogDetailActivity extends AppCompatActivity implements SwipeRefresh final String TAG = "AFWall"; private void initTheme() { - switch (G.getSelectedTheme()) { - case "D": - setTheme(R.style.AppDarkTheme); - break; - case "L": - setTheme(R.style.AppLightTheme); - break; - case "B": - setTheme(R.style.AppBlackTheme); - break; - } + ThemeHelper.applyTheme(this); } @Override @@ -126,6 +136,14 @@ protected void onCreate(Bundle savedInstanceState) { recyclerView = findViewById(R.id.detailrecyclerview); emptyView = findViewById(R.id.emptydetail_view); + summaryHeader = findViewById(R.id.summary_header); + + // Initialize summary views + totalBlocks = findViewById(R.id.total_blocks); + uniqueDestinations = findViewById(R.id.unique_destinations); + timePeriod = findViewById(R.id.time_period); + mostBlockedDestination = findViewById(R.id.most_blocked_destination); + loadingMoreIndicator = findViewById(R.id.loading_more_indicator); initializeRecyclerView(getApplicationContext()); @@ -145,10 +163,18 @@ private void initializeRecyclerView(final Context ctx) { //menu.add(0, v.getId(), 0, R.string.add_ip_rule); menu.add(0, v.getId(), 1, R.string.show_destination_address); menu.add(0, v.getId(), 2, R.string.show_source_address); - menu.add(0, v.getId(), 3, R.string.ping_destination); - menu.add(0, v.getId(), 4, R.string.ping_source); menu.add(0, v.getId(), 5, R.string.resolve_destination); menu.add(0, v.getId(), 6, R.string.resolve_source); + // Only show Copy Domain if a hostname was resolved + String hostname = current_selected_logData.getHostname(); + if (hostname != null && !hostname.trim().isEmpty() && !hostname.equals(current_selected_logData.getDst())) { + menu.add(0, v.getId(), 9, R.string.copy_domain); + } + // the app's own rule, also for system UIDs without a package (-100: no UID known) + if (uid != -100) { + menu.add(0, v.getId(), 12, R.string.log_allow_app); + menu.add(0, v.getId(), 13, R.string.log_block_app); + } LogPreference logPreference = SQLite.select() .from(LogPreference.class) .where(LogPreference_Table.uid.eq(uid)).querySingle(); @@ -209,19 +235,6 @@ public boolean onContextItemSelected(MenuItem item) { .show(); break; - case 3: // Ping Destination - new LogNetUtil.NetTask(this).execute( - new LogNetUtil.NetParam(LogNetUtil.JobType.PING, current_selected_logData.getDst()) - ); - - break; - - case 4: // Ping Source - new LogNetUtil.NetTask(this).execute( - new LogNetUtil.NetParam(LogNetUtil.JobType.PING, current_selected_logData.getSrc()) - ); - break; - case 5: // Resolve Destination new LogNetUtil.NetTask(this).execute( new LogNetUtil.NetParam(LogNetUtil.JobType.RESOLVE, current_selected_logData.getDst()) @@ -235,9 +248,29 @@ public boolean onContextItemSelected(MenuItem item) { break; case 7: G.updateLogNotification(uid, false); + Api.toast(LogDetailActivity.this, getString(R.string.log_notification_on)); break; case 8: G.updateLogNotification(uid, true); + // like the notification's "Mute" action + Notifications.forgetBlocked(getApplicationContext(), uid); + Api.toast(LogDetailActivity.this, getString(R.string.log_notification_off)); + break; + case 9: // Copy Domain + String domain = current_selected_logData.getHostname(); + if (domain != null && !domain.trim().isEmpty() && !domain.equals(current_selected_logData.getDst())) { + Api.copyToClipboard(LogDetailActivity.this, domain); + Api.toast(LogDetailActivity.this, getString(R.string.domain_copied)); + } else { + Api.toast(LogDetailActivity.this, getString(R.string.no_domain_resolved)); + } + break; + case 12: // Allow the app on the connection types in use + case 13: // Block it + boolean allow = item.getOrder() == 12; + FirewallActions.setAppAccess(getApplicationContext(), uid, allow); + Api.toast(LogDetailActivity.this, getString(allow ? R.string.notif_app_allowed : R.string.notif_app_blocked, + Api.getSpecialAppName(uid))); break; } @@ -252,6 +285,17 @@ private List getLogData(final int uid) { .orderBy(LogData_Table.timestamp, false) .queryList(); } + + private List getPagedLogData(final int uid, int page, int pageSize) { + int offset = page * pageSize; + return SQLite.select() + .from(LogData.class) + .where(LogData_Table.uid.eq(uid)) + .orderBy(LogData_Table.timestamp, false) + .limit(pageSize) + .offset(offset) + .queryList(); + } private int getCount() { long l = SQLite.selectCountOf().from(LogData.class).where(LogData_Table.uid.eq(uid)).count(); @@ -285,20 +329,32 @@ public void doProgress(int value) { @Override protected Boolean doInBackground(Void... params) { - logDataList = getLogData(uid); try { + // First, get total count for statistics + int totalCount = getCount(); + publishProgress(totalCount); + + if (totalCount > PAGE_SIZE) { + // Large dataset - use pagination + fullLogDataList = getLogData(uid); // Get full list for statistics + logDataList = getPagedLogData(uid, 0, PAGE_SIZE); // Get first page + currentPage = 0; + } else { + // Small dataset - load all + logDataList = getLogData(uid); + fullLogDataList = logDataList; + } + if (logDataList != null && logDataList.size() > 0) { Collections.sort(logDataList, new DateComparator()); - recyclerViewAdapter.updateData(logDataList); return true; } else { return false; } } catch (Exception e) { - Log.e(Api.TAG, "Exception while retrieving data" + e.getLocalizedMessage()); + Log.e(Api.TAG, "Exception while retrieving data" + e.getLocalizedMessage()); return null; } - } @Override @@ -312,7 +368,7 @@ protected void onProgressUpdate(Integer... progress) { } @Override - protected void onPostExecute(Boolean logPresent) { + protected void onPostExecute(Boolean logPresent) { super.onPostExecute(logPresent); doProgress(-1); try { @@ -330,13 +386,28 @@ protected void onPostExecute(Boolean logPresent) { mSwipeLayout.setRefreshing(false); if (logPresent != null && logPresent) { + // the adapter's data only changes on the main thread + recyclerViewAdapter.updateData(logDataList); recyclerView.setVisibility(View.VISIBLE); mSwipeLayout.setVisibility(View.VISIBLE); + summaryHeader.setVisibility(View.VISIBLE); emptyView.setVisibility(View.GONE); recyclerViewAdapter.notifyDataSetChanged(); + + // Update title with log count + updateTitleWithLogCount(); + + // Update summary statistics using full dataset + updateSummaryStatistics(); + + // Setup load more functionality for large datasets + if (fullLogDataList != null && fullLogDataList.size() > PAGE_SIZE) { + setupLoadMoreFunctionality(); + } } else { mSwipeLayout.setVisibility(View.GONE); recyclerView.setVisibility(View.GONE); + summaryHeader.setVisibility(View.GONE); emptyView.setVisibility(View.VISIBLE); } } @@ -402,6 +473,160 @@ private void clearDatabase(final Context ctx) { public void onRefresh() { (new CollectDetailLog()).setContext(this).execute(); } + + private void updateTitleWithLogCount() { + if (logDataList != null && logDataList.size() > 0) { + String appName = ""; + if (logDataList.get(0).getAppName() != null) { + appName = logDataList.get(0).getAppName(); + } + // all of the app's entries, not the first page + int total = fullLogDataList != null ? fullLogDataList.size() : logDataList.size(); + String title = appName + " (" + total + " blocked)"; + setTitle(title); + } + } + + private void updateSummaryStatistics() { + if (fullLogDataList == null || fullLogDataList.isEmpty()) { + return; + } + + // Show basic count immediately for better UX (full dataset count) + totalBlocks.setText(String.valueOf(fullLogDataList.size())); + + // Process complex statistics in background thread using full dataset + new Thread(() -> { + // Calculate unique destinations and most blocked + java.util.Map destinationCounts = new java.util.HashMap<>(); + java.util.Set uniqueDests = new java.util.HashSet<>(); + + long oldestTimestamp = Long.MAX_VALUE; + long newestTimestamp = Long.MIN_VALUE; + + for (LogData logData : fullLogDataList) { + String destination = logData.getDst() + ":" + logData.getDpt(); + uniqueDests.add(destination); + Integer currentCount = destinationCounts.get(destination); + destinationCounts.put(destination, (currentCount == null ? 0 : currentCount) + 1); + + // Track time range + oldestTimestamp = Math.min(oldestTimestamp, logData.getTimestamp()); + newestTimestamp = Math.max(newestTimestamp, logData.getTimestamp()); + } + + final int uniqueCount = uniqueDests.size(); + final String period = (oldestTimestamp != Long.MAX_VALUE && newestTimestamp != Long.MIN_VALUE) ? + formatTimePeriod(newestTimestamp - oldestTimestamp) : "0s"; + + // Find most blocked destination + String mostBlocked = "No data"; + int maxCount = 0; + for (java.util.Map.Entry entry : destinationCounts.entrySet()) { + if (entry.getValue() > maxCount) { + maxCount = entry.getValue(); + mostBlocked = entry.getKey() + " (" + maxCount + "x)"; + } + } + final String finalMostBlocked = mostBlocked; + + // Update UI on main thread + runOnUiThread(() -> { + uniqueDestinations.setText(String.valueOf(uniqueCount)); + timePeriod.setText(period); + mostBlockedDestination.setText(finalMostBlocked); + }); + }).start(); + } + + private void setupLoadMoreFunctionality() { + if (loadMoreListenerAdded) { + return; // a refresh would add another one + } + loadMoreListenerAdded = true; + // Add scroll listener to load more data when user reaches bottom + recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { + @Override + public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { + super.onScrolled(recyclerView, dx, dy); + + LinearLayoutManager layoutManager = (LinearLayoutManager) recyclerView.getLayoutManager(); + if (layoutManager != null && !isLoading) { + int visibleItemCount = layoutManager.getChildCount(); + int totalItemCount = layoutManager.getItemCount(); + int firstVisibleItem = layoutManager.findFirstVisibleItemPosition(); + + // Load more when we're near the bottom + if ((visibleItemCount + firstVisibleItem) >= totalItemCount - 10) { + loadMoreData(); + } + } + } + }); + } + + private void loadMoreData() { + if (isLoading || fullLogDataList == null) return; + + int totalAvailable = fullLogDataList.size(); + int currentLoaded = (currentPage + 1) * PAGE_SIZE; + + if (currentLoaded >= totalAvailable) { + return; // No more data to load + } + + isLoading = true; + currentPage++; + + // Show loading indicator + loadingMoreIndicator.setVisibility(View.VISIBLE); + + new Thread(() -> { + try { + List newData = getPagedLogData(uid, currentPage, PAGE_SIZE); + if (newData != null && !newData.isEmpty()) { + Collections.sort(newData, new DateComparator()); + + runOnUiThread(() -> { + // the adapter keeps its own copy of the rows + int start = recyclerViewAdapter.getItemCount(); + recyclerViewAdapter.addData(newData); + recyclerViewAdapter.notifyItemRangeInserted(start, newData.size()); + loadingMoreIndicator.setVisibility(View.GONE); + isLoading = false; + }); + } else { + runOnUiThread(() -> { + loadingMoreIndicator.setVisibility(View.GONE); + isLoading = false; + }); + } + } catch (Exception e) { + Log.e(Api.TAG, "Error loading more data", e); + runOnUiThread(() -> { + loadingMoreIndicator.setVisibility(View.GONE); + isLoading = false; + }); + } + }).start(); + } + + private String formatTimePeriod(long millis) { + long seconds = millis / 1000; + long minutes = seconds / 60; + long hours = minutes / 60; + long days = hours / 24; + + if (days > 0) { + return days + "d"; + } else if (hours > 0) { + return hours + "h"; + } else if (minutes > 0) { + return minutes + "m"; + } else { + return seconds + "s"; + } + } private static class Task extends AsyncTask { public String filename = ""; diff --git a/app/src/main/java/dev/ukanth/ufirewall/activity/ProfileActivity.java b/app/src/main/java/dev/ukanth/ufirewall/activity/ProfileActivity.java index 17247e264..89eebd6b2 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/activity/ProfileActivity.java +++ b/app/src/main/java/dev/ukanth/ufirewall/activity/ProfileActivity.java @@ -28,6 +28,7 @@ import dev.ukanth.ufirewall.profiles.ProfileAdapter; import dev.ukanth.ufirewall.profiles.ProfileData; import dev.ukanth.ufirewall.profiles.ProfileHelper; +import dev.ukanth.ufirewall.util.AppRuleHelper; import dev.ukanth.ufirewall.util.G; /** @@ -50,6 +51,7 @@ public void onCreate(Bundle savedInstanceState) { Toolbar toolbar = findViewById(R.id.profile_toolbar); setSupportActionBar(toolbar); + setTitle(R.string.manage_profiles); getSupportActionBar().setHomeButtonEnabled(true); getSupportActionBar().setDisplayHomeAsUpEnabled(true); @@ -96,10 +98,8 @@ public void onCreateContextMenu(ContextMenu menu, View v, //ProfileData profile = profileAdapter.getItem(aInfo.position); String name = ((TextView) aInfo.targetView.findViewById(R.id.pro_name)).getText().toString(); menu.setHeaderTitle(getString(R.string.select) + " " + name); - if (G.isProfileMigrated()) { - menu.add(0, MENU_CLONE, 0, getString(R.string.clone)); - menu.add(0, MENU_RENAME, 0, getString(R.string.rename)); - } + menu.add(0, MENU_CLONE, 0, getString(R.string.clone)); + menu.add(0, MENU_RENAME, 0, getString(R.string.rename)); menu.add(0, MENU_DELETE, 0, getString(R.string.delete)); } @@ -111,29 +111,14 @@ public boolean onContextItemSelected(MenuItem item) { String profileName = profilesList.get(aInfo.position).getName(); switch (itemId) { case MENU_DELETE: - if (!G.isProfileMigrated()) { - if (aInfo.position > 3) { - boolean deleted = G.removeAdditionalProfile(profileName); - if (deleted) { - profilesList.remove(aInfo.position); - profileAdapter.notifyDataSetChanged(); - } else { - Api.toast(getApplicationContext(), getString(R.string.delete_profile)); - } - } else { - //TODO: can't delete default profiles(1,2,3) msg - Use migrate option - Api.toast(getApplicationContext(), getString(R.string.profile_notsupport)); - } - } else { - if (aInfo.position != 0) { - ProfileData data = ProfileHelper.getProfileByName(profileName); - if (data != null && ProfileHelper.deleteProfileByName(profileName) - && G.clearSharedPreferences(getApplicationContext(), data.getIdentifier())) { - profilesList.remove(aInfo.position); - profileAdapter.notifyDataSetChanged(); - } - } else { - //can't delete default profile + // the default profile can't be deleted + if (aInfo.position != 0) { + ProfileData data = ProfileHelper.getProfileByName(profileName); + if (data != null && ProfileHelper.deleteProfileByName(profileName) + && G.clearSharedPreferences(getApplicationContext(), data.getIdentifier())) { + AppRuleHelper.deleteRulesForProfile(data.getIdentifier()); + profilesList.remove(aInfo.position); + profileAdapter.notifyDataSetChanged(); } } break; @@ -159,9 +144,11 @@ public boolean onContextItemSelected(MenuItem item) { data1.setName(newName); data1.setIdentifier(identifier); data1.save(); - SharedPreferences fromShared = getSharedPreferences(profileName, Context.MODE_PRIVATE); - SharedPreferences.Editor toShared = getSharedPreferences(newName,Context.MODE_PRIVATE).edit(); + // rules are stored under the identifier, not the display name + SharedPreferences fromShared = getSharedPreferences(data.getIdentifier(), Context.MODE_PRIVATE); + SharedPreferences.Editor toShared = getSharedPreferences(identifier, Context.MODE_PRIVATE).edit(); Api.copySharedPreferences(fromShared,toShared); + AppRuleHelper.copyRulesToProfile(data.getIdentifier(), identifier); profilesList.add(data1); profileAdapter.notifyDataSetChanged(); } else { @@ -199,21 +186,7 @@ private void initList() { // We populate the Profiles profilesList.add(new ProfileData(G.gPrefs.getString("default", getString(R.string.defaultProfile)), "")); - if (G.isProfileMigrated()) { - List profiles = ProfileHelper.getProfiles(); - profilesList.addAll(profiles); - } else { - profilesList.add(new ProfileData(G.gPrefs.getString("profile1", getString(R.string.profile1)), "AFWallProfile1")); - profilesList.add(new ProfileData(G.gPrefs.getString("profile2", getString(R.string.profile2)), "AFWallProfile2")); - profilesList.add(new ProfileData(G.gPrefs.getString("profile3", getString(R.string.profile3)), "AFWallProfile3")); - - List pList = G.getAdditionalProfiles(); - for (String profileName : pList) { - if (profileName != null && profileName.length() > 0) { - profilesList.add(new ProfileData(profileName, profileName)); - } - } - } + profilesList.addAll(ProfileHelper.getProfiles()); } private void renameProfile(final ProfileData data, final int position) { @@ -249,14 +222,9 @@ private void addNewProfile() { if (isNotDuplicate(profileName)) { String identifier = profileName.replaceAll("\\s+", ""); ProfileData data = new ProfileData(profileName, identifier); - if (G.isProfileMigrated()) { - //store to database - data.save(); - profilesList.add(data); - profileAdapter.notifyDataSetChanged(); - } else { - Api.toast(getApplicationContext(), getString(R.string.profile_notsupport)); - } + data.save(); + profilesList.add(data); + profileAdapter.notifyDataSetChanged(); } else { Api.toast(getApplicationContext(), getString(R.string.profile_duplicate)); } diff --git a/app/src/main/java/dev/ukanth/ufirewall/activity/RulesActivity.java b/app/src/main/java/dev/ukanth/ufirewall/activity/RulesActivity.java index cff6a5cc1..957cf05fe 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/activity/RulesActivity.java +++ b/app/src/main/java/dev/ukanth/ufirewall/activity/RulesActivity.java @@ -32,6 +32,7 @@ import android.os.Bundle; import android.view.MenuItem; import android.view.SubMenu; +import android.widget.TextView; import androidx.annotation.NonNull; @@ -192,8 +193,8 @@ protected void appendSystemInfo(final Context ctx) { result.append("Bluetooth Tether status: ").append(cfg.tetherBluetoothStatusKnown ? (cfg.isBluetoothTethered ? "yes" : "no") : "unknown").append("\n"); result.append("Usb Tether status: ").append(cfg.tetherUsbStatusKnown ? (cfg.isUsbTethered ? "yes" : "no") : "unknown").append("\n"); result.append("Roam status: ").append(cfg.isRoaming ? "yes" : "no").append("\n"); - result.append("IPv4 subnet: ").append(cfg.lanMaskV4).append("\n"); - result.append("IPv6 subnet: ").append(cfg.lanMaskV6).append("\n"); + result.append("IPv4 subnets: ").append(cfg.lanMaskV4.isEmpty() ? "none" : String.join(", ", cfg.lanMaskV4)).append("\n"); + result.append("IPv6 subnets: ").append(cfg.lanMaskV6.isEmpty() ? "none" : String.join(", ", cfg.lanMaskV6)).append("\n"); // filesystem calls can block, so run in another thread new AsyncTask() { @@ -204,6 +205,7 @@ public String doInBackground(Void... args) { ret.append(getFileInfo("/system/bin/su")); ret.append(getFileInfo("/system/xbin/su")); ret.append(getFileInfo("/data/magisk/magisk")); + ret.append(getFileInfo("/data/adb/magisk")); ret.append(getFileInfo("/system/app/Superuser.apk")); PackageManager pm = ctx.getPackageManager(); @@ -216,6 +218,7 @@ public String doInBackground(Void... args) { @Override public void onPostExecute(String suInfo) { result.append(suInfo); + updateLoadingState(getString(R.string.finalizing)); appendPreferences(ctx); } }.execute(); @@ -225,6 +228,7 @@ public void onPostExecute(String suInfo) { protected void appendIfconfig(final Context ctx) { // Third section: "ifconfig" (for interface info obtained through busybox) writeHeading(result, true, "ifconfig"); + updateLoadingState(getString(R.string.loading_system_info)); Api.runIfconfig(ctx, new RootCommand() .setLogging(true) .setCallback(new RootCommand.Callback() { @@ -252,8 +256,11 @@ public void cbFunc(RootCommand state) { protected void populateData(final Context ctx) { result = new StringBuilder(); + // Update loading state for modern layout + updateLoadingState(getString(R.string.loading)); + // First section: "IPxx Rules" - writeHeading(result, false, showIPv6 ? "IPv6 Rules" : "IPv4 Rules"); + writeHeading(result, false, showIPv6 ? getString(R.string.ipv6_rules_title) : getString(R.string.ipv4_rules_title)); if (showIPv6) { sdDumpFile = "IPv6rules.log"; } else { @@ -266,11 +273,26 @@ protected void populateData(final Context ctx) { .setCallback(new RootCommand.Callback() { public void cbFunc(RootCommand state) { result.append(state.res); + updateLoadingState(getString(R.string.loading_network_info)); appendNetworkInterfaces(ctx); } })); } + private void updateLoadingState(String status) { + runOnUiThread(() -> { + TextView rulesStatus = findViewById(R.id.rules_status); + TextView rulesTitle = findViewById(R.id.rules_title); + if (rulesStatus != null) { + rulesStatus.setText(status); + } + if (rulesTitle != null) { + String title = showIPv6 ? getString(R.string.ipv6_rules_title) : getString(R.string.ipv4_rules_title); + rulesTitle.setText(title); + } + }); + } + @Override public boolean onOptionsItemSelected(MenuItem item) { final Context ctx = this; @@ -286,10 +308,12 @@ public boolean onOptionsItemSelected(MenuItem item) { return true; case MENU_IPV6_RULES: showIPv6 = true; + updateLoadingState(getString(R.string.loading)); populateData(this); return true; case MENU_IPV4_RULES: showIPv6 = false; + updateLoadingState(getString(R.string.loading)); populateData(this); return true; case MENU_SEND_REPORT: @@ -338,12 +362,7 @@ public void cbFunc(RootCommand state) { dialog.dismiss(); }) - .onNegative(new MaterialDialog.SingleButtonCallback() { - @Override - public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { - dialog.dismiss(); - } - }) + .onNegative((dialog, which) -> dialog.dismiss()) .show(); } diff --git a/app/src/main/java/dev/ukanth/ufirewall/broadcast/ConnectivityChangeReceiver.java b/app/src/main/java/dev/ukanth/ufirewall/broadcast/ConnectivityChangeReceiver.java index 39090bd71..e080c2f25 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/broadcast/ConnectivityChangeReceiver.java +++ b/app/src/main/java/dev/ukanth/ufirewall/broadcast/ConnectivityChangeReceiver.java @@ -32,6 +32,7 @@ import dev.ukanth.ufirewall.log.Log; import dev.ukanth.ufirewall.util.BootRuleManager; import dev.ukanth.ufirewall.util.G; +import dev.ukanth.ufirewall.util.NetworkChangeDebouncer; public class ConnectivityChangeReceiver extends BroadcastReceiver { @@ -46,6 +47,7 @@ public class ConnectivityChangeReceiver extends BroadcastReceiver { @Override public void onReceive(final Context context, Intent intent) { + Api.noteNetworkChange(); int status = Api.getConnectivityStatus(context); if (status > 0) { @@ -72,10 +74,10 @@ public void onReceive(final Context context, Intent intent) { if (action.equals(CONNECTIVITY_ACTION)) { Log.i(TAG, "Network change captured."); - InterfaceTracker.applyRulesOnChange(context, InterfaceTracker.CONNECTIVITY_CHANGE); + NetworkChangeDebouncer.scheduleNetworkChange(context, InterfaceTracker.CONNECTIVITY_CHANGE); } else if (action.equals(TETHER_STATE_CHANGED_ACTION)) { Log.i(TAG, "Tether change captured."); - InterfaceTracker.applyRulesOnChange(context, InterfaceTracker.TETHER_STATE_CHANGED); + NetworkChangeDebouncer.scheduleNetworkChange(context, InterfaceTracker.TETHER_STATE_CHANGED); } } } diff --git a/app/src/main/java/dev/ukanth/ufirewall/broadcast/NotificationActionReceiver.java b/app/src/main/java/dev/ukanth/ufirewall/broadcast/NotificationActionReceiver.java new file mode 100644 index 000000000..ea9dba48e --- /dev/null +++ b/app/src/main/java/dev/ukanth/ufirewall/broadcast/NotificationActionReceiver.java @@ -0,0 +1,47 @@ +package dev.ukanth.ufirewall.broadcast; + +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import dev.ukanth.ufirewall.util.G; +import dev.ukanth.ufirewall.util.Notifications; + +/** + * Actions of the blocked-connections notification that need no screen: mute an app's + * notifications, forget the collected entries when the notification is dismissed. + * Not exported: only reached through our own PendingIntents. + */ +public class NotificationActionReceiver extends BroadcastReceiver { + + private static final String ACTION_MUTE = "dev.ukanth.ufirewall.notification.MUTE_APP"; + private static final String ACTION_CLEAR_BLOCKED = "dev.ukanth.ufirewall.notification.CLEAR_BLOCKED"; + private static final String EXTRA_UID = "uid"; + + public static PendingIntent muteIntent(Context ctx, int uid) { + Intent intent = new Intent(ctx, NotificationActionReceiver.class) + .setAction(ACTION_MUTE) + .putExtra(EXTRA_UID, uid); + return PendingIntent.getBroadcast(ctx, 1, intent, + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); + } + + public static PendingIntent clearBlockedIntent(Context ctx) { + Intent intent = new Intent(ctx, NotificationActionReceiver.class).setAction(ACTION_CLEAR_BLOCKED); + return PendingIntent.getBroadcast(ctx, 2, intent, + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); + } + + @Override + public void onReceive(Context context, Intent intent) { + if (ACTION_MUTE.equals(intent.getAction()) && intent.hasExtra(EXTRA_UID)) { + int uid = intent.getIntExtra(EXTRA_UID, Integer.MIN_VALUE); + // same as unchecking "notifications" for the app in its details + G.updateLogNotification(uid, true); + Notifications.forgetBlocked(context, uid); + } else if (ACTION_CLEAR_BLOCKED.equals(intent.getAction())) { + Notifications.clearBlocked(); + } + } +} diff --git a/app/src/main/java/dev/ukanth/ufirewall/broadcast/OnBootReceiver.java b/app/src/main/java/dev/ukanth/ufirewall/broadcast/OnBootReceiver.java index ec745f8b4..140bff90a 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/broadcast/OnBootReceiver.java +++ b/app/src/main/java/dev/ukanth/ufirewall/broadcast/OnBootReceiver.java @@ -5,7 +5,6 @@ import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; -import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Messenger; @@ -38,13 +37,11 @@ public void onReceive(Context context, Intent intent) { PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP); } - Log.i("AFWall", "Startin boot service"); - if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - Log.i("AFWall", "Starting firewall service onboot"); - context.startForegroundService(new Intent(context, FirewallService.class)); - } else { - context.startService(new Intent(context, FirewallService.class)); - } + Log.i("AFWall", "Starting firewall service onboot"); + // Android 15 also sends BOOT_COMPLETED when an app leaves the stopped state (e.g. after a + // force-stop). That delivery has no foreground-service exemption; an uncaught exception + // here used to crash the app before the rules below were applied. + FirewallService.ensureRunning(context); // Use BootRuleManager for robust rule application BootRuleManager.initializeBootRuleApplication(context); @@ -54,16 +51,13 @@ public void onReceive(Context context, Intent intent) { if (G.enableLogService()) { Log.i("AFWall", "Starting log service onboot"); - try { - context.startService(new Intent(context, LogService.class)); - } catch (Exception e) { - } + LogService.ensureRunning(context); } try { G.registerPrivateLink(); - }catch (Exception e){ - + } catch (Exception e) { + Log.e("AFWall", "Unable to register private DNS listener on boot", e); } } } diff --git a/app/src/main/java/dev/ukanth/ufirewall/broadcast/PackageBroadcast.java b/app/src/main/java/dev/ukanth/ufirewall/broadcast/PackageBroadcast.java index 8f43ba210..92d7db0ac 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/broadcast/PackageBroadcast.java +++ b/app/src/main/java/dev/ukanth/ufirewall/broadcast/PackageBroadcast.java @@ -25,31 +25,28 @@ import static dev.ukanth.ufirewall.util.G.isDonate; import android.Manifest; -import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.app.PendingIntent; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.ApplicationInfo; +import android.content.pm.LauncherApps; +import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.content.pm.PackageManager.NameNotFoundException; import android.net.Uri; -import android.os.Build; +import android.os.UserHandle; import android.preference.PreferenceManager; -import androidx.core.app.NotificationCompat; - import java.util.HashSet; import dev.ukanth.ufirewall.Api; -import dev.ukanth.ufirewall.MainActivity; import dev.ukanth.ufirewall.R; import dev.ukanth.ufirewall.log.Log; import dev.ukanth.ufirewall.service.RootCommand; import dev.ukanth.ufirewall.util.G; +import dev.ukanth.ufirewall.util.Notifications; +import dev.ukanth.ufirewall.util.UidResolver; /** * Broadcast receiver responsible for removing rules that affect uninstalled @@ -59,42 +56,68 @@ public class PackageBroadcast extends BroadcastReceiver { public static final String TAG = "AFWall"; + private static String lastEventKey = ""; + private static long lastEventTime = 0; + @Override public void onReceive(final Context context, final Intent intent) { Uri inputUri = Uri.parse(intent.getDataString()); + String eventKey = intent.getAction() + ":" + inputUri.getSchemeSpecificPart() + ":" + intent.getIntExtra(Intent.EXTRA_UID, -1); + synchronized (PackageBroadcast.class) { + long now = System.currentTimeMillis(); + if (eventKey.equals(lastEventKey) && now - lastEventTime < 2000) { + Log.d(TAG, "Ignoring duplicate package event: " + eventKey); + return; + } + lastEventKey = eventKey; + lastEventTime = now; + } + if (!inputUri.getScheme().equals("package")) { Log.d(TAG, "Intent scheme was not 'package'"); return; } - if (Intent.ACTION_PACKAGE_REMOVED.equals(intent.getAction())) { - // Ignore application updates - final boolean replacing = intent.getBooleanExtra( - Intent.EXTRA_REPLACING, false); + if (Intent.ACTION_PACKAGE_REMOVED.equals(intent.getAction()) + || Intent.ACTION_PACKAGE_FULLY_REMOVED.equals(intent.getAction())) { + // Ignore application updates (FULLY_REMOVED is never sent for those) + final boolean replacing = Intent.ACTION_PACKAGE_REMOVED.equals(intent.getAction()) + && intent.getBooleanExtra(Intent.EXTRA_REPLACING, false); if (!replacing) { // Update the Firewall if necessary final int uid = intent.getIntExtra(Intent.EXTRA_UID, -123); - String packageName = context.getPackageManager().getNameForUid(uid); - //if it contains sharedID -- dont remove based on uid - if(packageName != null && packageName.contains("sharedID")) { - //ignore since the another app with same ID exists + if (uid < 0) { + Log.w(TAG, "Package removed without a UID, ignoring"); + return; + } + // Another package may still own this UID (shared user id); keep its rules then + String[] remaining = context.getPackageManager().getPackagesForUid(uid); + if (remaining != null && remaining.length > 0) { + Log.d(TAG, "UID " + uid + " is still used by " + remaining.length + " package(s); keeping rules"); } else { Api.applicationRemoved(context, uid, new RootCommand() - .setFailureToast(R.string.error_apply) - .setCallback(new RootCommand.Callback() { - @Override - public void cbFunc(RootCommand state) { - if (state.exitCode == 0) { - Api.removeCacheLabel(intent.getData().getSchemeSpecificPart(), context); - Api.removeAllUnusedCacheLabel(context); - // Force app list reload next time - Api.applications = null; - } - } - })); + .setFailureToast(R.string.error_apply)); } + + // Cache cleanup doesn't depend on the rule apply; do it off the main thread + final String removedPackage = inputUri.getSchemeSpecificPart(); + final PendingResult pendingResult = goAsync(); + new Thread(() -> { + try { + Api.removeCacheLabel(removedPackage, context); + Api.removeAllUnusedCacheLabel(context); + // Force app list reload next time + Api.applications = null; + UidResolver.invalidateUid(uid); + Log.d(TAG, "Package removed, invalidated UID cache for: " + uid); + } catch (Exception e) { + Log.e(TAG, "Error cleaning caches for removed package", e); + } finally { + pendingResult.finish(); + } + }, "AFWall-PackageRemoved").start(); } } else if (Intent.ACTION_PACKAGE_ADDED.equals(intent.getAction())) { final boolean updateApp = intent.getBooleanExtra(Intent.EXTRA_REPLACING, false); @@ -105,79 +128,89 @@ public void cbFunc(RootCommand state) { } else { // Force app list reload next time Api.applications = null; - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - boolean isNotify = prefs.getBoolean("notifyAppInstall", true); - if (isNotify && Api.isEnabled(context)) { - String added_package = intent.getData().getSchemeSpecificPart(); - final PackageManager packager = context.getPackageManager(); - String label = null; - try { - ApplicationInfo applicationInfo = packager.getApplicationInfo(added_package, 0); - label = packager.getApplicationLabel(applicationInfo).toString(); - if (PackageManager.PERMISSION_GRANTED == packager.checkPermission(Manifest.permission.INTERNET, added_package)) { - addNotification(context,label); - } - if (Api.recentlyInstalled == null) { - Api.recentlyInstalled = new HashSet<>(); - } - Api.recentlyInstalled.add(applicationInfo.packageName); - //sets default permissions - if ((G.isDoKey(context) || isDonate())) { - Api.setDefaultPermission(applicationInfo); - } - } catch (NameNotFoundException e) { - } + + // Clear UID resolver cache since new package may get a UID we've seen before + UidResolver.clearCache(); + Log.d(TAG, "Package added, cleared UID resolver cache"); + + String added_package = intent.getData().getSchemeSpecificPart(); + final PackageManager packager = context.getPackageManager(); + try { + ApplicationInfo applicationInfo = packager.getApplicationInfo(added_package, 0); + String label = packager.getApplicationLabel(applicationInfo).toString(); + onAppInstalled(context, applicationInfo.uid, added_package, label, + PackageManager.PERMISSION_GRANTED == packager.checkPermission(Manifest.permission.INTERNET, added_package)); + } catch (NameNotFoundException e) { + Log.w(TAG, "New package " + added_package + " not found"); } } } } - private void addNotification(Context context, String label) { - final int NOTIFICATION_ID = 100; - String NOTIFICATION_CHANNEL_ID = "firewall.app.notification"; - String channelName = context.getString(R.string.app_notification); - - //cancel existing notification - NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - manager.cancel(NOTIFICATION_ID); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - NotificationChannel chan = new NotificationChannel(NOTIFICATION_CHANNEL_ID, channelName, NotificationManager.IMPORTANCE_DEFAULT); - chan.setShowBadge(false); - chan.setSound(null,null); - chan.enableLights(false); - chan.enableVibration(false); - chan.setLockscreenVisibility(Notification.VISIBILITY_PRIVATE); - assert manager != null; - manager.createNotificationChannel(chan); + /** + * A package was installed in another Android user of this device (work profile, Private + * Space, clone profile), reported by LauncherApps while FirewallService runs. + */ + public static void onProfilePackageAdded(Context context, LauncherApps launcherApps, String pkg, UserHandle user) { + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.O) { + return; // the listener is only registered on 8+ } + Api.applications = null; + UidResolver.clearCache(); + try { + ApplicationInfo info = launcherApps.getApplicationInfo(pkg, 0, user); + if (info == null) { + return; + } + String label = info.loadLabel(context.getPackageManager()).toString(); + onAppInstalled(context, info.uid, pkg, context.getString(R.string.notif_new_app_profile, label), + requestsInternet(context, pkg)); + } catch (Exception e) { + Log.w(TAG, "Unable to read new app " + pkg + " of " + user + ": " + e.getMessage()); + } + } - - Intent appIntent = new Intent(context, MainActivity.class); - appIntent.setAction(Intent.ACTION_MAIN); - appIntent.addCategory(Intent.CATEGORY_LAUNCHER); - appIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); - - PendingIntent notifyPendingIntent = PendingIntent.getActivity(context, 0, appIntent, PendingIntent.FLAG_IMMUTABLE); - NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID); - notificationBuilder.setContentIntent(notifyPendingIntent); - - String notificationText = context.getString(R.string.notification_new); - if (label != null) { - notificationText = label + "-" + context.getString(R.string.notification_new_package); + /** + * A new app (in any Android user): apply the default connections (donate) in the active + * profile, then notify. The defaults don't depend on the notification setting. + */ + private static void onAppInstalled(Context context, int uid, String pkg, String label, boolean internet) { + if (Api.recentlyInstalled == null) { + Api.recentlyInstalled = new HashSet<>(); + } + Api.recentlyInstalled.add(pkg); + if (G.isDoKey(context) || isDonate()) { + Api.setDefaultPermission(context, uid); + } + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + if (internet && prefs.getBoolean("notifyAppInstall", true) && Api.isEnabled(context)) { + // after the defaults: the text says what access the app now has + Notifications.newApp(context, uid, pkg, label); } + } - Notification notification = notificationBuilder.setOngoing(false) - .setPriority(NotificationManager.IMPORTANCE_DEFAULT) - .setCategory(Notification.CATEGORY_SERVICE) - .setSound(null) - .setSmallIcon(R.drawable.notification_quest) - .setContentTitle(context.getString(R.string.notification_title)) - .setTicker(context.getString(R.string.notification_title)) - .setContentText(notificationText) - .build(); - - manager.notify(NOTIFICATION_ID, notification); + /** + * @return true unless the package is known not to request INTERNET (packages of other users + * may not be visible to this user's PackageManager; notify then) + */ + private static boolean requestsInternet(Context context, String pkg) { + PackageManager pm = context.getPackageManager(); + if (pm.checkPermission(Manifest.permission.INTERNET, pkg) == PackageManager.PERMISSION_GRANTED) { + return true; + } + try { + PackageInfo pi = pm.getPackageInfo(pkg, PackageManager.GET_PERMISSIONS); + if (pi.requestedPermissions != null) { + for (String perm : pi.requestedPermissions) { + if (Manifest.permission.INTERNET.equals(perm)) { + return true; + } + } + } + return false; + } catch (NameNotFoundException e) { + return true; + } } } diff --git a/app/src/main/java/dev/ukanth/ufirewall/log/LogDetailRecyclerViewAdapter.java b/app/src/main/java/dev/ukanth/ufirewall/log/LogDetailRecyclerViewAdapter.java index 0eb190092..667873181 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/log/LogDetailRecyclerViewAdapter.java +++ b/app/src/main/java/dev/ukanth/ufirewall/log/LogDetailRecyclerViewAdapter.java @@ -11,6 +11,9 @@ import java.util.ArrayList; import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.Map; +import java.util.HashMap; import dev.ukanth.ufirewall.R; @@ -24,6 +27,13 @@ public class LogDetailRecyclerViewAdapter extends RecyclerView.Adapter connectionAttempts = new ConcurrentHashMap<>(); + + // Cache for expensive operations + private final Map serviceCache = new HashMap<>(); + private final Map interfaceNameCache = new HashMap<>(); public LogDetailRecyclerViewAdapter(final Context context, RecyclerItemClickListener recyclerItemClickListener) { @@ -37,6 +47,13 @@ public void updateData(List logDataList) { logData.addAll(logDataList); } + /** + * Append a page of rows (main thread; notify the insert afterwards). + */ + public void addData(List more) { + logData.addAll(more); + } + @Override public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { View mView = LayoutInflater.from(parent.getContext()).inflate(R.layout.logdetail_recycle_item, parent, false); @@ -48,18 +65,168 @@ public void onBindViewHolder(ViewHolder holder, int position) { data = logData.get(position); if (data != null) { holder.bind(logData.get(position), recyclerItemClickListener); - if(data.getOut() != null) { - holder.deniedTime.setText(pretty(data.getTimestamp()) + "(" + data.getOut() + ")"); - if((data.getOut().contains("lan") || data.getOut().startsWith("eth") || data.getOut().startsWith("ra") || data.getOut().startsWith("bnep"))) { - holder.icon.setImageDrawable(context.getResources().getDrawable(R.drawable.ic_wifi)); - } else{ - holder.icon.setImageDrawable(context.getResources().getDrawable(R.drawable.ic_mobiledata)); + + // Format timestamp with interface info (cached) + String timeAndInterface = pretty(data.getTimestamp()); + if (data.getOut() != null && !data.getOut().isEmpty()) { + String interfaceType = getCachedInterfaceDisplayName(data.getOut()); + timeAndInterface += " via " + interfaceType; + } + holder.deniedTime.setText(timeAndInterface); + + // Set connection type icon with better logic + setConnectionIcon(holder.icon, data.getOut()); + + // Format destination with better readability + String destination = data.getDst() + ":" + data.getDpt(); + holder.dataDest.setText(destination); + + // Format source with better readability + String source = data.getSrc() + ":" + data.getSpt(); + holder.dataSrc.setText(source); + + // Format protocol with more context (cached) + String protocol = data.getProto(); + if (protocol != null) { + protocol = protocol.toUpperCase(); + // Add service context for common ports (cached) + String serviceInfo = getCachedServiceInfo(data.getDpt(), protocol); + if (!serviceInfo.isEmpty()) { + protocol += " (" + serviceInfo + ")"; } } - holder.dataDest.setText(context.getResources().getString(R.string.log_dst) + data.getDst() + ":" + data.getDpt()); - holder.dataSrc.setText(context.getResources().getString(R.string.log_src) + data.getSrc() + ":" + data.getSpt()); - holder.dataProto.setText(context.getResources().getString(R.string.log_proto) + data.getProto()); - holder.dataHost.setText(context.getResources().getString(R.string.host) + data.getHostname()); + holder.dataProto.setText(protocol != null ? protocol : "Unknown"); + + // Format hostname with better handling + if (data.getHostname() != null && !data.getHostname().trim().isEmpty() && !data.getHostname().equals(data.getDst())) { + holder.dataHost.setText(data.getHostname()); + holder.dataHost.setVisibility(View.VISIBLE); + } else { + holder.dataHost.setVisibility(View.GONE); + } + + // Show packet size if available + if (data.getLen() > 0 && holder.packetSize != null) { + holder.packetSize.setText(formatBytes(data.getLen())); + holder.packetSize.setVisibility(View.VISIBLE); + } else if (holder.packetSize != null) { + holder.packetSize.setVisibility(View.GONE); + } + + // Hide block count for now to improve performance - can be re-enabled later + if (holder.blockCount != null) { + holder.blockCount.setVisibility(View.GONE); + } + } + } + + private void setConnectionIcon(ImageView icon, String outInterface) { + if (outInterface == null || outInterface.isEmpty()) { + icon.setImageDrawable(context.getResources().getDrawable(R.drawable.ic_help)); + return; + } + + if (outInterface.contains("lan") || outInterface.startsWith("eth") || + outInterface.startsWith("ra") || outInterface.startsWith("bnep") || + outInterface.contains("wlan") || outInterface.contains("wifi")) { + icon.setImageDrawable(context.getResources().getDrawable(R.drawable.ic_wifi)); + } else if (outInterface.contains("mobile") || outInterface.contains("rmnet") || + outInterface.contains("ccmni") || outInterface.contains("pdp")) { + icon.setImageDrawable(context.getResources().getDrawable(R.drawable.ic_mobiledata)); + } else if (outInterface.contains("tun") || outInterface.contains("ppp")) { + icon.setImageDrawable(context.getResources().getDrawable(R.drawable.ic_lan)); // Use lan icon for VPN as fallback + } else { + icon.setImageDrawable(context.getResources().getDrawable(R.drawable.ic_help)); // Use help icon as fallback + } + } + + private String getCachedInterfaceDisplayName(String outInterface) { + if (outInterface == null || outInterface.isEmpty()) { + return "Unknown"; + } + + // Check cache first + if (interfaceNameCache.containsKey(outInterface)) { + return interfaceNameCache.get(outInterface); + } + + String displayName = getInterfaceDisplayName(outInterface); + interfaceNameCache.put(outInterface, displayName); + return displayName; + } + + private String getInterfaceDisplayName(String outInterface) { + if (outInterface == null || outInterface.isEmpty()) { + return "Unknown"; + } + + if (outInterface.contains("lan") || outInterface.startsWith("eth") || + outInterface.startsWith("ra") || outInterface.startsWith("bnep") || + outInterface.contains("wlan") || outInterface.contains("wifi")) { + return "Wi-Fi"; + } else if (outInterface.contains("mobile") || outInterface.contains("rmnet") || + outInterface.contains("ccmni") || outInterface.contains("pdp")) { + return "Mobile Data"; + } else if (outInterface.contains("tun") || outInterface.contains("ppp")) { + return "VPN"; + } else { + return outInterface; + } + } + + private String getCachedServiceInfo(int port, String protocol) { + int key = (protocol != null ? protocol.hashCode() : 0) * 100000 + port; + + // Check cache first + if (serviceCache.containsKey(key)) { + return serviceCache.get(key); + } + + String serviceInfo = getServiceInfo(port, protocol); + serviceCache.put(key, serviceInfo); + return serviceInfo; + } + + private String getServiceInfo(int port, String protocol) { + if ("TCP".equals(protocol)) { + switch (port) { + case 80: return "HTTP"; + case 443: return "HTTPS"; + case 21: return "FTP"; + case 22: return "SSH"; + case 23: return "Telnet"; + case 25: return "SMTP"; + case 53: return "DNS"; + case 110: return "POP3"; + case 143: return "IMAP"; + case 993: return "IMAPS"; + case 995: return "POP3S"; + case 1723: return "PPTP"; + case 3389: return "RDP"; + case 5060: return "SIP"; + case 8080: return "HTTP Alt"; + } + } else if ("UDP".equals(protocol)) { + switch (port) { + case 53: return "DNS"; + case 67: return "DHCP Server"; + case 68: return "DHCP Client"; + case 123: return "NTP"; + case 500: return "IPSec"; + case 1194: return "OpenVPN"; + case 5060: return "SIP"; + } + } + return ""; + } + + private String formatBytes(int bytes) { + if (bytes < 1024) { + return bytes + "B"; + } else if (bytes < 1024 * 1024) { + return String.format("%.1fKB", bytes / 1024.0); + } else { + return String.format("%.1fMB", bytes / (1024.0 * 1024.0)); } } @@ -76,24 +243,24 @@ public int getItemCount() { public static class ViewHolder extends RecyclerView.ViewHolder { final ImageView icon; - //final TextView appName; final TextView deniedTime; - //final TextView dataInterface; final TextView dataDest; final TextView dataSrc; final TextView dataProto; final TextView dataHost; + final TextView packetSize; + final TextView blockCount; public ViewHolder(View itemView) { super(itemView); icon = itemView.findViewById(R.id.data_icon); - //appName = (TextView)itemView.findViewById(R.id.app_name); deniedTime = itemView.findViewById(R.id.denied_time); - //dataInterface = (TextView)itemView.findViewById(R.id.data_interface); dataDest = itemView.findViewById(R.id.data_dest); dataSrc = itemView.findViewById(R.id.data_src); dataProto = itemView.findViewById(R.id.data_proto); dataHost = itemView.findViewById(R.id.data_host); + packetSize = itemView.findViewById(R.id.packet_size); + blockCount = itemView.findViewById(R.id.block_count); } public void bind(final LogData item, final RecyclerItemClickListener listener) { diff --git a/app/src/main/java/dev/ukanth/ufirewall/log/LogInfo.java b/app/src/main/java/dev/ukanth/ufirewall/log/LogInfo.java index e8b1e3155..8ebf9f571 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/log/LogInfo.java +++ b/app/src/main/java/dev/ukanth/ufirewall/log/LogInfo.java @@ -43,6 +43,8 @@ import dev.ukanth.ufirewall.InterfaceTracker; import dev.ukanth.ufirewall.R; import dev.ukanth.ufirewall.util.G; +import dev.ukanth.ufirewall.util.UidResolver; +import dev.ukanth.ufirewall.util.UidCorrelator; public class LogInfo { public String uidString; @@ -125,14 +127,26 @@ public static String parseLog(Context ctx, List listLogData) { for (int i = 0; i < map.size(); i++) { StringBuilder address = new StringBuilder(); id = map.keyAt(i); + appName = ""; // Reset for each iteration + appId = -1; + if (id != -1) { + // First, try to find in cached app list + boolean foundInApps = false; for (PackageInfoData app : apps) { if (app.uid == id) { appId = id; appName = app.names.get(0); + foundInApps = true; break; } } + + // If not found in apps, use comprehensive UID resolver + if (!foundInApps) { + appId = id; + appName = UidResolver.resolveUid(ctx, id); + } } else { appName = ctx.getString(R.string.unknown_item); } @@ -187,7 +201,9 @@ public static LogInfo parseLogs(String result, final Context ctx, String pattern if (((start = result.indexOf("UID=")) != -1) && ((end = result.indexOf(" ", start)) != -1)) { - strUid = Integer.parseInt(result.substring(start + 4, end)); + // nflog prints the UID unsigned: 4294967295 (-1) means no owner, like no UID + long parsedUid = Long.parseLong(result.substring(start + 4, end)); + strUid = parsedUid >= 0 && parsedUid < Integer.MAX_VALUE ? (int) parsedUid : null; if (strUid != null) { uid = strUid; logInfo.uid = strUid; @@ -249,9 +265,28 @@ public static LogInfo parseLogs(String result, final Context ctx, String pattern return null; //logInfo.uid = 0; } else if(uid == -100) { - appName = ctx.getString(R.string.unknown_item); - logInfo.uid = uid; - } else { + // Attempt enhanced UID correlation before giving up + int correlatedUid = UidCorrelator.correlateUid( + logInfo.src, logInfo.dst, logInfo.dpt, logInfo.spt, + logInfo.proto, System.currentTimeMillis()); + + if (correlatedUid != -100) { + // Successfully correlated! Update UID and continue with normal processing + uid = correlatedUid; + logInfo.uid = correlatedUid; + Log.d(Api.TAG, "Enhanced correlation resolved UID " + correlatedUid + + " for connection to " + logInfo.dst + ":" + logInfo.dpt); + } else { + // No owning socket: the traffic the kernel entry of the app list controls + // (closing packets of closed sockets, kernel VPNs, ...). Logged as that + // entry, so it can be understood, muted and allowed/blocked like an app. + uid = Api.SPECIAL_UID_KERNEL; + logInfo.uid = uid; + } + } + + // Process the UID (whether original, correlated, or unknown) + if (uid != -100) { if (uid < 2000) { appName = Api.getSpecialAppName(uid); if(uid == 1000) { @@ -261,13 +296,28 @@ public static LogInfo parseLogs(String result, final Context ctx, String pattern //system level packages try { if (!appNameMap.containsKey(uid)) { - appName = ctx.getPackageManager().getNameForUid(uid); + appName = null; for (PackageInfoData app : apps) { if (app.uid == uid) { appName = app.names.get(0); break; } } + // an app of a work profile / Private Space not in the list (dual apps + // off): the label of the same app of the main user and the profile marker + if ((appName == null || appName.length() == 0) && uid >= 100000) { + for (PackageInfoData app : apps) { + if (app.uid == uid % 100000) { + appName = app.names.get(0) + " " + Api.profileMarker(uid); + break; + } + } + } + // Android package visibility can hide packages from PackageManager. + // Fall back to shell-backed UID resolution before showing "Deleted App". + if (appName == null || appName.length() == 0) { + appName = UidResolver.resolveUid(ctx, uid); + } } else { appName = appNameMap.get(uid); } @@ -291,16 +341,8 @@ public static LogInfo parseLogs(String result, final Context ctx, String pattern address.append(":"); address.append(logInfo.dpt); logInfo.type = type; - if (G.showHost()) { - try { - String add = InetAddress.getByName(logInfo.dst).getHostName(); - if (add != null) { - logInfo.host = add; - address.append("(").append(add).append(") "); - } - } catch (Exception e) { - } - } + // Hostname resolution is handled asynchronously in LogService.store() + // to avoid blocking the log parsing callback thread address.append("\n"); logInfo.timestamp = System.currentTimeMillis(); logInfo.uidString = address.toString(); diff --git a/app/src/main/java/dev/ukanth/ufirewall/log/LogRecyclerViewAdapter.java b/app/src/main/java/dev/ukanth/ufirewall/log/LogRecyclerViewAdapter.java index c7b75b2b8..88f0ea14d 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/log/LogRecyclerViewAdapter.java +++ b/app/src/main/java/dev/ukanth/ufirewall/log/LogRecyclerViewAdapter.java @@ -25,6 +25,7 @@ import dev.ukanth.ufirewall.Api; import dev.ukanth.ufirewall.R; +import dev.ukanth.ufirewall.util.AppIcons; import dev.ukanth.ufirewall.util.G; /** @@ -58,61 +59,12 @@ public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { return new ViewHolder(mView); } - /*private Bitmap getAppIcon(PackageManager mPackageManager, ApplicationInfo applicationInfo) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - return getAppIcon26(mPackageManager, applicationInfo); - } - try { - Drawable drawable = mPackageManager.getApplicationIcon(applicationInfo); - return ((BitmapDrawable) drawable).getBitmap(); - } catch (Exception e) { - Log.e(TAG, e.getMessage(), e); - } - return null; - } - - @RequiresApi(api = Build.VERSION_CODES.O) - private Bitmap getAppIcon26(PackageManager mPackageManager, ApplicationInfo applicationInfo) { - Drawable drawable = mPackageManager.getApplicationIcon(applicationInfo); - if (drawable instanceof BitmapDrawable) { - return ((BitmapDrawable) drawable).getBitmap(); - } else if (drawable instanceof AdaptiveIconDrawable) { - Drawable backgroundDr = ((AdaptiveIconDrawable) drawable).getBackground(); - Drawable foregroundDr = ((AdaptiveIconDrawable) drawable).getForeground(); - - Drawable[] drr = new Drawable[2]; - drr[0] = backgroundDr; - drr[1] = foregroundDr; - - LayerDrawable layerDrawable = new LayerDrawable(drr); - - int width = layerDrawable.getIntrinsicWidth(); - int height = layerDrawable.getIntrinsicHeight(); - - Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); - - Canvas canvas = new Canvas(bitmap); - - layerDrawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); - layerDrawable.draw(canvas); - - return bitmap; - } - - return null; - }*/ - @Override public void onBindViewHolder(ViewHolder holder, int position) { data = logData.get(position); - PackageManager manager = context.getPackageManager(); holder.bind(logData.get(position),recyclerItemClickListener); - try { - Drawable applicationIcon = Api.getApplicationIcon(context, data.getUid()); - holder.icon.setBackground(applicationIcon); - } catch (Exception e) { - Log.e(TAG, e.getMessage(), e); - } + // loaded off the main thread, work profile apps badged + AppIcons.showAsBackground(context, data.getUid(), holder.icon); try { //if(data.getTimestamp() != null && !data.getTimestamp().isEmpty()) { diff --git a/app/src/main/java/dev/ukanth/ufirewall/log/LogRxEvent.java b/app/src/main/java/dev/ukanth/ufirewall/log/LogRxEvent.java deleted file mode 100644 index 1a8823406..000000000 --- a/app/src/main/java/dev/ukanth/ufirewall/log/LogRxEvent.java +++ /dev/null @@ -1,32 +0,0 @@ -package dev.ukanth.ufirewall.log; - -import androidx.annotation.NonNull; - -import dev.ukanth.ufirewall.events.LogEvent; -import io.reactivex.rxjava3.disposables.Disposable; -import io.reactivex.rxjava3.functions.Consumer; -import io.reactivex.rxjava3.subjects.PublishSubject; - -/** - * Created by ukanth on 25/9/17. - */ - -public class LogRxEvent { - private static final PublishSubject sSubject = PublishSubject.create(); - - private LogRxEvent() { - // hidden constructor - } - - public static Disposable subscribe(@NonNull Consumer action) { - return sSubject.subscribe(action); - } - - public static void publish(@NonNull LogEvent message) { - sSubject.onNext(message); - } - - public static PublishSubject getSubject() { - return sSubject; - } -} diff --git a/app/src/main/java/dev/ukanth/ufirewall/plugin/FireReceiver.java b/app/src/main/java/dev/ukanth/ufirewall/plugin/FireReceiver.java index cee9bfede..f5101f579 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/plugin/FireReceiver.java +++ b/app/src/main/java/dev/ukanth/ufirewall/plugin/FireReceiver.java @@ -17,14 +17,16 @@ import android.content.Intent; import android.os.Bundle; import android.os.Handler; -import android.os.Message; -import android.widget.Toast; +import android.os.Looper; + +import java.util.concurrent.atomic.AtomicBoolean; import dev.ukanth.ufirewall.Api; import dev.ukanth.ufirewall.R; +import dev.ukanth.ufirewall.log.Log; import dev.ukanth.ufirewall.profiles.ProfileData; import dev.ukanth.ufirewall.profiles.ProfileHelper; -import dev.ukanth.ufirewall.service.RootCommand; +import dev.ukanth.ufirewall.util.FirewallActions; import dev.ukanth.ufirewall.util.G; /** @@ -32,6 +34,8 @@ */ public final class FireReceiver extends BroadcastReceiver { public static final String TAG = "AFWall"; + // goAsync() must be released before the broadcast times out (10 s for foreground broadcasts) + private static final long ASYNC_TIMEOUT_MS = 9000; /** * @param context {@inheritDoc}. @@ -64,238 +68,106 @@ public void onReceive(final Context context, final Intent intent) { /* * Final verification of the plug-in Bundle before firing the setting. */ - if (PluginBundleManager.isBundleValid(bundle)) { - String index = bundle.getString(PluginBundleManager.BUNDLE_EXTRA_STRING_MESSAGE); - String name = null; - if (index.contains("::")) { - String[] msg = index.split("::"); - index = msg[0]; - name = msg[1]; - } - final boolean multimode = G.enableMultiProfile(); - final boolean disableToasts = G.disableTaskerToast(); - if (!G.isProfileMigrated()) { - if (index != null) { - //int id = Integer.parseInt(index); - switch (index) { - case "0": - Api.applySavedIptablesRules(context, false, new RootCommand() - .setFailureToast(R.string.error_apply) - .setCallback(new RootCommand.Callback() { - @Override - public void cbFunc(RootCommand state) { - Message msg = new Message(); - if (state.exitCode == 0) { - msg.arg1 = R.string.rules_applied; - Api.setEnabled(context, true, false); - } else { - // error details are already in logcat - msg.arg1 = R.string.error_apply; - } - sendMessage(msg); - } - })); - break; - case "1": - if (G.protectionLevel().equals("p0")) { - Api.purgeIptables(context, true, new RootCommand() - .setReopenShell(true) - .setCallback(new RootCommand.Callback() { - public void cbFunc(RootCommand state) { - Message msg = new Message(); - msg.arg1 = R.string.toast_disabled; - sendMessage(msg); - Api.setEnabled(context, false, false); - } - })); - } else { - Message msg = new Message(); - msg.arg1 = R.string.widget_disable_fail; - sendMessage(msg); - } - break; - case "2": - if (multimode) { - G.setProfile(true, "AFWallPrefs"); - } - break; - case "3": - if (multimode) { - G.setProfile(true, "AFWallProfile1"); - } - break; - case "4": - if (multimode) { - G.setProfile(true, "AFWallProfile2"); - } - break; - case "5": - if (multimode) { - G.setProfile(true, "AFWallProfile3"); - } - break; - default: - if (multimode) { - G.setProfile(true, name); - } - break; - } - - if (Integer.parseInt(index) > 1) { - if (multimode) { - if (Api.isEnabled(context)) { - if (!disableToasts) { - Toast.makeText(context, R.string.tasker_apply, Toast.LENGTH_SHORT).show(); - } - Api.applySavedIptablesRules(context, false, new RootCommand() - .setFailureToast(R.string.error_apply) - .setCallback(new RootCommand.Callback() { - @Override - public void cbFunc(RootCommand state) { - Message msg = new Message(); - if (state.exitCode == 0) { - msg.arg1 = R.string.tasker_profile_applied; - if (!disableToasts) - sendMessage(msg); - } else { - // error details are already in logcat - msg.arg1 = R.string.error_apply; - } - sendMessage(msg); - } - })); - } else { - Message msg = new Message(); - msg.arg1 = R.string.tasker_disabled; - sendMessage(msg); - } - } else { - Message msg = new Message(); - msg.arg1 = R.string.tasker_muliprofile; - sendMessage(msg); - } - G.reloadPrefs(); - /*if (G.activeNotification()) { - Api.showNotification(Api.isEnabled(context), context); - }*/ - Api.updateNotification(Api.isEnabled(context), context); - } - } - } else { - if (index != null) { - //int id = Integer.parseInt(index); - switch (index) { - case "0": - Api.applySavedIptablesRules(context, false, new RootCommand() - .setFailureToast(R.string.error_apply) - .setCallback(new RootCommand.Callback() { - @Override - public void cbFunc(RootCommand state) { - Message msg = new Message(); - if (state.exitCode == 0) { - msg.arg1 = R.string.rules_applied; - Api.setEnabled(context, true, false); - } else { - // error details are already in logcat - msg.arg1 = R.string.error_apply; - } - sendMessage(msg); - } - })); - break; - case "1": - if (G.protectionLevel().equals("p0")) { - Api.purgeIptables(context, true, new RootCommand() - .setReopenShell(true) - .setCallback(new RootCommand.Callback() { - public void cbFunc(RootCommand state) { - Message msg = new Message(); - msg.arg1 = R.string.toast_disabled; - sendMessage(msg); - Api.setEnabled(context, false, false); - } - })); - /* } else { - msg.arg1 = R.string.toast_error_disabling; - sendMessage(msg); - }*/ - } else { - Message msg = new Message(); - msg.arg1 = R.string.widget_disable_fail; - sendMessage(msg); - } - break; - case "2": - if (multimode) { - G.setProfile(true, "AFWallPrefs"); - } - break; - default: - if (multimode) { - ProfileData data = ProfileHelper.getProfileByName(name); - if (data != null) { - G.setProfile(true, data.getIdentifier()); - } + if (!PluginBundleManager.isBundleValid(bundle)) { + return; + } + if (!G.allowTaskerControl()) { + // the receiver has to be exported for Tasker/Locale, so any app can send this + Log.i(TAG, "Tasker/Locale action ignored: control by other apps is turned off"); + return; + } + String index = bundle.getString(PluginBundleManager.BUNDLE_EXTRA_STRING_MESSAGE); + String name = null; + if (index.contains("::")) { + String[] parts = index.split("::", 2); + index = parts[0]; + name = parts[1].isEmpty() ? null : parts[1]; + } + final String profileId = bundle.getString(PluginBundleManager.BUNDLE_EXTRA_STRING_PROFILE_ID); + final Context app = context.getApplicationContext(); - } - break; - } + // Keep the process alive until the rules are loaded: when AFWall+ isn't running, Android + // may otherwise kill it as soon as onReceive() returns. Released on completion, or + // before the broadcast timeout. + final PendingResult pending = goAsync(); + final AtomicBoolean finished = new AtomicBoolean(false); + final Runnable finish = () -> { + if (finished.compareAndSet(false, true)) { + pending.finish(); + } + }; + new Handler(Looper.getMainLooper()).postDelayed(finish, ASYNC_TIMEOUT_MS); - if (Integer.parseInt(index) > 1) { - if (multimode) { - if (Api.isEnabled(context)) { - if (!disableToasts) { - Toast.makeText(context, R.string.tasker_apply, Toast.LENGTH_SHORT).show(); - } - Api.applySavedIptablesRules(context, false, new RootCommand() - .setFailureToast(R.string.error_apply) - .setCallback(new RootCommand.Callback() { - @Override - public void cbFunc(RootCommand state) { - Message msg = new Message(); - if (state.exitCode == 0) { - msg.arg1 = R.string.tasker_profile_applied; - if (!disableToasts) sendMessage(msg); - } else { - // error details are already in logcat - msg.arg1 = R.string.error_apply; - } - } - })); - } else { - Message msg = new Message(); - msg.arg1 = R.string.tasker_disabled; - sendMessage(msg); - } - } else { - Message msg = new Message(); - msg.arg1 = R.string.tasker_muliprofile; - sendMessage(msg); - } - G.reloadPrefs(); - /* if (G.activeNotification()) { - Api.showNotification(Api.isEnabled(context), context); - }*/ - Api.updateNotification(Api.isEnabled(context), context); - } + switch (index) { + case "0": + FirewallActions.setEnabled(app, true, false, ok -> { + toast(app, ok ? R.string.toast_enabled : R.string.toast_error_enabling, ok); + finish.run(); + }); + return; + case "1": + if (!G.protectionLevel().equals("p0")) { + toast(app, R.string.widget_disable_fail, false); + finish.run(); + return; } - - - } + FirewallActions.setEnabled(app, false, false, ok -> { + toast(app, ok ? R.string.toast_disabled : R.string.toast_error_disabling, ok); + finish.run(); + }); + return; + default: + switchProfile(app, index, name, profileId, finish); } + } + private static void switchProfile(Context app, String index, String name, String profileId, Runnable finish) { + if (!G.enableMultiProfile()) { + toast(app, R.string.tasker_muliprofile, false); + finish.run(); + return; + } + String identifier = null; + if (profileId != null && (Api.DEFAULT_PREFS_NAME.equals(profileId) + || ProfileHelper.getProfileByIdentifier(profileId) != null)) { + identifier = profileId; // survives renaming + } else if (index.equals("2")) { + identifier = Api.DEFAULT_PREFS_NAME; + } else if (name != null) { + ProfileData data = ProfileHelper.getProfileByName(name); + identifier = data != null ? data.getIdentifier() : null; + } else if (index.equals("3") || index.equals("4") || index.equals("5")) { + // very old actions saved only the index: 3-5 were Profile 1-3 + ProfileData data = ProfileHelper.getProfileByIdentifier("AFWallProfile" + (Integer.parseInt(index) - 2)); + identifier = data != null ? data.getIdentifier() : null; + } + if (identifier == null) { + // deleted, or renamed before actions stored the identifier: don't pretend it worked + Api.toast(app, app.getString(R.string.tasker_profile_not_found, name != null ? name : index)); + finish.run(); + return; + } + final boolean enabled = Api.isEnabled(app); + if (enabled) { + toast(app, R.string.tasker_apply, true); + } + FirewallActions.switchProfile(app, identifier, false, ok -> { + if (!enabled) { + toast(app, R.string.tasker_disabled, false); + } else { + toast(app, ok ? R.string.tasker_profile_applied : R.string.error_apply, ok); + } + Api.updateNotification(Api.isEnabled(app), app); + finish.run(); + }); } - private void sendMessage(Message msg) { - try { - new Handler() { - public void handleMessage(Message msg) { - if (msg.arg1 != 0) - Toast.makeText(G.getContext(), msg.arg1, Toast.LENGTH_SHORT).show(); - } - }.sendMessage(msg); - }catch (Exception e) { - //unable to send toast. but don't crash + /** + * @param success success messages follow the "disable Tasker toasts" setting; errors always show + */ + private static void toast(Context app, int resId, boolean success) { + if (!success || !G.disableTaskerToast()) { + Api.toast(app, app.getString(resId)); } } } \ No newline at end of file diff --git a/app/src/main/java/dev/ukanth/ufirewall/plugin/LocaleEdit.java b/app/src/main/java/dev/ukanth/ufirewall/plugin/LocaleEdit.java index 6562d3bfa..c250e0d38 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/plugin/LocaleEdit.java +++ b/app/src/main/java/dev/ukanth/ufirewall/plugin/LocaleEdit.java @@ -7,26 +7,22 @@ import android.preference.PreferenceManager; import android.view.Menu; import android.view.MenuItem; +import android.view.View; import android.widget.RadioButton; import android.widget.RadioGroup; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.widget.Toolbar; -import java.util.List; - import dev.ukanth.ufirewall.R; import dev.ukanth.ufirewall.profiles.ProfileData; import dev.ukanth.ufirewall.profiles.ProfileHelper; -import dev.ukanth.ufirewall.util.G; public class LocaleEdit extends AppCompatActivity { //public static final String LOCALE_BRIGHTNESS = "dev.ukanth.ufirewall.plugin.LocaleEdit.ACTIVE_PROFLE"; private boolean mIsCancelled = false; - private final int CUSTOM_PROFILE_ID = 100; - protected void onCreate(Bundle paramBundle) { super.onCreate(paramBundle); @@ -50,120 +46,60 @@ protected void onCreate(Bundle paramBundle) { button1.setText(name != null && name.length() == 0 ? getString(R.string.defaultProfile) : name); - if (!G.isProfileMigrated()) { - - RadioGroup profiles = findViewById(R.id.radioProfiles); - - RadioButton button2 = findViewById(R.id.profile1); - RadioButton button3 = findViewById(R.id.profile2); - RadioButton button4 = findViewById(R.id.profile3); + RadioGroup profiles = findViewById(R.id.radioProfiles); + // the layout's fixed Profile 1-3 buttons are from the old profile model; list the saved ones + profiles.removeView(findViewById(R.id.profile1)); + profiles.removeView(findViewById(R.id.profile2)); + profiles.removeView(findViewById(R.id.profile3)); - List profilesList = G.getAdditionalProfiles(); - //int textColor = Color.parseColor("#000000"); - - int counter = CUSTOM_PROFILE_ID; - for (String profile : profilesList) { + for (ProfileData data : ProfileHelper.getProfiles()) { + if (data != null) { RadioButton rdbtn = new RadioButton(this); - rdbtn.setId(counter++); - rdbtn.setText(profile); + rdbtn.setId(View.generateViewId()); + rdbtn.setText(data.getName()); + rdbtn.setTag(data.getIdentifier()); profiles.addView(rdbtn); } + } - name = prefs.getString("profile1", getString(R.string.profile1)); - button2.setText(name != null && name.length() == 0 ? getString(R.string.profile1) : name); - name = prefs.getString("profile2", getString(R.string.profile2)); - button3.setText(name != null && name.length() == 0 ? getString(R.string.profile2) : name); - name = prefs.getString("profile3", getString(R.string.profile3)); - button4.setText(name != null && name.length() == 0 ? getString(R.string.profile3) : name); - - setupTitleApi11(); - - if (null == paramBundle) { - final Bundle forwardedBundle = getIntent().getBundleExtra( - com.twofortyfouram.locale.Intent.EXTRA_BUNDLE); - if (PluginBundleManager.isBundleValid(forwardedBundle)) { - String index = forwardedBundle.getString(PluginBundleManager.BUNDLE_EXTRA_STRING_MESSAGE); - if (index.contains("::")) { - index = index.split("::")[0]; - } - if (index != null) { - switch (index) { - case "0": - tasker_enable.setChecked(true); - break; - case "1": - tasker_disable.setChecked(true); - break; - case "2": - button1.setChecked(true); - break; - case "3": - button2.setChecked(true); - break; - case "4": - button3.setChecked(true); - break; - case "5": - button4.setChecked(true); - break; - default: - int diff = CUSTOM_PROFILE_ID + (Integer.parseInt(index) - 6); - RadioButton btn = findViewById(diff); - if (btn != null) { - btn.setChecked(true); - } - } - } - } - } - } else { - //TODO: lets do it on new way - RadioGroup profiles = findViewById(R.id.radioProfiles); - //remove the existing profiles - RadioButton button2 = findViewById(R.id.profile1); - RadioButton button3 = findViewById(R.id.profile2); - RadioButton button4 = findViewById(R.id.profile3); - profiles.removeView(button2); - profiles.removeView(button3); - profiles.removeView(button4); - - for (ProfileData data : ProfileHelper.getProfiles()) { - if (data != null) { - String profile = data.getName(); - Long id = data.getId(); - RadioButton rdbtn = new RadioButton(this); - rdbtn.setId(id.intValue()); - rdbtn.setText(profile); - profiles.addView(rdbtn); + setupTitleApi11(); + + if (null == paramBundle) { + final Bundle forwardedBundle = getIntent().getBundleExtra( + com.twofortyfouram.locale.Intent.EXTRA_BUNDLE); + if (PluginBundleManager.isBundleValid(forwardedBundle)) { + // "::

+ * Identifier of the profile to switch to, so the action keeps working after the profile is + * renamed. Actions saved by older versions only have the name in the message. + */ + public static final String BUNDLE_EXTRA_STRING_PROFILE_ID = "dev.ukanth.ufirewall.plugin.extra.PROFILE_ID"; //$NON-NLS-1$ + /** * Method to verify the content of the bundle are correct. *

@@ -63,9 +71,18 @@ public static boolean isBundleValid(final Bundle bundle) return false; } /* - * Make sure the extra isn't null or empty + * Make sure the extra is a numeric action/profile id. Locale/Tasker callers can send + * arbitrary exported extras, so reject malformed values before parseInt() call sites. */ - return !TextUtils.isEmpty(bundle.getString(BUNDLE_EXTRA_STRING_MESSAGE)); + String message = bundle.getString(BUNDLE_EXTRA_STRING_MESSAGE); + if (TextUtils.isEmpty(message)) { + return false; + } + String index = message; + if (message.contains("::")) { + index = message.split("::", 2)[0]; + } + return !TextUtils.isEmpty(index) && TextUtils.isDigitsOnly(index); } /** @@ -80,6 +97,17 @@ public static Bundle generateBundle(final Context context, final String message) return result; } + /** + * @param profileId identifier of the profile the action switches to; may be null + */ + public static Bundle generateBundle(final Context context, final String message, final String profileId) { + final Bundle result = generateBundle(context, message); + if (profileId != null) { + result.putString(BUNDLE_EXTRA_STRING_PROFILE_ID, profileId); + } + return result; + } + /** * Private constructor prevents instantiation * diff --git a/app/src/main/java/dev/ukanth/ufirewall/preferences/BootAdvancedPreferences.java b/app/src/main/java/dev/ukanth/ufirewall/preferences/BootAdvancedPreferences.java new file mode 100644 index 000000000..b1087c737 --- /dev/null +++ b/app/src/main/java/dev/ukanth/ufirewall/preferences/BootAdvancedPreferences.java @@ -0,0 +1,255 @@ +package dev.ukanth.ufirewall.preferences; + +import android.app.Activity; +import android.content.Context; +import android.preference.CheckBoxPreference; +import android.preference.ListPreference; +import android.preference.Preference; +import android.preference.PreferenceCategory; +import android.preference.PreferenceFragment; + +import java.util.ArrayList; +import java.util.List; + +import dev.ukanth.ufirewall.Api; +import dev.ukanth.ufirewall.R; +import dev.ukanth.ufirewall.log.Log; +import dev.ukanth.ufirewall.util.G; +import dev.ukanth.ufirewall.util.RootFiles; + +/** + * The "Boot" and "Advanced" settings of the Rules screen (formerly the Experimental screen): + * boot leak protection, the startup delay, multi-user and dual apps support. + *

+ * The Rules screen reloads its preferences after reading the chain policies, so {@link #bind()} + * has to run after every load. + */ +final class BootAdvancedPreferences { + + static final String KEY_FIX_LEAK = "fixLeak"; + static final String KEY_INIT_PATH = "initPath"; + static final String KEY_DELAY = "customDelay"; + static final String KEY_MULTI_USER = "multiUser"; + static final String KEY_DUAL_APPS = "supportDualApps"; + private static final String KEY_BOOT_CATEGORY = "bootCategory"; + + private static final String SCRIPT = "afwallstart"; + + /** + * Where root solutions run boot scripts, best first. The script is meant to run early + * (post-fs-data); the others are kept for older root solutions (SuperSU, old Magisk, init.d). + */ + private static final String[] INIT_DIRS = { + "/data/adb/post-fs-data.d/", + "/data/adb/service.d/", + "/data/adb/su/su.d", + "/sbin/supersu/su.d", + "/su/su.d/", + "/system/su.d/", + "/magisk/.core/service.d/", + "/sbin/.core/img/.core/service.d/", + "/sbin/.magisk/img/.core/service.d/", + "/magisk/phh/su.d/", + "/sbin/.core/img/phh/su.d/", + "/system/etc/init.d/", + "/etc/init.d/"}; + + // startup directories found on this device; null until detected (root check) + private static volatile List detectedDirs; + + private final PreferenceFragment fragment; + + BootAdvancedPreferences(PreferenceFragment fragment) { + this.fragment = fragment; + } + + void bind() { + bindDelay(); + bindDualApps(); + bindMultiUser(); + bindFixLeak(); + } + + // ---- startup delay: one slider, 0 = off ---- + + private void bindDelay() { + SeekBarPreference delay = (SeekBarPreference) fragment.findPreference(KEY_DELAY); + if (delay == null) { + return; + } + delay.setZeroText(fragment.getString(R.string.off)); + if (!G.startupDelay()) { + delay.setValue(0); + } + delay.setOnPreferenceChangeListener((preference, newValue) -> { + G.startupDelay((Integer) newValue > 0); + return true; + }); + } + + // ---- dual apps: defaults to on when the device has other profiles ---- + + private void bindDualApps() { + CheckBoxPreference dual = (CheckBoxPreference) fragment.findPreference(KEY_DUAL_APPS); + if (dual != null) { + dual.setChecked(G.supportDual()); + dual.setOnPreferenceChangeListener((preference, newValue) -> { + Api.applications = null; // the app list changes + return true; + }); + } + } + + private void bindMultiUser() { + CheckBoxPreference multiUser = (CheckBoxPreference) fragment.findPreference(KEY_MULTI_USER); + if (multiUser == null) { + return; + } + multiUser.setOnPreferenceChangeListener((preference, newValue) -> { + Context ctx = fragment.getActivity().getApplicationContext(); + if ((Boolean) newValue) { + if (!Api.supportsMultipleUsers(ctx)) { + return false; + } + Api.setUserOwner(ctx); + } + return true; + }); + } + + // ---- boot leak protection ---- + + private void bindFixLeak() { + CheckBoxPreference fixLeak = (CheckBoxPreference) fragment.findPreference(KEY_FIX_LEAK); + ListPreference initPath = (ListPreference) fragment.findPreference(KEY_INIT_PATH); + if (fixLeak == null || initPath == null) { + return; + } + fixLeak.setOnPreferenceChangeListener((preference, newValue) -> { + setFixLeak((Boolean) newValue); + return true; + }); + initPath.setOnPreferenceChangeListener((preference, newValue) -> { + changeInitPath(newValue.toString()); + return true; + }); + + if (detectedDirs != null) { + showDirs(detectedDirs); + } else { + fixLeak.setEnabled(false); // until we know whether there is a startup directory + new Thread(() -> { + List found = new ArrayList<>(); + for (String dir : INIT_DIRS) { + if (RootFiles.exists(dir)) { + found.add(dir); + } + } + detectedDirs = found; + runOnUi(() -> showDirs(found)); + }).start(); + } + refreshInstalledState(); + } + + /** + * The picker is only shown when there is a choice; with one directory it is used as is. + */ + private void showDirs(List dirs) { + CheckBoxPreference fixLeak = (CheckBoxPreference) fragment.findPreference(KEY_FIX_LEAK); + ListPreference initPath = (ListPreference) fragment.findPreference(KEY_INIT_PATH); + if (fixLeak == null || initPath == null) { + return; + } + String current = G.initPath(); + List choices = new ArrayList<>(dirs); + if (current != null && !current.isEmpty() && !choices.contains(current)) { + choices.add(0, current); // keep a path chosen earlier visible + } + fixLeak.setEnabled(!choices.isEmpty()); + if (choices.isEmpty()) { + fixLeak.setSummary(R.string.fixleak_unsupported); + } + if (choices.size() > 1) { + String[] entries = choices.toArray(new String[0]); + initPath.setEntries(entries); + initPath.setEntryValues(entries); + if (current != null && !current.isEmpty()) { + initPath.setValue(current); + } + } else { + PreferenceCategory boot = (PreferenceCategory) fragment.findPreference(KEY_BOOT_CATEGORY); + if (boot != null) { + boot.removePreference(initPath); + } + } + } + + private void setFixLeak(boolean enable) { + final Context ctx = fragment.getActivity().getApplicationContext(); + if (enable && (G.initPath() == null || G.initPath().isEmpty())) { + List dirs = detectedDirs; + if (dirs == null || dirs.isEmpty()) { + return; + } + G.initPath(dirs.get(0)); // best available + } + new Thread(() -> { + boolean ok = enable ? Api.installFixLeakScript(ctx, SCRIPT) : Api.removeFixLeakScript(ctx, SCRIPT); + Api.toast(ctx, ctx.getString(ok ? (enable ? R.string.success_initd : R.string.remove_initd) + : (enable ? R.string.mount_initd_error : R.string.delete_initd_error))); + refreshInstalledState(); + }).start(); + } + + private void changeInitPath(String newPath) { + final Context ctx = fragment.getActivity().getApplicationContext(); + String oldPath = G.initPath(); + if (newPath.equals(oldPath)) { + return; + } + if (!G.fixLeak()) { + G.initPath(newPath); + return; + } + // move the installed script; the list saves the new path right after this listener, so + // remember where the old script is now + final String oldScript = Api.getFixLeakPath(SCRIPT); + new Thread(() -> { + if (!Api.removeFixLeakScriptAt(ctx, oldScript)) { + Log.w(G.TAG, "Unable to remove the fix leak script from " + oldPath); + } + G.initPath(newPath); + boolean ok = Api.installFixLeakScript(ctx, SCRIPT); + Api.toast(ctx, ctx.getString(ok ? R.string.success_initd : R.string.mount_initd_error)); + refreshInstalledState(); + }).start(); + } + + /** + * Show whether the script is really installed (the setting alone can be stale). + */ + private void refreshInstalledState() { + new Thread(() -> { + String path = Api.getFixLeakPath(SCRIPT); + boolean installed = path != null && RootFiles.exists(path); + runOnUi(() -> { + CheckBoxPreference fixLeak = (CheckBoxPreference) fragment.findPreference(KEY_FIX_LEAK); + if (fixLeak != null && fixLeak.isChecked() != installed) { + fixLeak.setChecked(installed); + } + }); + }).start(); + } + + private void runOnUi(Runnable r) { + Activity activity = fragment.getActivity(); + if (activity != null) { + activity.runOnUiThread(() -> { + if (fragment.isAdded()) { + r.run(); + } + }); + } + } +} diff --git a/app/src/main/java/dev/ukanth/ufirewall/preferences/CustomThemePreferenceFragment.java b/app/src/main/java/dev/ukanth/ufirewall/preferences/CustomThemePreferenceFragment.java new file mode 100644 index 000000000..ac96c8132 --- /dev/null +++ b/app/src/main/java/dev/ukanth/ufirewall/preferences/CustomThemePreferenceFragment.java @@ -0,0 +1,141 @@ +package dev.ukanth.ufirewall.preferences; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.preference.Preference; +import android.preference.PreferenceFragment; +import android.widget.Toast; + +import dev.ukanth.ufirewall.Api; +import dev.ukanth.ufirewall.R; +import dev.ukanth.ufirewall.log.Log; +import dev.ukanth.ufirewall.util.G; + +public class CustomThemePreferenceFragment extends PreferenceFragment implements + SharedPreferences.OnSharedPreferenceChangeListener { + + private static final String KEY_CUSTOM_THEME_COLORS = "customThemeColors"; + private static final String[] COLOR_KEYS = { + "primaryColor", + "primaryDarkColor", + "accentColor", + "backgroundColor", + "textPrimaryColor", + "textSecondaryColor", + "userColor", + "defaultIconColor" + }; + + private Context ctx; + + @Override + public void onAttach(Context context) { + super.onAttach(context); + ctx = context; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (!ensureDonorAccess()) { + return; + } + if (!G.customThemeColorsEnabled()) { + G.seedCustomThemeColorsFromSelectedThemeIfNeeded(); + } + addPreferencesFromResource(R.xml.theme_custom_preference); + setupPreferences(); + } + + @Override + public void onResume() { + super.onResume(); + getPreferenceManager().getSharedPreferences() + .registerOnSharedPreferenceChangeListener(this); + } + + @Override + public void onPause() { + getPreferenceManager().getSharedPreferences() + .unregisterOnSharedPreferenceChangeListener(this); + super.onPause(); + } + + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { + if (ctx == null) ctx = getActivity(); + if (ctx != null && (KEY_CUSTOM_THEME_COLORS.equals(key) || isColorKey(key))) { + broadcastThemeRefresh(); + } + } + + private void setupPreferences() { + Preference customThemeColors = findPreference(KEY_CUSTOM_THEME_COLORS); + if (customThemeColors != null) { + customThemeColors.setOnPreferenceChangeListener((preference, newValue) -> { + if ((Boolean) newValue && !G.canUseDonorFeatures(resolveContext())) { + showDonorRequiredToast(); + return false; + } + if ((Boolean) newValue) { + G.seedCustomThemeColorsFromSelectedThemeIfNeeded(); + } + Log.i(G.TAG, "Custom theme colors " + ((Boolean) newValue ? "enabled" : "disabled")); + return true; + }); + } + + for (String key : COLOR_KEYS) { + Preference colorPreference = findPreference(key); + if (colorPreference != null) { + colorPreference.setOnPreferenceChangeListener((preference, newValue) -> { + if (!G.canUseDonorFeatures(resolveContext())) { + showDonorRequiredToast(); + return false; + } + Log.i(G.TAG, "Theme color updated: " + preference.getKey()); + return true; + }); + } + } + } + + private boolean ensureDonorAccess() { + if (!G.canUseDonorFeatures(resolveContext())) { + showDonorRequiredToast(); + if (getActivity() != null) { + getActivity().finish(); + } + return false; + } + return true; + } + + private boolean isColorKey(String key) { + for (String colorKey : COLOR_KEYS) { + if (colorKey.equals(key)) return true; + } + return false; + } + + private Context resolveContext() { + return ctx != null ? ctx : getActivity(); + } + + private void showDonorRequiredToast() { + Context context = resolveContext(); + if (context != null) { + Api.toast(context, context.getText(R.string.donate_only), Toast.LENGTH_LONG); + } + } + + private void broadcastThemeRefresh() { + Context context = resolveContext(); + if (context != null) { + Intent broadcastIntent = new Intent("dev.ukanth.ufirewall.theme.REFRESH"); + context.sendBroadcast(broadcastIntent); + } + } +} diff --git a/app/src/main/java/dev/ukanth/ufirewall/preferences/ExpPreferenceFragment.java b/app/src/main/java/dev/ukanth/ufirewall/preferences/ExpPreferenceFragment.java deleted file mode 100644 index f7e7e1706..000000000 --- a/app/src/main/java/dev/ukanth/ufirewall/preferences/ExpPreferenceFragment.java +++ /dev/null @@ -1,260 +0,0 @@ -package dev.ukanth.ufirewall.preferences; - -import static dev.ukanth.ufirewall.Api.getFixLeakPath; -import static dev.ukanth.ufirewall.Api.mountDir; - -import android.annotation.SuppressLint; -import android.app.Activity; -import android.content.Context; -import android.content.SharedPreferences; -import android.content.SharedPreferences.OnSharedPreferenceChangeListener; -import android.content.pm.PackageManager; -import android.content.pm.PackageManager.NameNotFoundException; -import android.os.Bundle; -import android.preference.CheckBoxPreference; -import android.preference.ListPreference; -import android.preference.Preference; -import android.preference.PreferenceFragment; - -import com.stericson.roottools.RootTools; -import com.topjohnwu.superuser.Shell; - -import java.io.File; -import java.util.ArrayList; -import java.util.List; - -import dev.ukanth.ufirewall.Api; -import dev.ukanth.ufirewall.R; -import dev.ukanth.ufirewall.service.RootCommand; -import dev.ukanth.ufirewall.util.G; - -public class ExpPreferenceFragment extends PreferenceFragment implements - OnSharedPreferenceChangeListener { - - private final String[] initDirs = { - "/magisk/.core/service.d/", - "/sbin/.core/img/.core/service.d/", - "/sbin/.magisk/img/.core/service.d/", - "/magisk/phh/su.d/", - "/data/adb/post-fs-data.d/", - "/data/adb/service.d/", - "/sbin/.core/img/phh/su.d/", - "/su/su.d/", - "/system/su.d/", - "/system/etc/init.d/", - "/etc/init.d/", - "/sbin/supersu/su.d", - "/data/adb/su/su.d"}; - - private final String initScript = "afwallstart"; - - @SuppressLint("NewApi") - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - // Load the preferences from an XML resource - addPreferencesFromResource(R.xml.experimental_preferences); - setupInitDir(findPreference("initPath")); - } - - @Override - public void onDestroy() { - super.onDestroy(); - } - - private void setupInitDir(Preference initd) { - ListPreference listPreference = (ListPreference) initd; - final Context ctx = getActivity().getApplicationContext(); - listPreference.setOnPreferenceChangeListener((preference, newValue) -> { - String selected = newValue.toString(); - // fix leak enabled - but user trying to change the path - if (!selected.equals(G.initPath()) && G.fixLeak()) { - deleteFiles(ctx, false); - G.initPath(selected); - updateFixLeakScript(true); - return true; - } - return true; - }); - - Activity activity = getActivity(); - new Thread(() -> { - List listSupportedDir = new ArrayList<>(); - //going through the list of known initDirectories - for (String dir : initDirs) { - //path exists - if (RootTools.exists(dir, true)) { - listSupportedDir.add(dir); - } - } - //some path exists - if (listSupportedDir.size() > 0) { - String[] entries = listSupportedDir.toArray(new String[0]); - activity.runOnUiThread(() -> { - listPreference.setEntries(entries); - listPreference.setEntryValues(entries); - }); - } - }).start(); - - if (G.initPath() != null && !G.initPath().isEmpty()) { - listPreference.setValue(G.initPath()); - } else { - CheckBoxPreference fixLeakPref = (CheckBoxPreference) findPreference("fixLeak"); - fixLeakPref.setEnabled(false); - } - setupFixLeak(findPreference("fixLeak"), this.getActivity().getApplicationContext()); - } - - @Override - public void onResume() { - super.onResume(); - getPreferenceManager().getSharedPreferences() - .registerOnSharedPreferenceChangeListener(this); - - } - - @Override - public void onPause() { - getPreferenceManager().getSharedPreferences() - .unregisterOnSharedPreferenceChangeListener(this); - super.onPause(); - } - - @Override - public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, - String key) { - if (key.equals("fixLeak")) { - boolean enabled = G.fixLeak(); - - Activity activity = getActivity(); - new Thread(() -> { - if (enabled != isFixLeakInstalled()) { - activity.runOnUiThread(() -> updateFixLeakScript(enabled)); - } - }).start(); - } - - if (key.equals("initPath")) { - if (G.initPath() != null) { - CheckBoxPreference fixPath = (CheckBoxPreference) findPreference("fixLeak"); - fixPath.setEnabled(true); - } - } - - if (key.equals("multiUser")) { - if (!Api.supportsMultipleUsers(this.getActivity().getApplicationContext())) { - CheckBoxPreference multiUserPref = (CheckBoxPreference) findPreference(key); - multiUserPref.setChecked(false); - } else { - Api.setUserOwner(this.getActivity().getApplicationContext()); - } - } - } - - public void setupFixLeak(Preference pref, Context ctx) { - if (pref == null) { - return; - } - CheckBoxPreference fixLeakPref = (CheckBoxPreference) pref; - - if (fixLeakPref.isEnabled()) { - // gray out the fixLeak preference if the ROM doesn't support init.d - updateLeakCheckbox(); - fixLeakPref.setEnabled(getFixLeakPath(initScript) != null && !isPackageInstalled("com.androguide.universal.init.d", ctx)); - } - } - - private boolean isPackageInstalled(String packagename, Context ctx) { - PackageManager pm = ctx.getPackageManager(); - try { - pm.getPackageInfo(packagename, PackageManager.GET_ACTIVITIES); - return true; - } catch (NameNotFoundException e) { - return false; - } - } - - /** - * Tests whether the fix leak script is installed. - * - * You should call this from an I/O thread, because current api level does not allow usage of futures. - * - * @return {@code true} if the fix leak script exists. - */ - private boolean isFixLeakInstalled() { - String path = getFixLeakPath(initScript); - return path != null && RootTools.exists(path); - } - - private void updateFixLeakScript(final boolean enabled) { - Activity activity = getActivity(); - if (activity != null && isAdded()) { - final Context ctx = activity.getApplicationContext(); - final String srcPath = new File(ctx.getDir("bin", 0), initScript) - .getAbsolutePath(); - new Thread(() -> { - String path = G.initPath(); - if (path != null) { - if (enabled) { - File f = new File(path); - boolean mountable = mountDir(ctx, getFixLeakPath(initScript), "RW"); - if (mountable) { - //make sure it's executable - Shell.Result result = Shell.cmd("chmod 755 " + f.getAbsolutePath()).exec(); - if(result.isSuccess() && RootTools.copyFile(srcPath, (f.getAbsolutePath() + "/" + initScript), - true, false)) { - Api.sendToastBroadcast(ctx, ctx.getString(R.string.success_initd)); - mountDir(ctx, getFixLeakPath(initScript), "RO"); - activity.runOnUiThread(() -> updateLeakCheckbox()); - } - } else { - Api.sendToastBroadcast(ctx, ctx.getString(R.string.mount_initd_error)); - } - } else { - deleteFiles(ctx, true); - } - } - }).start(); - } - } - - private void updateLeakCheckbox() { - Activity activity = getActivity(); - CheckBoxPreference fixLeakPref = (CheckBoxPreference) findPreference("fixLeak"); - new Thread(() -> { - boolean isFixLeakInstalled = isFixLeakInstalled(); - activity.runOnUiThread(() -> fixLeakPref.setChecked(isFixLeakInstalled)); - }).start(); - } - - - private void deleteFiles(final Context ctx, final boolean updateCheckbox) { - String path = G.initPath(); - if(path != null) { - new Thread(() -> { - if (RootTools.exists(path, true)) { - final String filePath = path + "/" + initScript; - boolean mountable = mountDir(ctx, getFixLeakPath(initScript), "RW"); - if (mountable) { - Shell.Result result = Shell.cmd("rm -f " + filePath).exec(); - if(result.isSuccess()){ - Api.sendToastBroadcast(ctx, ctx.getString(R.string.remove_initd)); - } else{ - Api.sendToastBroadcast(ctx, ctx.getString(R.string.delete_initd_error)); - } - if (updateCheckbox) { - getActivity().runOnUiThread(() -> updateLeakCheckbox()); - } - mountDir(ctx, getFixLeakPath(initScript), "RO"); - } else { - Api.sendToastBroadcast(ctx, ctx.getString(R.string.mount_initd_error)); - } - } - }).start(); - } else { - Api.sendToastBroadcast(ctx, ctx.getString(R.string.delete_initd_error)); - } - - } -} diff --git a/app/src/main/java/dev/ukanth/ufirewall/preferences/LogPreferenceFragment.java b/app/src/main/java/dev/ukanth/ufirewall/preferences/LogPreferenceFragment.java index e4a7eca46..9dbd61673 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/preferences/LogPreferenceFragment.java +++ b/app/src/main/java/dev/ukanth/ufirewall/preferences/LogPreferenceFragment.java @@ -1,15 +1,25 @@ package dev.ukanth.ufirewall.preferences; +import android.content.Context; +import android.content.Intent; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; import android.preference.CheckBoxPreference; import android.preference.ListPreference; import android.preference.Preference; import android.preference.PreferenceFragment; import android.preference.PreferenceGroup; +import android.widget.Toast; + +import com.afollestad.materialdialogs.DialogAction; +import com.afollestad.materialdialogs.MaterialDialog; import dev.ukanth.ufirewall.Api; import dev.ukanth.ufirewall.R; import dev.ukanth.ufirewall.log.Log; +import dev.ukanth.ufirewall.service.LogService; +import dev.ukanth.ufirewall.service.RootCommand; import dev.ukanth.ufirewall.util.G; public class LogPreferenceFragment extends PreferenceFragment { @@ -20,7 +30,6 @@ public void onCreate(Bundle savedInstanceState) { // Load the preferences from an XML resource try { //fix for the mess - //G.logPingTimeout(G.logPingTimeout()); addPreferencesFromResource(R.xml.log_preferences); // populateLogMessage(findPreference("logDmesg")); // populateAppList(findPreference("block_filter")); @@ -43,6 +52,24 @@ private void populateLogTarget(Preference logTarget) { if (listPreference != null) { listPreference.setEntries(items); listPreference.setEntryValues(items); + + // Add custom listener to intercept preference changes + listPreference.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + String newLogTarget = (String) newValue; + String oldLogTarget = G.logTarget(); + + // If it's the same target, allow change immediately + if (newLogTarget.equals(oldLogTarget)) { + return true; + } + + // Show confirmation dialog and prevent automatic change + showLogTargetChangeDialog(oldLogTarget, newLogTarget, listPreference); + return false; // Prevent automatic preference change + } + }); } //if there is only one entry if(items.length == 1) { @@ -78,7 +105,7 @@ private void setupLogHostname(Preference showHostName) { val.add("OS"); ListPreference listPreference = (ListPreference) logDmesg; - if (RootTools.isBusyboxAvailable() || !Api.getBusyBoxPath(ctx,false).isEmpty()) { + if (Api.findSystemBinary("busybox") != null || !Api.getBusyBoxPath(ctx,false).isEmpty()) { ar.add("Busybox"); val.add("BX"); } @@ -157,4 +184,71 @@ private Integer[] selectItems(ArrayList entryValuesList) { } return items.toArray(new Integer[0]); }*/ + + private void showLogTargetChangeDialog(String oldLogTarget, String newLogTarget, ListPreference listPreference) { + new MaterialDialog.Builder(getActivity()) + .title(R.string.log_target_change_title) + .content(getString(R.string.log_target_change_message, oldLogTarget, newLogTarget)) + .positiveText(R.string.Yes) + .negativeText(R.string.Cancel) + .onPositive(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(MaterialDialog dialog, DialogAction which) { + // Apply the log target change + applyLogTargetChange(newLogTarget, listPreference); + } + }) + .onNegative(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(MaterialDialog dialog, DialogAction which) { + // Do nothing - preference change was already prevented by returning false + Log.d("LogPreferenceFragment", "Log target change cancelled by user"); + } + }) + .show(); + } + + private void applyLogTargetChange(String newLogTarget, ListPreference listPreference) { + Context ctx = getActivity(); + + // Set the new log target in preferences + G.logTarget(newLogTarget); + + // Update the ListPreference to show the new value + listPreference.setValue(newLogTarget); + + // Update log rules + Api.updateLogRules(ctx, new RootCommand() + .setReopenShell(true) + .setSuccessToast(R.string.log_target_success) + .setFailureToast(R.string.log_target_fail)); + + // Change log target without restarting service + changeLogTargetInService(ctx, newLogTarget); + } + + /** + * Change log target in the running service without restarting it + */ + private void changeLogTargetInService(Context ctx, String newLogTarget) { + Log.i("LogPreferenceFragment", "Changing log target to: " + newLogTarget); + + if (G.enableLogService()) { + // Send log target change request to the running service + Intent changeIntent = new Intent(ctx, LogService.class); + changeIntent.setAction(LogService.ACTION_CHANGE_LOG_TARGET); + changeIntent.putExtra(LogService.EXTRA_NEW_LOG_TARGET, newLogTarget); + ctx.startService(changeIntent); + + // Show success message after a delay to allow the change to process + Handler handler = new Handler(Looper.getMainLooper()); + handler.postDelayed(() -> { + Toast.makeText(ctx, getString(R.string.log_target_changed_success, newLogTarget), Toast.LENGTH_LONG).show(); + }, 2000); + } else { + // Service is not running, just update the preference + Log.i("LogPreferenceFragment", "Log service disabled, only updating preference"); + Toast.makeText(ctx, getString(R.string.log_target_changed_success, newLogTarget), Toast.LENGTH_SHORT).show(); + } + } } diff --git a/app/src/main/java/dev/ukanth/ufirewall/preferences/MultiProfilePreferenceFragment.java b/app/src/main/java/dev/ukanth/ufirewall/preferences/MultiProfilePreferenceFragment.java index 1c7e9532c..365984027 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/preferences/MultiProfilePreferenceFragment.java +++ b/app/src/main/java/dev/ukanth/ufirewall/preferences/MultiProfilePreferenceFragment.java @@ -1,10 +1,8 @@ package dev.ukanth.ufirewall.preferences; -import android.content.Context; import android.content.Intent; import android.os.Bundle; import android.preference.Preference; -import android.preference.PreferenceCategory; import android.preference.PreferenceFragment; import java.io.File; @@ -13,11 +11,8 @@ import java.io.IOException; import java.nio.channels.FileChannel; -import dev.ukanth.ufirewall.Api; import dev.ukanth.ufirewall.R; import dev.ukanth.ufirewall.activity.ProfileActivity; -import dev.ukanth.ufirewall.profiles.ProfileHelper; -import dev.ukanth.ufirewall.util.G; public class MultiProfilePreferenceFragment extends PreferenceFragment { @Override @@ -31,41 +26,6 @@ public void onCreate(Bundle savedInstanceState) { startActivity(new Intent(getActivity(), ProfileActivity.class)); return true; }); - - final PreferenceCategory mCategory = (PreferenceCategory) findPreference("promigrate"); - final PreferenceCategory mCategory2 = (PreferenceCategory) findPreference("oldprofile_pref"); - final Preference migrate = findPreference("migrate_profile"); - if (!G.isProfileMigrated()) { - migrate.setOnPreferenceClickListener(preference -> { - Context ctx = getActivity(); - ProfileHelper.migrateProfiles(ctx); - if (ctx != null) { - Api.toast(getActivity(), ctx.getString(R.string.profile_migrate_msg)); - mCategory.removePreference(migrate); - - Preference migrate1 = findPreference("profile1"); - mCategory2.removePreference(migrate1); - - migrate1 = findPreference("profile2"); - mCategory2.removePreference(migrate1); - - migrate1 = findPreference("profile3"); - mCategory2.removePreference(migrate1); - } - return true; - }); - } else { - mCategory.removePreference(migrate); - - Preference migrate2 = findPreference("profile1"); - mCategory2.removePreference(migrate2); - - migrate2 = findPreference("profile2"); - mCategory2.removePreference(migrate2); - - migrate2 = findPreference("profile3"); - mCategory2.removePreference(migrate2); - } } public void copy(File src, File dst) throws IOException { diff --git a/app/src/main/java/dev/ukanth/ufirewall/preferences/PreferencesActivity.java b/app/src/main/java/dev/ukanth/ufirewall/preferences/PreferencesActivity.java index 46bca172f..ca8be9b06 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/preferences/PreferencesActivity.java +++ b/app/src/main/java/dev/ukanth/ufirewall/preferences/PreferencesActivity.java @@ -23,7 +23,6 @@ package dev.ukanth.ufirewall.preferences; -import android.app.NotificationManager; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; @@ -58,32 +57,23 @@ import dev.ukanth.ufirewall.service.LogService; import dev.ukanth.ufirewall.service.RootCommand; import dev.ukanth.ufirewall.util.G; +import dev.ukanth.ufirewall.util.Notifications; import dev.ukanth.ufirewall.util.SecurityUtil; +import dev.ukanth.ufirewall.util.ThemeHelper; import io.reactivex.rxjava3.disposables.Disposable; public class PreferencesActivity extends PreferenceActivity implements SharedPreferences.OnSharedPreferenceChangeListener { private static final boolean ALWAYS_SIMPLE_PREFS = false; + public static final String EXTRA_TOOLBAR_TITLE = "dev.ukanth.ufirewall.extra.TOOLBAR_TITLE"; + public static final String EXTRA_FINISH_ON_BACK = "dev.ukanth.ufirewall.extra.FINISH_ON_BACK"; private Toolbar mToolBar; private RxEvent rxEvent; private Disposable disposable; - - - private void initTheme() { - switch(G.getSelectedTheme()) { - case "D": - setTheme(R.style.AppDarkTheme); - break; - case "L": - setTheme(R.style.AppLightTheme); - break; - case "B": - setTheme(R.style.AppBlackTheme); - break; - } + ThemeHelper.applyTheme(this); } /** * Helper method to determine if the device has an extra-large screen. For @@ -111,11 +101,14 @@ protected void onCreate(Bundle savedInstanceState) { // set language Api.updateLanguage(getApplicationContext(), G.locale()); initTheme(); - super.onCreate(savedInstanceState); + prepareLayout(); subscribe(); + handleIntentExtras(); + } + private void handleIntentExtras() { Bundle bundle = getIntent().getExtras(); if (bundle != null) { Object data = bundle.get("validate"); @@ -176,15 +169,28 @@ private void prepareLayout() { root.addView(toolbarContainer); mToolBar = toolbarContainer.findViewById(R.id.toolbar); - mToolBar.setTitle(getTitle() + " " + getString(R.string.preferences)); + mToolBar.setTitle(getToolbarTitle()); + mToolBar.setNavigationIcon(androidx.appcompat.content.res.AppCompatResources.getDrawable(this, + androidx.appcompat.R.drawable.abc_ic_ab_back_material)); mToolBar.setNavigationOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { finish(); } }); + ThemeHelper.apply(this); } + private CharSequence getToolbarTitle() { + Intent intent = getIntent(); + if (intent != null) { + String toolbarTitle = intent.getStringExtra(EXTRA_TOOLBAR_TITLE); + if (toolbarTitle != null && toolbarTitle.length() > 0) { + return toolbarTitle; + } + } + return getTitle() + " " + getString(R.string.preferences); + } @Override protected void onApplyThemeResource(Resources.Theme theme, int resid, boolean first) { @@ -248,13 +254,17 @@ public boolean onOptionsItemSelected(MenuItem item) { @Override protected boolean isValidFragment(String fragmentName) { + // Prevent fragment injection attacks by explicitly allowing only known safe fragments + if (fragmentName == null) { + return false; + } + return UIPreferenceFragment.class.getName().equals(fragmentName) || ThemePreferenceFragment.class.getName().equals(fragmentName) + || CustomThemePreferenceFragment.class.getName().equals(fragmentName) || RulesPreferenceFragment.class.getName().equals(fragmentName) || LogPreferenceFragment.class.getName().equals(fragmentName) - || ExpPreferenceFragment.class.getName().equals(fragmentName) - || CustomBinaryPreferenceFragment.class.getName().equals( - fragmentName) + || CustomBinaryPreferenceFragment.class.getName().equals(fragmentName) || SecPreferenceFragment.class.getName().equals(fragmentName) || MultiProfilePreferenceFragment.class.getName().equals(fragmentName) || WidgetPreferenceFragment.class.getName().equals(fragmentName) @@ -301,68 +311,96 @@ public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, Strin Context ctx = getApplicationContext(); boolean isRefreshRequired = false; - if (key.equals("showUid") || key.equals("disableIcons") || key.equals("enableVPN") + if (key.equals("showUid") || key.equals("showPackageName") || key.equals("disableIcons") || key.equals("enableVPN") || key.equals("enableTether") || key.equals("enableLAN") || key.equals("enableRoam") - || key.equals("locale") || key.equals("showFilter")) { + || key.equals("locale") || key.equals("showFilter") + || key.equals("enableCustomRules") + || isThemeColorKey(key)) { G.reloadProfile(); isRefreshRequired = true; } + if (key.equals("enableCustomRules")) { + // the direct rules are applied (or not) with the next full apply + dev.ukanth.ufirewall.MainActivity.requireFullApply(); + Api.setRulesUpToDate(false); + } + if (key.equals("ipt_path") || key.equals("dns_value")) { rxEvent.publish(new RulesEvent("", ctx)); } + handleNotificationChanges(sharedPreferences, key, ctx); + handleLogServiceChanges(sharedPreferences, key, ctx); + handleProfileAndThemeChanges(key, ctx, isRefreshRequired); + /*if (key.equals("logDmesg")) { rxEvent.publish(new LogChangeEvent("", ctx)); }*/ + } - if (key.equals("notification_priority")) { - NotificationManager notificationManager = (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE); - notificationManager.cancelAll(); - Api.updateNotification(Api.isEnabled(ctx), ctx); - } - - if(key.equals("activeNotification")) { - boolean enabled = sharedPreferences.getBoolean(key, false); - if(!enabled) { - NotificationManager notificationManager = (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE); - notificationManager.cancelAll(); + private void handleNotificationChanges(SharedPreferences prefs, String key, Context ctx) { + // (Android 6/7 only) show or hide the status notification + if (key.equals("activeNotification")) { + boolean enabled = prefs.getBoolean(key, false); + if (!enabled) { + Notifications.cancel(ctx, Notifications.ID_STATUS); } else { Api.updateNotification(Api.isEnabled(ctx), ctx); } } + if (key.equals("notifyBlocked") && !prefs.getBoolean(key, true)) { + Notifications.clearBlocked(); + } + } + private void handleLogServiceChanges(SharedPreferences prefs, String key, Context ctx) { if(key.equals("logTarget")) { - Api.updateLogRules(ctx, new RootCommand() - .setReopenShell(true) - .setSuccessToast(R.string.log_target_success) - .setFailureToast(R.string.log_target_fail)); - Intent intent = new Intent(ctx, LogService.class); - ctx.stopService(intent); - ctx.startService(intent); + // Log target changes are now handled by LogPreferenceFragment + // This should not be called anymore due to the OnPreferenceChangeListener + Log.d("PreferencesActivity", "logTarget preference changed: " + prefs.getString(key, "")); } + if (key.equals("enableLogService")) { if(G.logTarget() !=null && !G.logTarget().trim().isEmpty()) { - boolean enabled = sharedPreferences.getBoolean(key, false); + boolean enabled = prefs.getBoolean(key, false); if (enabled) { Toast.makeText(getApplicationContext(), getString(R.string.log_service_start), Toast.LENGTH_LONG).show(); - Intent intent = new Intent(ctx, LogService.class); - ctx.stopService(intent); - ctx.startService(intent); + Api.updateLogRules(ctx, new RootCommand().setCallback(new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + LogService.ensureRunning(ctx); + } + })); } else { Toast.makeText(getApplicationContext(), getString(R.string.log_service_stop), Toast.LENGTH_LONG).show(); - Intent intent = new Intent(ctx, LogService.class); - ctx.stopService(intent); + ctx.stopService(new Intent(ctx, LogService.class)); } } else{ Toast.makeText(getApplicationContext(), getString(R.string.log_service_select), Toast.LENGTH_LONG).show(); } } + } + + private boolean isThemeColorKey(String key) { + return key.equals("sysColor") + || key.equals("primaryColor") + || key.equals("primaryDarkColor") + || key.equals("accentColor") + || key.equals("backgroundColor") + || key.equals("textPrimaryColor") + || key.equals("textSecondaryColor") + || key.equals("userColor") + || key.equals("defaultIconColor"); + } + + private void handleProfileAndThemeChanges(String key, Context ctx, boolean isRefreshRequired) { if (key.equals("enableMultiProfile")) { G.reloadProfile(); } - if (key.equals("theme")) { + + if (key.equals("theme") || key.equals("customThemeColors") || isThemeColorKey(key)) { initTheme(); recreate(); Intent broadcastIntent = new Intent(); @@ -377,7 +415,6 @@ public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, Strin } } - @Override public void onDestroy() { if (rxEvent != null && disposable != null) { @@ -391,5 +428,4 @@ protected void attachBaseContext(Context base) { super.attachBaseContext(Api.updateBaseContextLocale(base)); } - } diff --git a/app/src/main/java/dev/ukanth/ufirewall/preferences/RulesPreferenceFragment.java b/app/src/main/java/dev/ukanth/ufirewall/preferences/RulesPreferenceFragment.java index caf861836..1db1e30a6 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/preferences/RulesPreferenceFragment.java +++ b/app/src/main/java/dev/ukanth/ufirewall/preferences/RulesPreferenceFragment.java @@ -23,6 +23,7 @@ public class RulesPreferenceFragment extends PreferenceFragment implements SharedPreferences.OnSharedPreferenceChangeListener { private Context ctx; + private final BootAdvancedPreferences bootAdvanced = new BootAdvancedPreferences(this); @Override @@ -30,6 +31,7 @@ public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // Load the preferences from an XML resource addPreferencesFromResource(R.xml.rules_preferences); + bootAdvanced.bind(); try { updateRuleStatus(); @@ -118,8 +120,17 @@ public void cbFunc(RootCommand state) { } } } - getPreferenceScreen().removeAll(); - addPreferencesFromResource(R.xml.rules_preferences); + // callback runs on the root shell thread; rebuild the screen on the UI thread + android.app.Activity activity = getActivity(); + if (activity != null) { + activity.runOnUiThread(() -> { + if (isAdded()) { + getPreferenceScreen().removeAll(); + addPreferencesFromResource(R.xml.rules_preferences); + bootAdvanced.bind(); + } + }); + } } } })); diff --git a/app/src/main/java/dev/ukanth/ufirewall/preferences/SecPreferenceFragment.java b/app/src/main/java/dev/ukanth/ufirewall/preferences/SecPreferenceFragment.java index 5049aba59..93d6ae046 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/preferences/SecPreferenceFragment.java +++ b/app/src/main/java/dev/ukanth/ufirewall/preferences/SecPreferenceFragment.java @@ -61,8 +61,6 @@ public class SecPreferenceFragment extends PreferenceFragment implements private ComponentName deviceAdmin; private DevicePolicyManager mDPM; - private Context globalContext = null; - //private String passOption = "p0"; public void setupEnableAdmin(Preference pref) { @@ -84,8 +82,6 @@ public void onCreate(Bundle savedInstanceState) { .getApplicationContext(), AdminDeviceReceiver.class); super.onCreate(savedInstanceState); - globalContext = this.getActivity(); - // Load the preferences from an XML resource addPreferencesFromResource(R.xml.security_preferences); @@ -118,8 +114,9 @@ private void setupDeviceSecurityCheck(Preference pref) { if (Build.VERSION.SDK_INT >= 21) { //only for donate version if ((G.isDoKey(getActivity()) || G.isDonate())) { - if (globalContext != null) { - KeyguardManager keyguardManager = (KeyguardManager) globalContext.getSystemService(KEYGUARD_SERVICE); + Context context = getActivity(); + if (context != null) { + KeyguardManager keyguardManager = (KeyguardManager) context.getSystemService(KEYGUARD_SERVICE); //enable only when keyguard has set if (keyguardManager.isKeyguardSecure()) { enableDeviceCheckPref.setEnabled(true); @@ -318,11 +315,15 @@ public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, @TargetApi(Build.VERSION_CODES.M) private boolean canUserFingerPrint() { try { - KeyguardManager keyguardManager = (KeyguardManager) globalContext.getSystemService(KEYGUARD_SERVICE); - FingerprintManager fingerprintManager = (FingerprintManager) globalContext.getSystemService(FINGERPRINT_SERVICE); + Context context = getActivity(); + if (context == null) { + return false; + } + KeyguardManager keyguardManager = (KeyguardManager) context.getSystemService(KEYGUARD_SERVICE); + FingerprintManager fingerprintManager = (FingerprintManager) context.getSystemService(FINGERPRINT_SERVICE); return fingerprintManager.isHardwareDetected() && - ActivityCompat.checkSelfPermission(globalContext, Manifest.permission.USE_FINGERPRINT) == PackageManager.PERMISSION_GRANTED && + ActivityCompat.checkSelfPermission(context, Manifest.permission.USE_FINGERPRINT) == PackageManager.PERMISSION_GRANTED && fingerprintManager.hasEnrolledFingerprints() && keyguardManager.isKeyguardSecure(); } catch (Exception e) { @@ -334,28 +335,32 @@ private boolean canUserFingerPrint() { @TargetApi(Build.VERSION_CODES.M) private void checkFingerprintDeviceSupport() { // Initializing both Android Keyguard Manager and Fingerprint Manager - KeyguardManager keyguardManager = (KeyguardManager) globalContext.getSystemService(KEYGUARD_SERVICE); - FingerprintManager fingerprintManager = (FingerprintManager) globalContext.getSystemService(FINGERPRINT_SERVICE); + Context context = getActivity(); + if (context == null) { + return; + } + KeyguardManager keyguardManager = (KeyguardManager) context.getSystemService(KEYGUARD_SERVICE); + FingerprintManager fingerprintManager = (FingerprintManager) context.getSystemService(FINGERPRINT_SERVICE); ListPreference itemList = (ListPreference) findPreference("passSetting"); // Check whether the device has a Fingerprint sensor. if (!fingerprintManager.isHardwareDetected()) { - Api.toast(globalContext, getString(R.string.device_with_no_fingerprint_sensor)); + Api.toast(context, getString(R.string.device_with_no_fingerprint_sensor)); itemList.setValueIndex(0); } else { // Checks whether fingerprint permission is set on manifest - if (ActivityCompat.checkSelfPermission(globalContext, Manifest.permission.USE_FINGERPRINT) != PackageManager.PERMISSION_GRANTED) { - Api.toast(globalContext, getString(R.string.fingerprint_permission_manifest_missing)); + if (ActivityCompat.checkSelfPermission(context, Manifest.permission.USE_FINGERPRINT) != PackageManager.PERMISSION_GRANTED) { + Api.toast(context, getString(R.string.fingerprint_permission_manifest_missing)); itemList.setValueIndex(0); } else { // Check whether at least one fingerprint is registered if (!fingerprintManager.hasEnrolledFingerprints()) { - Api.toast(globalContext, getString(R.string.register_at_least_one_fingerprint)); + Api.toast(context, getString(R.string.register_at_least_one_fingerprint)); itemList.setValueIndex(0); } else { // Checks whether lock screen security is enabled or not if (!keyguardManager.isKeyguardSecure()) { - Api.toast(globalContext, getString(R.string.lock_screen_not_enabled)); + Api.toast(context, getString(R.string.lock_screen_not_enabled)); itemList.setValueIndex(0); } else { // Anything is ok @@ -363,7 +368,7 @@ private void checkFingerprintDeviceSupport() { G.isFingerprintEnabled(true); //make sure we set the index itemList.setValueIndex(3); - Api.toast(globalContext, getString(R.string.fingerprint_enabled_successfully)); + Api.toast(context, getString(R.string.fingerprint_enabled_successfully)); } return; } @@ -430,16 +435,22 @@ public void onInput(MaterialDialog dialog, CharSequence input) { final FingerprintUtil.FingerprintDialog dialog; if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { - dialog = new FingerprintUtil.FingerprintDialog(globalContext); - dialog.setOnFingerprintFailureListener(() -> { - itemList.setValueIndex(3); - dialog.dismiss(); - }); - dialog.setOnFingerprintSuccess(() -> { - G.isFingerprintEnabled(false); - Api.toast(globalContext, getString(R.string.fingerprint_disabled_successfully)); - }); - dialog.show(); + Context context = getActivity(); + if (context != null) { + dialog = new FingerprintUtil.FingerprintDialog(context); + dialog.setOnFingerprintFailureListener(() -> { + itemList.setValueIndex(3); + dialog.dismiss(); + }); + dialog.setOnFingerprintUnavailable(() -> { + itemList.setValueIndex(0); + }); + dialog.setOnFingerprintSuccess(() -> { + G.isFingerprintEnabled(false); + Api.toast(context, getString(R.string.fingerprint_disabled_successfully)); + }); + dialog.show(); + } } } @@ -460,55 +471,72 @@ public void onPause() { super.onPause(); } + @Override + public void onDestroy() { + super.onDestroy(); + enableAdminPref = null; + enableDeviceCheckPref = null; + } + @Override public void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); + if (!isAdded()) return; setupEnableAdmin(findPreference("enableAdmin")); switch (requestCode) { - case REQ_CREATE_PATTERN: { - ListPreference itemList = (ListPreference) findPreference("passSetting"); - if (resultCode == getActivity().RESULT_OK) { - char[] pattern = data.getCharArrayExtra( - EXTRA_PATTERN); - final SharedPreferences.Editor editor = G.sPrefs.edit(); - editor.putString("LockPassword", new String(pattern)); - editor.commit(); - G.enableDeviceCheck(false); - //enable - if (itemList != null) { - final ListPreference patternMaxTry = (ListPreference) findPreference("patternMax"); - final CheckBoxPreference stealthMode = (CheckBoxPreference) findPreference("stealthMode"); - if (stealthMode != null) stealthMode.setEnabled(true); - if (patternMaxTry != null) patternMaxTry.setEnabled(true); - } - - } else { - itemList = (ListPreference) findPreference("passSetting"); - if (itemList != null) { - itemList.setValueIndex(0); - } - } + handleCreatePatternResult(resultCode, data); break; } - case REQ_ENTER_PATTERN: { - ListPreference itemList = (ListPreference) findPreference("passSetting"); - if (resultCode == getActivity().RESULT_OK) { - final SharedPreferences.Editor editor = G.sPrefs.edit(); - editor.putString("LockPassword", ""); - editor.commit(); - itemList = (ListPreference) findPreference("passSetting"); - if (itemList != null) { - itemList.setValueIndex(0); - } - } else { - if (itemList != null) { - itemList.setValueIndex(2); - G.enableDeviceCheck(false); - } - } + handleEnterPatternResult(resultCode); + } + } + } + + private void handleCreatePatternResult(int resultCode, Intent data) { + ListPreference itemList = (ListPreference) findPreference("passSetting"); + if (resultCode == getActivity().RESULT_OK && data != null) { + char[] pattern = data.getCharArrayExtra(EXTRA_PATTERN); + if (pattern != null) { + savePattern(new String(pattern)); + enablePatternFeatures(); + } + } else { + resetPatternSelection(itemList); + } + } + + private void handleEnterPatternResult(int resultCode) { + ListPreference itemList = (ListPreference) findPreference("passSetting"); + if (resultCode == getActivity().RESULT_OK) { + G.sPrefs.edit().putString("LockPassword", "").apply(); + if (itemList != null) { + itemList.setValueIndex(0); + } + } else { + if (itemList != null) { + itemList.setValueIndex(2); + G.enableDeviceCheck(false); } } } + + private void savePattern(String pattern) { + G.sPrefs.edit().putString("LockPassword", pattern).apply(); + G.enableDeviceCheck(false); + } + + private void enablePatternFeatures() { + final ListPreference patternMaxTry = (ListPreference) findPreference("patternMax"); + final CheckBoxPreference stealthMode = (CheckBoxPreference) findPreference("stealthMode"); + if (stealthMode != null) stealthMode.setEnabled(true); + if (patternMaxTry != null) patternMaxTry.setEnabled(true); + } + + private void resetPatternSelection(ListPreference itemList) { + if (itemList != null) { + itemList.setValueIndex(0); + } + } } diff --git a/app/src/main/java/dev/ukanth/ufirewall/preferences/SeekBarPreference.java b/app/src/main/java/dev/ukanth/ufirewall/preferences/SeekBarPreference.java new file mode 100644 index 000000000..694ab8f31 --- /dev/null +++ b/app/src/main/java/dev/ukanth/ufirewall/preferences/SeekBarPreference.java @@ -0,0 +1,194 @@ +package dev.ukanth.ufirewall.preferences; + +import android.content.Context; +import android.content.res.TypedArray; +import android.preference.Preference; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.widget.SeekBar; +import android.widget.TextView; + +import dev.ukanth.ufirewall.R; + +/** + * Custom SeekBar preference for selecting numeric values with a slider + */ +public class SeekBarPreference extends Preference implements SeekBar.OnSeekBarChangeListener { + + private static final int DEFAULT_MIN = 0; + private static final int DEFAULT_MAX = 100; + private static final int DEFAULT_VALUE = 0; + + private int mMax; + private int mMin; + private int mValue; + private String mSuffix; + private String mZeroText; + private TextView mValueText; + private SeekBar mSeekBar; + + public SeekBarPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context, attrs); + } + + public SeekBarPreference(Context context, AttributeSet attrs) { + super(context, attrs); + init(context, attrs); + } + + public SeekBarPreference(Context context) { + super(context); + init(context, null); + } + + private void init(Context context, AttributeSet attrs) { + setLayoutResource(R.layout.preference_seekbar); + + if (attrs != null) { + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.SeekBarPreference); + mMax = a.getInt(R.styleable.SeekBarPreference_android_max, DEFAULT_MAX); + mMin = a.getInt(R.styleable.SeekBarPreference_min, DEFAULT_MIN); + mSuffix = a.getString(R.styleable.SeekBarPreference_suffix); + a.recycle(); + } else { + mMax = DEFAULT_MAX; + mMin = DEFAULT_MIN; + mSuffix = ""; + } + } + + @Override + protected View onCreateView(ViewGroup parent) { + View view = super.onCreateView(parent); + return view; + } + + @Override + protected void onBindView(View view) { + super.onBindView(view); + + mSeekBar = view.findViewById(R.id.seekbar); + mValueText = view.findViewById(R.id.seekbar_value); + + if (mSeekBar != null) { + mSeekBar.setMax(mMax - mMin); + mSeekBar.setProgress(mValue - mMin); + mSeekBar.setOnSeekBarChangeListener(this); + } + + updateValueText(); + } + + @Override + protected Object onGetDefaultValue(TypedArray a, int index) { + return a.getInt(index, DEFAULT_VALUE); + } + + @Override + protected void onSetInitialValue(boolean restoreValue, Object defaultValue) { + if (restoreValue) { + try { + mValue = getPersistedInt(DEFAULT_VALUE); + } catch (ClassCastException e) { + // Handle migration from EditTextPreference (String) to SeekBarPreference (Integer) + String stringValue = getSharedPreferences().getString(getKey(), String.valueOf(DEFAULT_VALUE)); + try { + mValue = Integer.parseInt(stringValue); + // Remove old String value and migrate to integer storage + getSharedPreferences().edit().remove(getKey()).commit(); + getSharedPreferences().edit().putInt(getKey(), mValue).commit(); + } catch (NumberFormatException nfe) { + mValue = DEFAULT_VALUE; + getSharedPreferences().edit().remove(getKey()).commit(); + getSharedPreferences().edit().putInt(getKey(), mValue).commit(); + } + } + } else { + mValue = (Integer) defaultValue; + persistInt(mValue); + } + } + + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + if (fromUser) { + int newValue = progress + mMin; + if (callChangeListener(newValue)) { + setValue(newValue); + } + } + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + // Not needed + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + persistInt(mValue); + } + + public void setValue(int value) { + if (value < mMin) { + value = mMin; + } + if (value > mMax) { + value = mMax; + } + + if (value != mValue) { + mValue = value; + persistInt(value); + updateValueText(); + if (mSeekBar != null) { + mSeekBar.setProgress(value - mMin); + } + } + } + + public int getValue() { + return mValue; + } + + private void updateValueText() { + if (mValueText != null) { + if (mValue == 0 && mZeroText != null) { + mValueText.setText(mZeroText); + return; + } + // suffixes are written with or without a leading space (" s"): show exactly one + String suffix = mSuffix != null && !mSuffix.trim().isEmpty() ? " " + mSuffix.trim() : ""; + mValueText.setText(String.valueOf(mValue) + suffix); + } + } + + /** + * @param zeroText shown instead of "0" (e.g. "Off" when 0 turns the setting off); null for "0" + */ + public void setZeroText(String zeroText) { + mZeroText = zeroText; + updateValueText(); + } + + public void setMax(int max) { + mMax = max; + if (mSeekBar != null) { + mSeekBar.setMax(max - mMin); + } + } + + public void setMin(int min) { + mMin = min; + if (mSeekBar != null) { + mSeekBar.setMax(mMax - min); + } + } + + public void setSuffix(String suffix) { + mSuffix = suffix; + updateValueText(); + } +} diff --git a/app/src/main/java/dev/ukanth/ufirewall/preferences/ThemePreferenceFragment.java b/app/src/main/java/dev/ukanth/ufirewall/preferences/ThemePreferenceFragment.java index 5d64a139d..1a3544f6c 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/preferences/ThemePreferenceFragment.java +++ b/app/src/main/java/dev/ukanth/ufirewall/preferences/ThemePreferenceFragment.java @@ -1,27 +1,27 @@ package dev.ukanth.ufirewall.preferences; -import static dev.ukanth.ufirewall.util.G.isDonate; - import android.content.Context; +import android.content.Intent; import android.content.SharedPreferences; import android.os.Bundle; +import android.preference.ListPreference; +import android.preference.Preference; +import android.preference.PreferenceActivity; import android.preference.PreferenceFragment; import android.widget.Toast; import dev.ukanth.ufirewall.Api; import dev.ukanth.ufirewall.R; +import dev.ukanth.ufirewall.log.Log; import dev.ukanth.ufirewall.util.G; public class ThemePreferenceFragment extends PreferenceFragment implements SharedPreferences.OnSharedPreferenceChangeListener { - private Context ctx; - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - // Load the preferences from an XML resource - addPreferencesFromResource(R.xml.theme_preference); - } + private static final String KEY_THEME = "theme"; + private static final String KEY_CUSTOM_THEME_COLORS_SCREEN = "customThemeColorsScreen"; + + private Context ctx; @Override public void onAttach(Context context) { @@ -29,13 +29,20 @@ public void onAttach(Context context) { ctx = context; } + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + addPreferencesFromResource(R.xml.theme_preference); + enforceDonorThemeAccess(); + setupThemePreference(); + setupCustomThemePreferences(); + } @Override public void onResume() { super.onResume(); getPreferenceManager().getSharedPreferences() .registerOnSharedPreferenceChangeListener(this); - } @Override @@ -45,37 +52,82 @@ public void onPause() { super.onPause(); } - @Override - public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, - String key) { - if (ctx == null) { - ctx = getActivity(); - } - if (ctx != null) { - if (key.equals("theme")) { - switch (G.getSelectedTheme()){ - case "D": - G.getInstance().setTheme(R.style.AppDarkTheme); - break; - case "L": - if ((G.isDoKey(ctx) || isDonate())) { - G.getInstance().setTheme(R.style.AppLightTheme); - } else { - Api.toast(ctx, ctx.getText(R.string.donate_only), Toast.LENGTH_LONG); - G.getSelectedTheme("D"); - } - break; - case "B": - if ((G.isDoKey(ctx) || isDonate())) { - G.getInstance().setTheme(R.style.AppBlackTheme); - } else { - Api.toast(ctx, ctx.getText(R.string.donate_only), Toast.LENGTH_LONG); - G.getSelectedTheme("D"); - } - break; - } + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { + if (ctx == null) ctx = getActivity(); + // PreferencesActivity handles recreate + broadcast; nothing needed here + } + + private void setupThemePreference() { + ListPreference themePreference = (ListPreference) findPreference(KEY_THEME); + if (themePreference == null) return; + + themePreference.setOnPreferenceChangeListener((preference, newValue) -> { + String theme = String.valueOf(newValue); + if (!G.isThemeAvailable(theme, resolveContext())) { + showDonorRequiredToast(); + return false; } + G.customThemeColorsEnabled(false); + String savedTheme = G.getSelectedTheme(theme); + themePreference.setValue(savedTheme); + themePreference.setSummary(themePreference.getEntry()); + Log.i(G.TAG, "Theme changed to " + savedTheme); + broadcastThemeRefresh(); + return false; + }); + } + + private void setupCustomThemePreferences() { + Preference customScreen = findPreference(KEY_CUSTOM_THEME_COLORS_SCREEN); + if (customScreen == null) return; + + customScreen.setOnPreferenceClickListener(preference -> { + if (!G.canUseDonorFeatures(resolveContext())) { + showDonorRequiredToast(); + return true; + } + Intent intent = new Intent(getActivity(), PreferencesActivity.class); + intent.putExtra(PreferenceActivity.EXTRA_SHOW_FRAGMENT, + CustomThemePreferenceFragment.class.getName()); + intent.putExtra(PreferenceActivity.EXTRA_NO_HEADERS, true); + intent.putExtra(PreferencesActivity.EXTRA_TOOLBAR_TITLE, + getString(R.string.custom_theme_colors_title)); + intent.putExtra(PreferencesActivity.EXTRA_FINISH_ON_BACK, true); + startActivity(intent); + return true; + }); + } + + private void enforceDonorThemeAccess() { + if (ctx == null) ctx = getActivity(); + if (ctx == null) return; + if (!G.isThemeAvailable(G.getSelectedTheme(), ctx)) { + G.getSelectedTheme("D"); + Log.i(G.TAG, "Unavailable donor-only theme reset to default"); + } + if (G.customThemeColorsEnabled() && !G.canUseDonorFeatures(ctx)) { + G.customThemeColorsEnabled(false); + Log.i(G.TAG, "Custom theme colors disabled: no donor access"); + } + } + + private Context resolveContext() { + return ctx != null ? ctx : getActivity(); + } + + private void showDonorRequiredToast() { + Context context = resolveContext(); + if (context != null) { + Api.toast(context, context.getText(R.string.donate_only), Toast.LENGTH_LONG); + } + } + + private void broadcastThemeRefresh() { + Context context = resolveContext(); + if (context != null) { + Intent broadcastIntent = new Intent("dev.ukanth.ufirewall.theme.REFRESH"); + context.sendBroadcast(broadcastIntent); } } } diff --git a/app/src/main/java/dev/ukanth/ufirewall/preferences/UIPreferenceFragment.java b/app/src/main/java/dev/ukanth/ufirewall/preferences/UIPreferenceFragment.java index b976c70ce..b7d6ecfcc 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/preferences/UIPreferenceFragment.java +++ b/app/src/main/java/dev/ukanth/ufirewall/preferences/UIPreferenceFragment.java @@ -2,7 +2,6 @@ import static dev.ukanth.ufirewall.util.G.isDonate; -import android.app.NotificationManager; import android.content.Context; import android.content.SharedPreferences; import android.os.Bundle; @@ -30,7 +29,35 @@ public void onCreate(Bundle savedInstanceState) { addPreferencesFromResource(R.xml.ui_preferences); if ((G.isDoKey(ctx) || isDonate())) { populatePreference(findPreference("default_behavior_allow_mode"), getString(R.string.connection_default_allow), 0); - populatePreference(findPreference("default_behavior_block_mode"), getString(R.string.connection_default_allow), 1); + populatePreference(findPreference("default_behavior_block_mode"), getString(R.string.connection_default_block), 1); + } + Preference notificationSettings = findPreference("notification_settings"); + if (notificationSettings != null) { + // importance, sound, lock screen... of each notification are Android settings once the + // channels exist; an app setting can't change them + notificationSettings.setOnPreferenceClickListener(preference -> { + openNotificationSettings(getActivity()); + return true; + }); + } + } + + public static void openNotificationSettings(android.app.Activity activity) { + if (activity == null) { + return; + } + android.content.Intent intent; + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + intent = new android.content.Intent(android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, activity.getPackageName()); + } else { + intent = new android.content.Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + android.net.Uri.fromParts("package", activity.getPackageName(), null)); + } + try { + activity.startActivity(intent); + } catch (Exception e) { + Api.toast(activity, e.getMessage()); } } @@ -62,16 +89,9 @@ public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, if(ctx == null) { ctx = getActivity(); } - if(ctx != null) { - if (key.equals("notification_priority")) { - NotificationManager notificationManager = (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE); - notificationManager.cancel(1); - //Api.showNotification(Api.isEnabled(ctx), ctx); - Api.updateNotification(Api.isEnabled(ctx), ctx); - } - } } + private void populatePreference(Preference list, String title, int modeType) { final ArrayList entriesList = new ArrayList(); final ArrayList entryValuesList = new ArrayList(); @@ -97,6 +117,7 @@ private void populatePreference(Preference list, String title, int modeType) { MaterialDialog dialog = new MaterialDialog.Builder(getActivity()) .title(title) + .content(modeType == 0 ? R.string.default_connection_scope_allow : R.string.default_connection_scope_block) .itemsIds(convertIntegers(entryValuesList)) .items(entriesList) .itemsCallbackMultiChoice(null, (dialog1, which, text) -> { diff --git a/app/src/main/java/dev/ukanth/ufirewall/profiles/ProfileHelper.java b/app/src/main/java/dev/ukanth/ufirewall/profiles/ProfileHelper.java index f731dae73..9754f9d8b 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/profiles/ProfileHelper.java +++ b/app/src/main/java/dev/ukanth/ufirewall/profiles/ProfileHelper.java @@ -8,7 +8,7 @@ import com.raizlabs.android.dbflow.structure.database.DatabaseWrapper; import com.raizlabs.android.dbflow.structure.database.transaction.ITransaction; -import java.util.ArrayList; +import java.io.File; import java.util.List; import dev.ukanth.ufirewall.R; @@ -88,48 +88,132 @@ public static boolean deleteProfileByName(String profileName) { return true; } + private static final String LEGACY_PROFILE_PREFIX = "AFWallProfile"; + // comma-separated custom profile names of the old profile model + private static final String LEGACY_ADDITIONAL_PROFILES = "plusprofiles"; + + /** + * Move the profiles of the old model (fixed Profile 1-3, custom names kept in the "profile1..3" + * preferences, plus the "plusprofiles" list) into the profile database, once. The rules stay + * in their preference files, which are keyed by the identifier, so they are not touched. + * Profiles that are already in the database are skipped, so it is safe to run again after a + * partial failure. + *

+ * Profile 1-3 always existed in the old model; they are only carried over when they may be in + * use, so a new install doesn't start with three empty profiles. + */ public static void migrateProfiles(Context ctx) { - if (!G.isProfileMigrated()) { - List listProfile = new ArrayList<>(); - List addProfiles = G.getAdditionalProfiles(); - List defaultProfiles = G.getDefaultProfiles(); - if (defaultProfiles != null && addProfiles != null) { - for (int i = 0; i < defaultProfiles.size(); i++) { - String profileName = defaultProfiles.get(i); - String customName = ""; - switch (i) { - case 0: - customName = G.gPrefs.getString("profile1", ctx.getString(R.string.profile1)); - break; - case 1: - customName = G.gPrefs.getString("profile2", ctx.getString(R.string.profile2)); - break; - case 2: - customName = G.gPrefs.getString("profile3", ctx.getString(R.string.profile3)); - break; - } - ProfileData profile = new ProfileData(); - profile.setName(customName); - profile.setIdentifier(profileName); - listProfile.add(profile); + if (G.isProfileMigrated()) { + return; + } + try { + for (int i = 1; i <= 3; i++) { + String identifier = LEGACY_PROFILE_PREFIX + i; + boolean hasRules = new File(ctx.getFilesDir().getParent(), "shared_prefs/" + identifier + ".xml").exists(); + boolean renamed = !G.gPrefs.getString("profile" + i, "").trim().isEmpty(); + if (G.enableMultiProfile() || hasRules || renamed) { + ensureProfile(ctx, identifier); } - for (String profileName : addProfiles) { - ProfileData profile = new ProfileData(); - profile.setName(profileName); - profile.setIdentifier(profileName); - listProfile.add(profile); + } + String additional = G.gPrefs.getString(LEGACY_ADDITIONAL_PROFILES, ""); + for (String name : additional.split("\\s*,\\s*")) { + if (!name.trim().isEmpty()) { + ensureProfile(ctx, name.trim()); } } - //now store the migrateProfile - try { - for (ProfileData profile : listProfile) { - ProfileHelper.storeProfile(profile, ctx, null); + G.isProfileMigrated(true); + Log.i(TAG, "Profiles migrated to the profile database"); + } catch (Exception e) { + Log.e(TAG, "Profile migration failed; will retry on next start", e); + } + } + + /** + * Make sure a profile with this identifier exists. A missing one is created with its old-model + * name: the custom name of Profile 1-3, otherwise the identifier itself. + */ + public static void ensureProfile(Context ctx, String identifier) { + if (getProfileByIdentifier(identifier) != null) { + return; + } + String name = identifier; + if (identifier.startsWith(LEGACY_PROFILE_PREFIX)) { + String suffix = identifier.substring(LEGACY_PROFILE_PREFIX.length()); + int resId = 0; + switch (suffix) { + case "1": + resId = R.string.profile1; + break; + case "2": + resId = R.string.profile2; + break; + case "3": + resId = R.string.profile3; + break; + } + if (resId != 0) { + name = G.gPrefs.getString("profile" + suffix, ""); + if (name.trim().isEmpty()) { + name = ctx.getString(resId); } - //now all is well, mark as migrated - G.isProfileMigrated(true); - } catch (Exception e) { - G.isProfileMigrated(false); } } + new ProfileData(name, identifier).save(); + } + + /** + * @return the name to show for a profile identifier ("AFWallPrefs" is the default profile) + */ + public static String displayName(Context ctx, String identifier) { + if (identifier == null || dev.ukanth.ufirewall.Api.DEFAULT_PREFS_NAME.equals(identifier)) { + String name = G.gPrefs.getString("default", ""); + return name == null || name.trim().isEmpty() ? ctx.getString(R.string.defaultProfile) : name; + } + try { + ProfileData data = getProfileByIdentifier(identifier); + if (data != null && data.getName() != null) { + return data.getName(); + } + } catch (Exception e) { + Log.w(TAG, "Unable to read profile " + identifier + ": " + e.getMessage()); + } + return identifier; + } + + /** + * @return identifier of the profile named {@code name}. If there is none it is created, with + * {@code preferredIdentifier} when that is free (so a restore keeps the identifiers), otherwise + * with a new one. Null if {@code name} is empty. + */ + public static String ensureProfile(String name, String preferredIdentifier) { + if (name == null || name.trim().isEmpty()) { + return null; + } + ProfileData data = getProfileByName(name); + if (data != null) { + return data.getIdentifier(); + } + String base = preferredIdentifier == null || preferredIdentifier.trim().isEmpty() + || preferredIdentifier.equals(dev.ukanth.ufirewall.Api.DEFAULT_PREFS_NAME) + ? name.replaceAll("\\s+", "") : preferredIdentifier; + String identifier = base; + for (int i = 2; getProfileByIdentifier(identifier) != null; i++) { + identifier = base + i; + } + new ProfileData(name, identifier).save(); + return identifier; + } + + /** + * @return identifier of the profile with this name, created (like a profile added by the user) + * if it doesn't exist + */ + public static String ensureProfileNamed(String name) { + ProfileData data = getProfileByName(name); + if (data == null) { + data = new ProfileData(name, name.replaceAll("\\s+", "")); + data.save(); + } + return data.getIdentifier(); } } diff --git a/app/src/main/java/dev/ukanth/ufirewall/service/FirewallService.java b/app/src/main/java/dev/ukanth/ufirewall/service/FirewallService.java index 4d0c2829e..5c3fa27b4 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/service/FirewallService.java +++ b/app/src/main/java/dev/ukanth/ufirewall/service/FirewallService.java @@ -3,9 +3,7 @@ import static android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE; import android.app.Notification; -import android.app.NotificationChannel; import android.app.NotificationManager; -import android.app.PendingIntent; import android.app.Service; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothProfile; @@ -13,31 +11,35 @@ import android.content.Context; import android.content.Intent; import android.content.IntentFilter; +import android.content.pm.LauncherApps; import android.content.pm.PackageManager; import android.net.ConnectivityManager; import android.os.Build; import android.os.IBinder; - -import androidx.core.app.NotificationCompat; +import android.os.UserHandle; import dev.ukanth.ufirewall.Api; import dev.ukanth.ufirewall.InterfaceTracker; -import dev.ukanth.ufirewall.MainActivity; -import dev.ukanth.ufirewall.R; import dev.ukanth.ufirewall.broadcast.ConnectivityChangeReceiver; import dev.ukanth.ufirewall.broadcast.PackageBroadcast; import dev.ukanth.ufirewall.log.Log; import dev.ukanth.ufirewall.util.G; +import dev.ukanth.ufirewall.util.Notifications; public class FirewallService extends Service { + private static final String TAG = "AFWall"; private static final int NOTIFICATION_ID = 1; + private static boolean logServiceActive = false; // Track if LogService is running + private static FirewallService instance = null; // Track service instance BroadcastReceiver connectivityReciver; BroadcastReceiver packageReceiver; + private LauncherApps.Callback profileAppCallback; IntentFilter filter; private BluetoothAdapter bluetoothAdapter; private BluetoothProfile.ServiceListener btListener; private static BluetoothProfile btPanProfile; + private static boolean btConnectionRequested = false; // Track if connection was requested public Context context; @Override public IBinder onBind(Intent intent) { @@ -48,127 +50,141 @@ public IBinder onBind(Intent intent) { public void onCreate() { super.onCreate(); context = this; + instance = this; + // logServiceActive is not reset here: LogService may have started first (it sets the flag + // when it starts and clears it when it stops) + Log.d(TAG, "FirewallService created, log monitoring " + (logServiceActive ? "active" : "inactive")); } private void registerBTListener() { - btListener = new BluetoothProfile.ServiceListener() { - @Override - public void onServiceConnected(int profile, BluetoothProfile proxy) { - Log.d(G.TAG, "BluetoothProfile.ServiceListener connected"); - btPanProfile = proxy; - } + // Only create listener if it doesn't exist to prevent leaks + if (btListener == null) { + btListener = new BluetoothProfile.ServiceListener() { + @Override + public void onServiceConnected(int profile, BluetoothProfile proxy) { + Log.d(G.TAG, "BluetoothProfile.ServiceListener connected"); + btPanProfile = proxy; + } - @Override - public void onServiceDisconnected(int profile) { - Log.d(G.TAG, "BluetoothProfile.ServiceListener disconected"); - } - }; + @Override + public void onServiceDisconnected(int profile) { + Log.d(G.TAG, "BluetoothProfile.ServiceListener disconnected"); + btPanProfile = null; // Clear reference on disconnect + } + }; + } } private void addNotification() { - String NOTIFICATION_CHANNEL_ID = "firewall.service"; - String channelName = getString(R.string.firewall_service); - - NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - manager.cancel(NOTIFICATION_ID); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - NotificationChannel notificationChannel = new NotificationChannel(NOTIFICATION_CHANNEL_ID, channelName, NotificationManager.IMPORTANCE_LOW); - notificationChannel.setLockscreenVisibility(Notification.VISIBILITY_PRIVATE); - assert manager != null; - if(G.getNotificationPriority() == 0) { - notificationChannel.setImportance(NotificationManager.IMPORTANCE_DEFAULT); + // one builder for the status notification (see Notifications); updating a foreground + // notification in place, without cancelling it first, avoids flicker + Notification notification = Notifications.buildStatus(this, logServiceActive); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground(NOTIFICATION_ID, notification, FOREGROUND_SERVICE_TYPE_SPECIAL_USE); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForeground(NOTIFICATION_ID, notification); + } else if (G.activeNotification()) { + NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + if (manager != null) { + manager.notify(NOTIFICATION_ID, notification); } - notificationChannel.setSound(null, null); - notificationChannel.enableLights(false); - notificationChannel.setShowBadge(false); - notificationChannel.enableVibration(false); - manager.createNotificationChannel(notificationChannel); } + } - - Intent appIntent = new Intent(this, MainActivity.class); - appIntent.setAction(Intent.ACTION_MAIN); - appIntent.addCategory(Intent.CATEGORY_LAUNCHER); - appIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); - - - /*TaskStackBuilder stackBuilder = TaskStackBuilder.create(this); - stackBuilder.addParentStack(MainActivity.class); - stackBuilder.addNextIntent(appIntent);*/ - - int icon; - String notificationText = ""; - - if (Api.isEnabled(this)) { - if (G.enableMultiProfile()) { - String profile = ""; - switch (G.storedProfile()) { - case "AFWallPrefs": - profile = G.gPrefs.getString("default", getString(R.string.defaultProfile)); - break; - case "AFWallProfile1": - profile = G.gPrefs.getString("profile1", getString(R.string.profile1)); - break; - case "AFWallProfile2": - profile = G.gPrefs.getString("profile2", getString(R.string.profile2)); - break; - case "AFWallProfile3": - profile = G.gPrefs.getString("profile3", getString(R.string.profile3)); - break; - default: - profile = G.storedProfile(); - break; + /** + * New apps in other Android users of this device (work profile, Private Space, clones): their + * PACKAGE_ADDED broadcasts go to those users only. + */ + private void registerProfileAppListener() { + if (profileAppCallback != null || Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + final LauncherApps launcherApps = (LauncherApps) getSystemService(Context.LAUNCHER_APPS_SERVICE); + if (launcherApps == null) { + return; + } + profileAppCallback = new LauncherApps.Callback() { + @Override + public void onPackageAdded(String packageName, UserHandle user) { + if (android.os.Process.myUserHandle().equals(user)) { + return; // handled by PackageBroadcast } - notificationText = getString(R.string.active) + " (" + profile + ")"; - } else { - notificationText = getString(R.string.active); + PackageBroadcast.onProfilePackageAdded(getApplicationContext(), launcherApps, packageName, user); } - //notificationText = context.getString(R.string.active); - icon = R.drawable.notification; - } else { - notificationText = getString(R.string.inactive); - icon = R.drawable.notification_error; - } + @Override + public void onPackageRemoved(String packageName, UserHandle user) { + } - PendingIntent notifyPendingIntent = PendingIntent.getActivity(this, 0, appIntent, PendingIntent.FLAG_IMMUTABLE); - NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID); - notificationBuilder.setContentIntent(notifyPendingIntent); - - //int notifyType = G.getNotificationPriority(); - Notification notification = notificationBuilder - .setContentTitle(getString(R.string.app_name)) - .setTicker(getString(R.string.app_name)) - .setSound(null) - .setChannelId(NOTIFICATION_CHANNEL_ID) - .setPriority(NotificationCompat.PRIORITY_LOW) - .setCategory(Notification.CATEGORY_SERVICE) - .setVisibility(NotificationCompat.VISIBILITY_SECRET) - .setContentText(notificationText) - .setSmallIcon(icon) - .setOngoing(true) - .build(); - - //if(G.activeNotification()) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - startForeground(NOTIFICATION_ID, notification, FOREGROUND_SERVICE_TYPE_SPECIAL_USE); - } else if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ) { - startForeground(NOTIFICATION_ID, notification); - } else { - manager.notify(NOTIFICATION_ID, notification); - } - /*} else { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - startForeground(NOTIFICATION_ID, notification); - } else { - //empty one - startForeground(NOTIFICATION_ID, new Notification()); + @Override + public void onPackageChanged(String packageName, UserHandle user) { } - }*/ + @Override + public void onPackagesAvailable(String[] packageNames, UserHandle user, boolean replacing) { + } + + @Override + public void onPackagesUnavailable(String[] packageNames, UserHandle user, boolean replacing) { + } + }; + try { + launcherApps.registerCallback(profileAppCallback); + } catch (Exception e) { + Log.w(TAG, "Unable to watch apps of other profiles: " + e.getMessage()); + profileAppCallback = null; + } + } + + /** + * Update notification when LogService status changes + */ + public static void setLogServiceActive(boolean active) { + logServiceActive = active; + if (instance != null) { + instance.addNotification(); + } + } + /** + * Check if FirewallService is running + */ + public static boolean isInstanceRunning() { + return instance != null; + } + + /** + * Start the service if it isn't running. Android 12+ refuses foreground-service starts while + * the app is in the background (except e.g. a real boot); that is logged, not thrown. + */ + public static void ensureRunning(Context ctx) { + if (ctx == null || isInstanceRunning()) { + return; + } + try { + androidx.core.content.ContextCompat.startForegroundService(ctx, new Intent(ctx, FirewallService.class)); + } catch (Exception e) { + Log.w(TAG, "Unable to start FirewallService: " + e.getMessage()); + } + } + + /** + * Refresh the notification + */ + public static void refreshNotification() { + if (instance != null) { + Log.d(TAG, "Refreshing FirewallService notification"); + // Ensure we run on the main thread + new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> { + if (instance != null) { + instance.addNotification(); + Log.d(TAG, "FirewallService notification refreshed"); + } + }); + } else { + Log.w(TAG, "Cannot refresh notification - FirewallService instance is null"); + } } @Override @@ -176,6 +192,7 @@ public int onStartCommand(Intent intent, int flags, int startId) { addNotification(); registerBTListener(); + registerProfileAppListener(); //incase if it's not null, make sure we unregister it if(packageReceiver != null) { @@ -215,9 +232,8 @@ public int onStartCommand(Intent intent, int flags, int startId) { registerReceiver(packageReceiver, intentFilter); } - if(bluetoothAdapter == null) { - bluetoothAdapter = getBTAdapter(context); - } + // TEMPORARY: Bluetooth initialization completely disabled to prevent connection leaks + Log.d(G.TAG, "Bluetooth initialization disabled to prevent service connection leaks"); return START_STICKY; } @@ -228,21 +244,24 @@ private BluetoothAdapter getBTAdapter(Context context) { boolean hasBluetooth = pm.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH); if (hasBluetooth) { bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); - if (bluetoothAdapter != null && btListener != null && btPanProfile == null) { - try { - boolean success = bluetoothAdapter.getProfileProxy(context, btListener, 5); // BluetoothProfile.PAN - if (!success) { - Log.w(G.TAG, "Failed to get Bluetooth PAN profile proxy"); - } - } catch (Exception e) { - Log.e(G.TAG, "Error getting Bluetooth profile proxy", e); - } - } + + // TEMPORARY: Disable Bluetooth profile connection to prevent service leaks + // TODO: Find better way to handle Bluetooth tethering detection without connection leaks + Log.d(G.TAG, "Bluetooth PAN profile connection disabled to prevent service leaks"); + } else { + Log.d(G.TAG, "Device does not support Bluetooth, skipping"); } return bluetoothAdapter; } @Override public void onDestroy() { + if (profileAppCallback != null) { + try { + ((LauncherApps) getSystemService(Context.LAUNCHER_APPS_SERVICE)).unregisterCallback(profileAppCallback); + } catch (Exception ignored) { + } + profileAppCallback = null; + } if (connectivityReciver != null) { unregisterReceiver(connectivityReciver); connectivityReciver = null; @@ -258,6 +277,7 @@ public void onDestroy() { if(btPanProfile != null) { bluetoothAdapter.closeProfileProxy(5, btPanProfile); // BluetoothProfile.PAN btPanProfile = null; + Log.d(G.TAG, "Closed Bluetooth PAN profile proxy"); } } catch (Exception e){ Log.e(G.TAG, "Error closing bt profile", e); @@ -265,7 +285,15 @@ public void onDestroy() { // Always clean up references regardless of profile state btListener = null; bluetoothAdapter = null; + btConnectionRequested = false; // Reset connection flag + Log.d(G.TAG, "Bluetooth cleanup completed"); } + } else if (btConnectionRequested || btPanProfile != null) { + // Edge case: Clean up even if adapter is null + Log.w(G.TAG, "Bluetooth adapter is null but connection state exists, cleaning up"); + btPanProfile = null; + btListener = null; + btConnectionRequested = false; } super.onDestroy(); } @@ -273,6 +301,9 @@ public void onDestroy() { public static BluetoothProfile getBtPanProfile() { - return btPanProfile; + // TEMPORARY: Return null to disable Bluetooth tethering detection + // This prevents service connection leaks while we find a better solution + Log.d(G.TAG, "Bluetooth PAN profile disabled, returning null"); + return null; } } diff --git a/app/src/main/java/dev/ukanth/ufirewall/service/LogService.java b/app/src/main/java/dev/ukanth/ufirewall/service/LogService.java index 2feb7fdad..b56425e8d 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/service/LogService.java +++ b/app/src/main/java/dev/ukanth/ufirewall/service/LogService.java @@ -22,17 +22,17 @@ package dev.ukanth.ufirewall.service; +import static android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE; import static dev.ukanth.ufirewall.util.G.ctx; -import android.annotation.SuppressLint; import android.app.AlarmManager; import android.app.Notification; -import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; import android.app.Service; import android.content.Context; import android.content.Intent; +import android.net.ConnectivityManager; import android.os.Build; import android.os.Handler; import android.os.IBinder; @@ -42,32 +42,41 @@ import android.widget.Toast; import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; import com.raizlabs.android.dbflow.config.FlowConfig; import com.raizlabs.android.dbflow.config.FlowManager; import com.topjohnwu.superuser.CallbackList; +import com.topjohnwu.superuser.NoShellException; import com.topjohnwu.superuser.Shell; import org.ocpsoft.prettytime.PrettyTime; import org.ocpsoft.prettytime.TimeUnit; import org.ocpsoft.prettytime.units.JustNow; +import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.Locale; +import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; import dev.ukanth.ufirewall.Api; +import dev.ukanth.ufirewall.InterfaceDetails; +import dev.ukanth.ufirewall.InterfaceTracker; import dev.ukanth.ufirewall.R; -import dev.ukanth.ufirewall.activity.LogActivity; import dev.ukanth.ufirewall.events.LogEvent; import dev.ukanth.ufirewall.log.Log; import dev.ukanth.ufirewall.log.LogData; import dev.ukanth.ufirewall.log.LogDatabase; import dev.ukanth.ufirewall.log.LogInfo; +import dev.ukanth.ufirewall.service.FirewallService; import dev.ukanth.ufirewall.util.G; +import dev.ukanth.ufirewall.util.Notifications; +import dev.ukanth.ufirewall.util.SystemUids; public class LogService extends Service { @@ -75,16 +84,96 @@ public class LogService extends Service { public static String logPath; public static final int QUEUE_NUM = 40; - private String NOTIFICATION_CHANNEL_ID = "firewall.logservice"; + public static final String ACTION_GRACEFUL_SHUTDOWN = "dev.ukanth.ufirewall.GRACEFUL_SHUTDOWN"; + public static final String ACTION_CHANGE_LOG_TARGET = "dev.ukanth.ufirewall.CHANGE_LOG_TARGET"; + public static final String EXTRA_NEW_LOG_TARGET = "new_log_target"; + private static final int LOG_FLUSH_BATCH_SIZE = 50; + private static final long LOG_FLUSH_INTERVAL_MS = 5000L; + private static final long HEALTH_CHECK_INTERVAL_MS = 60_000L; + + private static LogService instance; private NotificationManager manager; - private NotificationCompat.Builder notificationBuilder; private List callbackList; private ExecutorService executorService; + // Dedicated single-thread scheduled executor so log-line parsing/storage runs OFF the + // main (UI) thread. Scheduled so it can also handle timed batch flushes. + private ScheduledExecutorService logProcessExecutor; + private volatile boolean isShuttingDown = false; + + private Shell logWatcherShell; + + /** + * libsu delivers job results and output lines to the given executor from its own threads. If + * that executor rejects a task (it was shut down while the job was still running, e.g. when + * the log shell's su session died or the watcher was restarted), libsu throws on its thread + * and the whole app crashes. Bound to the executor in use when the job was started, so + * callbacks of a replaced/stopped watcher are dropped rather than run on its successor. + */ + private static Executor nonRejecting(final Executor target) { + return command -> runOrDrop(target, command); + } + + private static void runOrDrop(Executor target, Runnable command) { + if (target == null) { + Log.d(TAG, "Log watcher executor is gone; dropping callback"); + return; + } + try { + target.execute(command); + } catch (RejectedExecutionException e) { + Log.d(TAG, "Log watcher executor was shut down; dropping callback"); + } + } + + // Log watcher restart backoff + private static final long RESTART_BASE_DELAY_MS = 5_000; + private static final long RESTART_MAX_DELAY_MS = 5 * 60_000; + private static final long WATCHER_STABLE_MS = 60_000; + private final java.util.concurrent.atomic.AtomicBoolean restartPending = new java.util.concurrent.atomic.AtomicBoolean(false); + private volatile long watcherStartedAt; + private volatile int quickRestarts; + + // A watcher that exits with an error this soon after starting never worked (e.g. nflog + // can't be executed on this Android version); tell the user instead of logging nothing. + private static final long START_FAILURE_WINDOW_MS = 5_000; + private static volatile String lastStartFailure; + private volatile String lastWatcherOutput; + private boolean startFailureNotified; + + /** reason the log watcher could not start, or null if it is working / unknown */ + public static String getLastStartFailure() { + return lastStartFailure; + } + + private void reportStartFailure(String command, int exitCode) { + String output = lastWatcherOutput; + String reason = output != null ? output : command + " exited with code " + exitCode; + lastStartFailure = reason; + Log.e(TAG, "Log watcher could not start: " + reason); + if (startFailureNotified) { + return; + } + startFailureNotified = true; + Context appCtx = getApplicationContext(); + Intent prefs = new Intent(appCtx, dev.ukanth.ufirewall.preferences.PreferencesActivity.class) + .putExtra(android.preference.PreferenceActivity.EXTRA_SHOW_FRAGMENT, + dev.ukanth.ufirewall.preferences.LogPreferenceFragment.class.getName()); + Api.showNotification(appCtx, Api.LOG_WATCHER_FAILED_NOTIFICATION_ID, + appCtx.getString(R.string.log_watcher_failed_title), + appCtx.getString(R.string.log_watcher_failed_text, reason), prefs); + } + + // Batching: accumulated entries flushed periodically or when the batch is full. + // Only touched on logProcessExecutor — no lock needed. + private final List pendingLogs = new ArrayList<>(); + private ScheduledFuture scheduledFlush; - private Shell logWatcherShell; // Additional shell for long running log-watcher process + // Periodic health check handler (runs on main looper; does no DB work). + private Handler healthHandler; + private Runnable healthCheck; @Nullable @Override @@ -94,6 +183,21 @@ public IBinder onBind(Intent intent) { @Override public int onStartCommand(Intent intent, int flags, int startId) { + if (intent != null) { + if (ACTION_GRACEFUL_SHUTDOWN.equals(intent.getAction())) { + Log.i(TAG, "Received graceful shutdown request"); + initiateGracefulShutdown(); + return START_NOT_STICKY; + } else if (ACTION_CHANGE_LOG_TARGET.equals(intent.getAction())) { + String newLogTarget = intent.getStringExtra(EXTRA_NEW_LOG_TARGET); + Log.i(TAG, "Received log target change request to: " + newLogTarget); + changeLogTarget(newLogTarget); + return START_STICKY; + } + } + + // Reset shutdown flag when service starts normally + isShuttingDown = false; startLogService(); return START_STICKY; } @@ -101,14 +205,115 @@ public int onStartCommand(Intent intent, int flags, int startId) { @Override public void onCreate() { - startLogService(); + super.onCreate(); + instance = this; + } + + public static boolean isInstanceRunning() { + return instance != null; + } + + public static void ensureRunning(Context ctx) { + if (!G.enableLogService()) return; + if (!isInstanceRunning()) { + // LogService calls startForeground() in onCreate on 8+, so start it as a foreground + // service; a plain startService() is refused while the app is in the background (boot). + try { + androidx.core.content.ContextCompat.startForegroundService(ctx, new Intent(ctx, LogService.class)); + } catch (Exception e) { + Log.e(TAG, "Unable to start log service", e); + } + } + } + + private boolean isWatcherHealthy() { + return logWatcherShell != null && logWatcherShell.isAlive() + && executorService != null && !executorService.isShutdown() + && logProcessExecutor != null && !logProcessExecutor.isShutdown(); + } + + private void closeLogWatcher() { + if (logWatcherShell != null) { + try { + logWatcherShell.close(); + } catch (Exception e) { + Log.w(TAG, "Error closing log watcher shell: " + e.getMessage()); + } + logWatcherShell = null; + } + } + + private void scheduleHealthCheck() { + if (healthHandler == null) { + healthHandler = new Handler(Looper.getMainLooper()); + } + if (healthCheck == null) { + healthCheck = new Runnable() { + @Override + public void run() { + if (!isShuttingDown && !isWatcherHealthy()) { + Log.w(TAG, "Health check: watcher unhealthy, restarting"); + initiateLogWatcher(logPath); + } + if (healthHandler != null) { + healthHandler.postDelayed(this, HEALTH_CHECK_INTERVAL_MS); + } + } + }; + } + healthHandler.removeCallbacks(healthCheck); + healthHandler.postDelayed(healthCheck, HEALTH_CHECK_INTERVAL_MS); } + /** + * Get the best available command for reading kernel logs with iptables messages + * Tries multiple methods in order of preference for efficiency and compatibility + * @return command string or null if no suitable method is available + */ + private String getBestLogCommand() { + // Method 1: Try dmesg with follow and grep (most efficient for iptables logs) + if (isCommandAvailable("dmesg --follow")) { + return "dmesg --follow | grep '{AFL}'"; + } + + // Method 2: Try dmesg with tail simulation (good fallback) + if (isCommandAvailable("dmesg") && isCommandAvailable("tail")) { + return "while true; do dmesg | grep '{AFL}' | tail -n +$(( $(wc -l < /tmp/afwall_lastline 2>/dev/null || echo 0) + 1 )); dmesg | wc -l > /tmp/afwall_lastline; sleep 1; done"; + } + + // Method 3: Try logcat kernel logs (Android 7+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isCommandAvailable("logcat")) { + return "logcat -s kernel:* | grep '{AFL}'"; + } + + // Method 4: Try journalctl if available (some Android variants) + if (isCommandAvailable("journalctl")) { + return "journalctl -k -f | grep '{AFL}'"; + } + + // Method 5: Fall back to /proc/kmsg with improvements + Log.w(TAG, "Falling back to /proc/kmsg - less efficient method"); + return "cat /proc/kmsg | grep --line-buffered '{AFL}'"; + } + + /** + * Check if a command is available on the system + * @param command the command to test + * @return true if command is available + */ + private boolean isCommandAvailable(String command) { + try { + String testCommand = command.split(" ")[0]; // Get the base command + Shell.Result result = Shell.cmd("which " + testCommand + " || command -v " + testCommand).exec(); + return result.isSuccess() && !result.getOut().isEmpty(); + } catch (Exception e) { + return false; + } + } + private void startLogService() { if (G.enableLogService()) { - // this method is executed in a background thread - // no problem calling su here String log = G.logTarget(); if (log != null) { log = log.trim(); @@ -116,35 +321,65 @@ private void startLogService() { Toast.makeText(getApplicationContext(), "Please select log target first", Toast.LENGTH_LONG).show(); return; } + String nextLogPath; switch (log) { case "LOG": - logPath = "cat /proc/kmsg"; + nextLogPath = getBestLogCommand(); + if (nextLogPath == null) { + Log.e(TAG, "No suitable log reading method available"); + return; + } break; case "NFLOG": - logPath = Api.getNflogPath(getApplicationContext()); - if (logPath == null) { + nextLogPath = Api.getEnhancedNflogCommand(getApplicationContext(), QUEUE_NUM); + if (nextLogPath == null) { Log.e(TAG, "NFLOG binary not available, cannot start logging service"); return; } - logPath = logPath + " " + QUEUE_NUM; break; + default: + nextLogPath = null; + } + if (nextLogPath == null) return; + + // Skip re-init if already watching the same path and shell is healthy. + if (isWatcherHealthy() && nextLogPath.equals(logPath)) { + Log.i(TAG, "Log watcher already running for: " + logPath); + return; } + logPath = nextLogPath; Log.i(TAG, "Starting Log Service: " + logPath + " for LogTarget: " + G.logTarget()); - callbackList = new CallbackList() { + if (logProcessExecutor == null || logProcessExecutor.isShutdown() || logProcessExecutor.isTerminated()) { + logProcessExecutor = Executors.newSingleThreadScheduledExecutor(); + } + // Pass an Executor so libsu delivers onAddElement off the main thread. + callbackList = new CallbackList(nonRejecting(logProcessExecutor)) { @Override public void onAddElement(String line) { - //kmsg entering into idle state, wait for few seconds and start again ? - if(line.contains("suspend exit")) { + // Handle device suspend/resume scenarios + if(line.contains("suspend exit") || line.contains("PM: suspend exit")) { + restartWatcher(logPath); + } + + // Handle log rotation or kernel ring buffer wrap + if(line.contains("log_buf_len") || line.contains("Buffer wrap")) { restartWatcher(logPath); } + // Process iptables/netfilter log entries if(line.contains("{AFL}")) { + lastStartFailure = null; // logging works storeLogInfo(line, getApplicationContext()); + } else if (!line.trim().isEmpty()) { + // stderr is merged in: keep the last message in case the watcher dies + String trimmed = line.trim(); + lastWatcherOutput = trimmed.length() > 300 ? trimmed.substring(0, 300) : trimmed; } } }; initiateLogWatcher(logPath); + scheduleHealthCheck(); createNotification(); } else { @@ -157,78 +392,439 @@ public void onAddElement(String line) { } private void restartWatcher(String logPath) { + if (isShuttingDown) { + return; + } + // Several triggers (suspend/resume lines, watcher exit) can fire together; keep one restart + if (!restartPending.compareAndSet(false, true)) { + return; + } + + // A watcher that keeps exiting right away (unsupported command, no root) would otherwise be + // restarted every 5s forever; back off exponentially until it stays up for a while. + long ranFor = SystemClock.elapsedRealtime() - watcherStartedAt; + if (ranFor >= WATCHER_STABLE_MS) { + quickRestarts = 0; + } else { + quickRestarts = Math.min(quickRestarts + 1, 6); + } + final long delay = Math.min(RESTART_BASE_DELAY_MS << quickRestarts, RESTART_MAX_DELAY_MS); + final Handler handler = new Handler(Looper.getMainLooper()); handler.postDelayed(() -> { - Log.i(G.TAG, "Restarting log watcher after 5s"); - initiateLogWatcher(logPath); - }, 5000); + restartPending.set(false); + if (G.enableLogService() && !isShuttingDown) { + Log.i(G.TAG, "Restarting log watcher after " + delay + "ms"); + cleanupTempFiles(); + initiateLogWatcher(logPath); + } + }, delay); + } + + /** + * Clean up temporary files used by log watchers + */ + private void cleanupTempFiles() { + try { + // async: this runs on the main thread + Shell.cmd("rm -f /tmp/afwall_lastline").submit(); + } catch (Exception e) { + // Ignore cleanup errors + } + } + + /** + * Initiate graceful shutdown of the log service + */ + private void initiateGracefulShutdown() { + Log.i(TAG, "Starting graceful shutdown process"); + + // Set shutdown flag to prevent new tasks + isShuttingDown = true; + + // Stop in background thread to avoid blocking the main thread + new Thread(() -> { + try { + // Close shell first to stop generating new tasks + if (logWatcherShell != null) { + try { + logWatcherShell.close(); + Log.i(TAG, "Log watcher shell closed"); + } catch (Exception e) { + Log.w(TAG, "Error closing log watcher shell during graceful shutdown: " + e.getMessage()); + } + logWatcherShell = null; + } + + // Give executor service time to finish current tasks + if (executorService != null) { + try { + Log.i(TAG, "Shutting down executor service..."); + executorService.shutdown(); // Don't accept new tasks + if (!executorService.awaitTermination(5000, java.util.concurrent.TimeUnit.MILLISECONDS)) { + Log.w(TAG, "Executor service didn't terminate within 5s, forcing shutdown"); + executorService.shutdownNow(); + // Wait a bit more for tasks to respond to being cancelled + if (!executorService.awaitTermination(2000, java.util.concurrent.TimeUnit.MILLISECONDS)) { + Log.w(TAG, "Executor service still didn't terminate after force shutdown"); + } + } + Log.i(TAG, "Executor service shutdown complete"); + } catch (InterruptedException e) { + Log.w(TAG, "Interrupted while shutting down executor service"); + executorService.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + + // Clean up and stop service + cleanupTempFiles(); + Log.i(TAG, "Graceful shutdown complete, stopping service"); + + // Stop the service on the main thread + Handler mainHandler = new Handler(Looper.getMainLooper()); + mainHandler.post(() -> stopSelf()); + + } catch (Exception e) { + Log.e(TAG, "Error during graceful shutdown: " + e.getMessage(), e); + // Fallback to immediate stop + Handler mainHandler = new Handler(Looper.getMainLooper()); + mainHandler.post(() -> stopSelf()); + } + }, "LogService-GracefulShutdown").start(); + } + + /** + * Change the log target without restarting the service + */ + private void changeLogTarget(String newLogTarget) { + if (newLogTarget == null || newLogTarget.trim().isEmpty()) { + Log.w(TAG, "Invalid log target provided, ignoring change request"); + return; + } + + String currentLogTarget = G.logTarget(); + if (newLogTarget.equals(currentLogTarget)) { + Log.i(TAG, "New log target is same as current, no change needed"); + return; + } + + Log.i(TAG, "Changing log target from " + currentLogTarget + " to " + newLogTarget); + + // Stop current log watcher gracefully in background thread + new Thread(() -> { + try { + // Set shutdown flag temporarily to prevent restarts + isShuttingDown = true; + + // Close current shell and executor + if (logWatcherShell != null) { + try { + logWatcherShell.close(); + Log.i(TAG, "Closed existing log watcher shell"); + } catch (Exception e) { + Log.w(TAG, "Error closing existing shell: " + e.getMessage()); + } + logWatcherShell = null; + } + + if (executorService != null) { + try { + executorService.shutdown(); + if (!executorService.awaitTermination(3000, java.util.concurrent.TimeUnit.MILLISECONDS)) { + Log.w(TAG, "Executor didn't terminate gracefully, forcing shutdown"); + executorService.shutdownNow(); + } + Log.i(TAG, "Executor service shut down successfully"); + } catch (InterruptedException e) { + Log.w(TAG, "Interrupted while shutting down executor"); + executorService.shutdownNow(); + Thread.currentThread().interrupt(); + } + executorService = null; + } + + // Wait a moment for cleanup + Thread.sleep(1000); + + // Update log target in preferences + G.logTarget(newLogTarget); + + // Reset shutdown flag + isShuttingDown = false; + + // Restart log service with new target on main thread + Handler mainHandler = new Handler(Looper.getMainLooper()); + mainHandler.post(() -> { + Log.i(TAG, "Restarting log service with new target: " + newLogTarget); + startLogService(); + }); + + } catch (Exception e) { + Log.e(TAG, "Error during log target change: " + e.getMessage(), e); + isShuttingDown = false; // Reset flag on error + } + }, "LogService-ChangeTarget").start(); } private void createNotification() { manager = (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE); - manager.cancel(109); - + // the status notification shows "log monitoring"; FirewallService owns it + FirewallService.setLogServiceActive(true); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - NotificationChannel notificationChannel = new NotificationChannel(NOTIFICATION_CHANNEL_ID, ctx.getString(R.string.firewall_log_notify), NotificationManager.IMPORTANCE_DEFAULT); - notificationChannel.setLockscreenVisibility(Notification.VISIBILITY_PRIVATE); - assert manager != null; - if (G.getNotificationPriority() == 0) { - notificationChannel.setImportance(NotificationManager.IMPORTANCE_DEFAULT); + // LogService MUST call startForeground() within 5 seconds on Android 8+: share the + // status notification (same id and content as FirewallService's) + Notification notification = Notifications.buildStatus(ctx, true); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground(Notifications.ID_STATUS, notification, FOREGROUND_SERVICE_TYPE_SPECIAL_USE); + } else { + startForeground(Notifications.ID_STATUS, notification); } - notificationChannel.setSound(null, null); - notificationChannel.setShowBadge(false); - notificationChannel.enableLights(false); - notificationChannel.enableVibration(false); - manager.createNotificationChannel(notificationChannel); + Log.i(TAG, "LogService started as foreground with the shared status notification"); } - - Intent appIntent = new Intent(ctx, LogActivity.class); - appIntent.setAction(Intent.ACTION_MAIN); - appIntent.addCategory(Intent.CATEGORY_LAUNCHER); - appIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); - - PendingIntent notifyPendingIntent = PendingIntent.getActivity(ctx, 0, appIntent, PendingIntent.FLAG_IMMUTABLE); - notificationBuilder = new NotificationCompat.Builder(ctx, NOTIFICATION_CHANNEL_ID); - notificationBuilder.setContentIntent(notifyPendingIntent); + // FirewallService keeps the status notification up to date + Notifications.refreshStatus(ctx); } - private void initiateLogWatcher(String logCommand) { + private void initiateLogWatcher(String logCommand) { // Clear/remove existing tasks if(executorService != null) { executorService.shutdownNow(); } //make sure it's enabled first - if(G.enableLogService()) { + if(G.enableLogService() && !isShuttingDown) { if (executorService == null) { executorService = Executors.newCachedThreadPool(); } + if (logWatcherShell != null && !logWatcherShell.isAlive()) { + // a dead shell can't run new jobs; recreate it + closeLogWatcher(); + } if (logWatcherShell == null) { - logWatcherShell = Shell.Builder.create().setFlags(Shell.FLAG_REDIRECT_STDERR).build(); + try { + logWatcherShell = Shell.Builder.create() + .setFlags(Shell.FLAG_REDIRECT_STDERR) + .setTimeout(10) // 10 second timeout for shell creation + .build(); + } catch (NoShellException e) { + Log.e(TAG, "Failed to create root shell for log watcher", e); + return; + } } - Log.i(TAG, "Staring log watcher"); + + Log.i(TAG, "Starting log watcher with command: " + logCommand); + watcherStartedAt = SystemClock.elapsedRealtime(); + final long startedAt = watcherStartedAt; + lastWatcherOutput = null; try { - logWatcherShell.newJob().add(logCommand).to(callbackList).submit(executorService, out -> { - //failed to start, try restarting - if (out.getCode() == 0) { - restartWatcher(logPath); - } else { - Log.i(TAG, "Started successfully"); + if (executorService == null || executorService.isShutdown() || executorService.isTerminated()) { + Log.w(TAG, "ExecutorService is not available, recreating..."); + if (executorService != null) { + executorService.shutdownNow(); } - }); + executorService = Executors.newCachedThreadPool(); + } + + logWatcherShell.newJob() + .add(logCommand) + .to(callbackList) + .submit(nonRejecting(executorService), out -> { + try { + Log.i(TAG, "Log watcher finished with code: " + out.getCode()); + if (!isShuttingDown && out.getCode() != 0 && out.getCode() != 130 + && SystemClock.elapsedRealtime() - startedAt < START_FAILURE_WINDOW_MS) { + reportStartFailure(logCommand, out.getCode()); + } + + // Don't restart if service is shutting down + if (isShuttingDown) { + Log.i(TAG, "Service is shutting down, not restarting log watcher"); + return; + } + + // Handle different exit scenarios + if (out.getCode() == 0) { + // Normal termination, try restart after delay + Log.w(TAG, "Log watcher terminated normally, restarting..."); + restartWatcher(logPath); + } else if (out.getCode() == 130) { + // SIGINT - likely manual termination + Log.i(TAG, "Log watcher interrupted (SIGINT)"); + } else if (out.getCode() == 137) { + // SIGKILL - system killed the process + Log.w(TAG, "Log watcher killed by system, restarting..."); + restartWatcher(logPath); + } else { + // Other error codes, try fallback method + Log.w(TAG, "Log watcher failed with code " + out.getCode() + ", trying fallback"); + tryFallbackLogMethod(); + } + } catch (Exception e) { + if (e.getMessage() != null && e.getMessage().contains("RejectedExecutionException")) { + Log.w(TAG, "Caught SuperUser library RejectedExecutionException during app shutdown, ignoring to prevent crash"); + } else { + Log.e(TAG, "Error in log watcher completion callback: " + e.getMessage(), e); + } + } + }); + } catch(Exception e) { + Log.e(TAG, "Unable to start log service: " + e.getMessage(), e); + if (e.getMessage() != null && (e.getMessage().contains("rejected") || e.getMessage().contains("terminated"))) { + Log.w(TAG, "ExecutorService rejected task, recreating executor and retrying..."); + try { + if (executorService != null) { + executorService.shutdownNow(); + } + executorService = Executors.newCachedThreadPool(); + initiateLogWatcher(logPath); + return; + } catch (Exception retryException) { + Log.e(TAG, "Retry also failed: " + retryException.getMessage(), retryException); + } + } + tryFallbackLogMethod(); + } + } + } + + /** + * Try a fallback log reading method if the primary method fails + */ + private void tryFallbackLogMethod() { + if (isShuttingDown) { + return; + } + + // Prefer polling dmesg: reading /proc/kmsg consumes the kernel messages, so logd and other + // readers would miss them. /proc/kmsg is only used when dmesg is not there at all. + String fallbackCommand; + if (isCommandAvailable("dmesg")) { + Log.i(TAG, "Attempting fallback to polling dmesg"); + fallbackCommand = "while true; do dmesg | grep '{AFL}' | tail -n +$(( $(wc -l < /tmp/afwall_lastline 2>/dev/null || echo 0) + 1 )); dmesg | wc -l > /tmp/afwall_lastline; sleep 1; done"; + } else { + Log.i(TAG, "Attempting fallback to basic /proc/kmsg reading"); + fallbackCommand = "cat /proc/kmsg | grep --line-buffered '{AFL}'"; + } + + final Handler handler = new Handler(Looper.getMainLooper()); + handler.postDelayed(() -> { + if (G.enableLogService() && !isShuttingDown) { + Log.i(TAG, "Starting fallback log watcher"); + initiateLogWatcherWithCommand(fallbackCommand); + } + }, 3000); + } + + /** + * Initiate log watcher with a specific command (used for fallback) + */ + private void initiateLogWatcherWithCommand(String logCommand) { + if(G.enableLogService() && logWatcherShell != null && logWatcherShell.isAlive() && !isShuttingDown) { + watcherStartedAt = SystemClock.elapsedRealtime(); + try { + if (executorService == null || executorService.isShutdown() || executorService.isTerminated()) { + Log.w(TAG, "ExecutorService is not available for fallback, recreating..."); + if (executorService != null) { + executorService.shutdownNow(); + } + executorService = Executors.newCachedThreadPool(); + } + + logWatcherShell.newJob() + .add(logCommand) + .to(callbackList) + .submit(nonRejecting(executorService), out -> { + Log.i(TAG, "Fallback log watcher finished with code: " + out.getCode()); + if (out.getCode() == 0) { + restartWatcher(logCommand); + } + }); } catch(Exception e) { - Log.i(TAG, "Unable to start log service."); + Log.e(TAG, "Fallback log service also failed: " + e.getMessage(), e); + if (e.getMessage() != null && (e.getMessage().contains("rejected") || e.getMessage().contains("terminated"))) { + Log.w(TAG, "ExecutorService rejected fallback task, service may be shutting down"); + } } } } + + /** + * Determine if a log entry should be suppressed because the app is allowed + * on the currently active network interface. + * + * When an app is allowed on WiFi (the active connection), it can still generate + * spurious block logs from: + * - Cross-interface probes (trying 3G while on WiFi) + * - VPN interface blocks (tun+, ppp+) + * - Tethering chains + * - INPUT chain (empty OUT= field) + * + * All of these are noise because the app IS working on the active interface. + * If the app is in the allowed list for the currently active network type, + * suppress the log entry entirely. + */ + private boolean shouldSuppressLog(LogInfo info, Context ctx) { + // Only suppress regular app traffic, not kernel or special IDs + if (info.uid < 0) return false; + + // Get current active network state + InterfaceDetails details = InterfaceTracker.getCurrentCfg(ctx, false); + if (details == null || !details.netEnabled) { + return false; + } + + int netType = details.netType; + if (netType != ConnectivityManager.TYPE_WIFI && netType != ConnectivityManager.TYPE_MOBILE) { + return false; // Unknown network state, don't suppress + } + + // Rules are stored in G.pPrefs as pipe-separated UIDs: "1000|1005|..." + String selected = netType == ConnectivityManager.TYPE_WIFI + ? G.pPrefs.getString(Api.PREF_WIFI_PKG_UIDS, "") + : G.pPrefs.getString(Api.PREF_3G_PKG_UIDS, ""); + String list = "|" + selected + "|"; + boolean listed = list.contains("|" + info.uid + "|") || list.contains("|" + Api.SPECIAL_UID_ANY + "|"); + + // The list holds the allowed apps in whitelist mode but the blocked apps in blacklist + // mode; treating it as "allowed" in both modes hid every block log in blacklist mode. + boolean whitelist = Api.MODE_WHITELIST.equals(G.pPrefs.getString(Api.PREF_MODE, Api.MODE_WHITELIST)); + boolean allowedOnActiveInterface = whitelist == listed; + + // If the app IS allowed on the active interface, suppress this block log. + // The block must be from an inactive/secondary interface (3G probe, VPN, tether, etc.) + return allowedOnActiveInterface; + } + private void storeLogInfo(String line, Context context) { try { LogEvent event = new LogEvent(LogInfo.parseLogs(line, context, "{AFL}", 0), context); if(event.logInfo != null) { + // a blocked system UID without an entry in the app list gets one + SystemUids.seenInLog(context, event.logInfo.uid, event.logInfo.appName); + // Filter multicast/broadcast traffic to reduce log noise + // IPv4 Multicast: 224.0.0.0/4 (224.0.0.0 - 239.255.255.255) + // Broadcast: 255.255.255.255 + // IPv6 Multicast: ff00::/8 + String dst = event.logInfo.dst; + if (dst != null) { + if (dst.equals("255.255.255.255") || + dst.startsWith("224.") || dst.startsWith("239.") || + dst.toLowerCase().startsWith("ff")) { + // Skip notification and storage for multicast/broadcast garbage + return; + } + } + + // Smart Filter: Suppress logs for apps allowed on active interface but blocked on inactive one + if (shouldSuppressLog(event.logInfo, context)) { + return; + } + store(event.logInfo, event.ctx); showNotification(event.logInfo); } @@ -262,22 +858,59 @@ public static String pretty(Date date) { return prettyTime.format(new Date(0)); } - @SuppressLint("RestrictedApi") private void showNotification(LogInfo logInfo) { - if(G.enableLogService()) { - manager.notify(109, notificationBuilder.setOngoing(false) - .setCategory(NotificationCompat.CATEGORY_EVENT) - .setVisibility(NotificationCompat.VISIBILITY_SECRET) - .setContentText(logInfo.uidString) - .setSmallIcon(R.drawable.ic_block_black_24dp) - .setAutoCancel(true) - .build()); + // no notifications for packets without an app (kernel entry, formerly "unknown"), as before + if (G.enableLogService() && G.notifyBlocked() && logInfo.uid != -100 + && logInfo.uid != Api.SPECIAL_UID_KERNEL && G.canShow(logInfo.uid)) { + // collected and rate limited (see Notifications.blocked) + Notifications.blocked(ctx, logInfo); + } + } + + + + // --- Batching --- + + // Called on logProcessExecutor — no lock needed (single-threaded executor). + private void enqueueLog(LogData data) { + pendingLogs.add(data); + if (pendingLogs.size() >= LOG_FLUSH_BATCH_SIZE) { + flushPendingLogs(); + } else { + scheduleLogFlush(); + } + } + + private void scheduleLogFlush() { + if (scheduledFlush != null) { + scheduledFlush.cancel(false); + } + if (logProcessExecutor != null && !logProcessExecutor.isShutdown()) { + scheduledFlush = logProcessExecutor.schedule(this::flushPendingLogs, + LOG_FLUSH_INTERVAL_MS, java.util.concurrent.TimeUnit.MILLISECONDS); + } + } + + private void flushPendingLogs() { + if (pendingLogs.isEmpty()) return; + final List batch = new ArrayList<>(pendingLogs); + pendingLogs.clear(); + if (scheduledFlush != null) { + scheduledFlush.cancel(false); + scheduledFlush = null; } + FlowManager.getDatabase(LogDatabase.class) + .beginTransactionAsync(dw -> { for (LogData d : batch) d.save(dw); }) + .build().execute(); } + // --- Store --- + private void store(final LogInfo logInfo, Context context) { + store(logInfo, context, false); + } - private static void store(final LogInfo logInfo, Context context) { + private void store(final LogInfo logInfo, Context context, boolean isRetry) { try { if (logInfo != null) { LogData data = new LogData(); @@ -292,25 +925,38 @@ private static void store(final LogInfo logInfo, Context context) { data.setSpt(logInfo.spt); data.setUid(logInfo.uid); data.setAppName(logInfo.appName); - data.setType(logInfo.type); - if (G.isDoKey(context) || G.isDonate()) { - try { - data.setHostname(logInfo.host != null ? logInfo.host : ""); - } catch (Exception e) { - } - } data.setType(0); - FlowManager.getDatabase(LogDatabase.class).beginTransactionAsync(databaseWrapper -> - data.save(databaseWrapper)).build().execute(); + + // Resolve hostname asynchronously. If DNS returns before the batch flushes, + // hostname is included for free. If it returns after, the async save below + // updates the already-saved record via its primary key. + if (G.showHost() && logInfo.dst != null && !logInfo.dst.isEmpty()) { + final String dstIp = logInfo.dst; + final LogData dataRef = data; + new Thread(() -> { + try { + String hostname = java.net.InetAddress.getByName(dstIp).getHostName(); + if (hostname != null && !hostname.equals(dstIp)) { + dataRef.setHostname(hostname); + FlowManager.getDatabase(LogDatabase.class) + .beginTransactionAsync(dw -> dataRef.save(dw)) + .build().execute(); + } + } catch (Exception e) { + // DNS resolution failed; hostname stays empty + } + }, "LogService-DNS-" + dstIp.hashCode()).start(); + } + + enqueueLog(data); } } catch (IllegalStateException e) { - if (e.getMessage().contains("connection pool has been closed")) { - //reconnect logic + if (!isRetry && e.getMessage() != null && e.getMessage().contains("connection pool has been closed")) { try { FlowManager.init(new FlowConfig.Builder(context).build()); - store(logInfo,context); + store(logInfo, context, true); } catch (Exception de) { - Log.e(TAG, "Exception while saving log data:" + e.getLocalizedMessage(), de); + Log.e(TAG, "Exception while saving log data (retry):" + de.getLocalizedMessage(), de); } } Log.e(TAG, "Exception while saving log data:" + e.getLocalizedMessage(), e); @@ -321,27 +967,103 @@ private static void store(final LogInfo logInfo, Context context) { @Override public void onDestroy() { - Log.d(TAG, "Log service onDestroy"); + instance = null; + + // Stop health checks first so they don't restart the watcher mid-shutdown. + if (healthHandler != null) { + healthHandler.removeCallbacks(healthCheck); + healthHandler = null; + } + + // Set shutdown flag to prevent new tasks from starting. + isShuttingDown = true; + + // Close log watcher shell first to stop generating new tasks. + closeLogWatcher(); + + // Flush any pending log entries, then drain the executor. + if (logProcessExecutor != null && !logProcessExecutor.isShutdown()) { + logProcessExecutor.execute(this::flushPendingLogs); + logProcessExecutor.shutdown(); + } + logProcessExecutor = null; + + // Shutdown the shell-submission executor. if(executorService != null) { - executorService.shutdownNow(); + try { + executorService.shutdown(); + if (!executorService.awaitTermination(2000, java.util.concurrent.TimeUnit.MILLISECONDS)) { + Log.w(TAG, "ExecutorService did not terminate gracefully, forcing shutdown"); + executorService.shutdownNow(); + if (!executorService.awaitTermination(1000, java.util.concurrent.TimeUnit.MILLISECONDS)) { + Log.w(TAG, "ExecutorService did not terminate after force shutdown"); + } + } + } catch (InterruptedException e) { + Log.w(TAG, "Interrupted while shutting down ExecutorService"); + executorService.shutdownNow(); + Thread.currentThread().interrupt(); + } } executorService = null; + + // Update FirewallService notification if it's running. + if (FirewallService.isInstanceRunning()) { + FirewallService.setLogServiceActive(false); + Log.i(TAG, "Notified FirewallService that log monitoring stopped"); + } else { + try { + stopForeground(true); + Log.i(TAG, "Stopped foreground service"); + } catch (Exception e) { + Log.w(TAG, "Error stopping foreground service: " + e.getMessage()); + } + } + + cleanupTempFiles(); + super.onDestroy(); } @Override public void onTaskRemoved(Intent rootIntent) { super.onTaskRemoved(rootIntent); - Log.d(TAG, "Log service removed"); - Intent intent = new Intent(getApplicationContext(), LogService.class); - PendingIntent pendingIntent = PendingIntent.getService(this, 1, intent, PendingIntent.FLAG_MUTABLE); - AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE); - alarmManager.set(AlarmManager.RTC_WAKEUP, SystemClock.elapsedRealtime() + 000, pendingIntent); + // Restart service if log service is still enabled + if (G.enableLogService()) { + Intent intent = new Intent(getApplicationContext(), LogService.class); + // Must be a foreground-service start on 8+ (a background start from an alarm is refused), + // and the trigger time must use the same clock as the alarm type. + PendingIntent pendingIntent = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + ? PendingIntent.getForegroundService(this, 1, intent, PendingIntent.FLAG_IMMUTABLE) + : PendingIntent.getService(this, 1, intent, PendingIntent.FLAG_IMMUTABLE); + AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE); + alarmManager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, SystemClock.elapsedRealtime() + 5000, pendingIntent); + } + + // Clean up resources gracefully + if(logWatcherShell != null && !logWatcherShell.isAlive()) { + try { + logWatcherShell.close(); + } catch (Exception e) { + Log.w(TAG, "Error closing shell in onTaskRemoved: " + e.getMessage()); + } + logWatcherShell = null; + } + if(executorService != null) { - executorService.shutdownNow(); + try { + executorService.shutdown(); + if (!executorService.awaitTermination(1000, java.util.concurrent.TimeUnit.MILLISECONDS)) { + executorService.shutdownNow(); + } + } catch (InterruptedException e) { + executorService.shutdownNow(); + Thread.currentThread().interrupt(); + } } executorService = null; - super.onTaskRemoved(rootIntent); + + cleanupTempFiles(); } } diff --git a/app/src/main/java/dev/ukanth/ufirewall/service/RootCommand.java b/app/src/main/java/dev/ukanth/ufirewall/service/RootCommand.java index f390c0698..065158277 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/service/RootCommand.java +++ b/app/src/main/java/dev/ukanth/ufirewall/service/RootCommand.java @@ -29,15 +29,14 @@ public class RootCommand { public boolean done = false; public int hash = -1; public boolean isv6 = false; + /** set for the current command when it is a "#WARN# " line (failure is recorded, not fatal) */ + public boolean warnOnError; + /** failures of "#WARN# " commands (e.g. custom script lines), "command: output" */ + public final List warnings = new ArrayList<>(); private List commmands; - private RootShellService rootShellService; - private RootShellService2 rootShellService2; - public RootCommand() { - rootShellService = new RootShellService(); - rootShellService2 = new RootShellService2(); } @@ -140,10 +139,16 @@ public RootCommand setRetryExitCode(int retryExitCode) { * @param script List of commands to run as root */ public final void run(Context ctx, List script) { - if (rootShellService == null) { - rootShellService = new RootShellService(); - } - rootShellService.runScriptAsRoot(ctx, script, this); + this.isv6 = false; + RootShellService.ENGINE.submit(ctx, script, this); + } + + /** + * Run several scripts back to back (each RootCommand must already have its commands set via + * {@link #setCommmands(List)}); no other root command can run in between them. + */ + public static void runAll(Context ctx, List batch) { + RootShellService.ENGINE.submitAll(ctx, batch); } /** @@ -153,11 +158,8 @@ public final void run(Context ctx, List script) { * @param script List of commands to run as root */ public final void run(Context ctx, List script, boolean isv6) { - if (rootShellService2 == null) { - rootShellService2 = new RootShellService2(); - } - - rootShellService2.runScriptAsRoot(ctx, script, this); + this.isv6 = isv6; + RootShellService.ENGINE.submit(ctx, script, this); } /** @@ -167,12 +169,10 @@ public final void run(Context ctx, List script, boolean isv6) { * @param cmd Command to run as root */ public final void run(Context ctx, String cmd) { - if (rootShellService == null) { - rootShellService = new RootShellService(); - } List script = new ArrayList(); script.add(cmd); - rootShellService.runScriptAsRoot(ctx, script, this); + this.isv6 = false; + RootShellService.ENGINE.submit(ctx, script, this); } public static abstract class Callback { diff --git a/app/src/main/java/dev/ukanth/ufirewall/service/RootShellEngine.java b/app/src/main/java/dev/ukanth/ufirewall/service/RootShellEngine.java new file mode 100644 index 000000000..de8db9d32 --- /dev/null +++ b/app/src/main/java/dev/ukanth/ufirewall/service/RootShellEngine.java @@ -0,0 +1,517 @@ +/** + * Serialized execution of RootCommand scripts on a persistent root shell. + *

+ * Copyright (C) 2013 Kevin Cernekee + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *

+ * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package dev.ukanth.ufirewall.service; + +import android.content.Context; +import android.content.Intent; +import android.os.SystemClock; + +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import com.topjohnwu.superuser.Shell; + +import java.util.ArrayDeque; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import dev.ukanth.ufirewall.Api; +import dev.ukanth.ufirewall.log.Log; +import dev.ukanth.ufirewall.util.G; + +/** + * All engine state is confined to a single worker thread: public entry points and libsu + * callbacks only post work to it. This gives a real FIFO queue (one RootCommand runs at a time, + * never interleaved with another), and RootCommand callbacks are always invoked on that thread. + *

+ * Every submitted RootCommand is guaranteed to complete exactly once: on success, on a command + * error, when the shell dies, when root is unavailable, or when the stall guard fires. + */ +final class RootShellEngine { + + static final int EXIT_NO_ROOT_ACCESS = -1; + static final int EXIT_STALLED = -3; + // same codes as libsuperuser, which this engine used before: a command ran longer than + // WATCHDOG_TIMEOUT_SEC / the shell died + static final int EXIT_WATCHDOG = -1; + static final int EXIT_SHELL_DIED = -2; + + // Per-command watchdog: a command that doesn't finish in time kills the shell. Must stay well + // above the "iptables -w 5" lock wait, otherwise waiting for the xtables lock during network + // changes kills the shell. + private static final int WATCHDOG_TIMEOUT_SEC = 30; + // How long su may take to start (includes the superuser grant prompt). + private static final int OPEN_TIMEOUT_SEC = 30; + // If a script (or opening the shell) makes no progress for this long, give up on it. + private static final long STALL_TIMEOUT_MS = 90_000; + private static final long STALL_CHECK_INTERVAL_MS = 10_000; + // Minimum interval between automatic reopen attempts after a shell that once worked died. + private static final long AUTO_REOPEN_INTERVAL_MS = 10_000; + private static final int MAX_RETRIES = 10; + private static final String FALLBACK_MARKER = " # __FALLBACK_ATTEMPTED__"; + /** command prefix: record a failure as a warning on the RootCommand instead of failing it */ + static final String WARN = "#WARN# "; + + private enum State {INIT, OPENING, READY, BUSY, FAIL} + + private final String tag; + private final String label; + + private final ScheduledExecutorService worker; + + // ---- confined to the worker thread ---- + private final ArrayDeque queue = new ArrayDeque<>(); + private State state = State.INIT; + // a dedicated shell, not libsu's shared main shell: the rule scripts must not queue behind (or + // hold up) the app's other root commands, and stderr is kept apart from stdout + private Shell session; + private RootCommand current; + private java.util.concurrent.ScheduledFuture commandWatchdog; + private long dispatchToken; + private long openToken; + private long lastProgress; + private long lastOpenAttempt; + private boolean everOpened; + private Context appCtx; + private boolean stallGuardStarted; + + RootShellEngine(String tag, String label) { + this.tag = tag; + this.label = label; + this.worker = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "AFWall-RootShell" + label); + t.setDaemon(true); + return t; + }); + } + + /** + * Queue a script for execution. Safe to call from any thread, including from inside a + * RootCommand callback. + */ + void submit(Context ctx, List cmds, RootCommand cmd) { + enqueue(ctx, java.util.Collections.singletonList(cmd), cmds); + } + + /** + * Queue several RootCommands (each with its commands already set) back to back, so that no + * other submission can run in between them. + */ + void submitAll(Context ctx, List batch) { + enqueue(ctx, batch, null); + } + + /** + * @param cmds commands for a single-item batch, or null when each RootCommand already has its + * commands set + */ + private void enqueue(Context ctx, List batch, List cmds) { + final Context app = ctx != null ? ctx.getApplicationContext() : null; + final List items = new java.util.ArrayList<>(batch); + worker.execute(() -> { + if (appCtx == null && app != null) { + appCtx = app; + } + for (RootCommand cmd : items) { + if (cmd == current || queue.contains(cmd)) { + // Re-submitting a pending RootCommand would replace the commands it is about + // to run and fire its callback twice; keep the first submission. + List pending = cmd.getCommmands(); + Log.e(tag, "RootCommand submitted again while still pending; ignoring. Pending script starts with: " + + (pending != null && !pending.isEmpty() ? pending.get(0) : "")); + continue; + } + if (cmds != null) { + cmd.setCommmands(cmds); + } + cmd.commandIndex = 0; + cmd.retryCount = 0; + cmd.exitCode = 0; + cmd.done = false; + cmd.warnings.clear(); + queue.add(cmd); + } + startStallGuard(); + pump(); + }); + } + + private void pump() { + if (state == State.BUSY || state == State.OPENING || queue.isEmpty()) { + return; + } + if (state == State.INIT) { + openShell(); + return; + } + if (state == State.READY && (session == null || !session.isAlive())) { + Log.w(tag, "Root shell(" + label + ") is no longer running"); + state = State.FAIL; + } + if (state == State.FAIL) { + if (canReopen(queue.peek())) { + openShell(); + } else { + // No usable shell and not allowed to reopen yet: fail what is queued now + while (!queue.isEmpty()) { + complete(queue.poll(), EXIT_NO_ROOT_ACCESS); + } + } + return; + } + current = queue.poll(); + state = State.BUSY; + lastProgress = SystemClock.elapsedRealtime(); + dispatch(); + } + + private boolean canReopen(RootCommand next) { + if (next != null && next.reopenShell) { + return true; + } + // A shell that worked before most likely died (watchdog, su daemon restart); retry it + // automatically, rate limited so a revoked grant doesn't turn into a prompt loop. + return everOpened && SystemClock.elapsedRealtime() - lastOpenAttempt >= AUTO_REOPEN_INTERVAL_MS; + } + + private void openShell() { + closeSession(); + state = State.OPENING; + lastOpenAttempt = lastProgress = SystemClock.elapsedRealtime(); + final long token = ++openToken; + Log.d(tag, "Starting root shell(" + label + ")..."); + Shell shell = null; + String failure = null; + try { + // Blocks until su is up (or refused); we are on the worker thread. build("su") runs + // exactly su: libsu's plain build() would fall back to a non-root sh. + shell = Shell.Builder.create() + .setFlags(0) // keep stderr separate: iptables reports its errors there + .setTimeout(OPEN_TIMEOUT_SEC) + .build("su"); + if (!shell.isRoot()) { + failure = "not a root shell"; + } + } catch (Exception e) { + failure = e.getClass().getSimpleName() + ": " + e.getMessage(); + } + onShellOpened(token, shell, failure); + } + + private void onShellOpened(long token, Shell shell, String failure) { + if (token != openToken || state != State.OPENING) { + closeQuietly(shell); // a newer shell superseded this one + return; + } + if (failure != null) { + Log.e(tag, "Can't open root shell(" + label + "): " + failure); + closeQuietly(shell); + state = State.FAIL; + failQueued(); + return; + } + session = shell; + Log.d(tag, "Root shell(" + label + ") is open"); + everOpened = true; + state = State.READY; + pump(); + } + + private void failQueued() { + while (!queue.isEmpty()) { + complete(queue.poll(), EXIT_NO_ROOT_ACCESS); + } + } + + private void dispatch() { + final RootCommand st = current; + final List cmds = st.getCommmands(); + // skip null entries instead of silently ending the script early + while (cmds != null && st.commandIndex < cmds.size() && cmds.get(st.commandIndex) == null) { + st.commandIndex++; + } + if (cmds == null || st.commandIndex >= cmds.size()) { + finishCurrent(0); + return; + } + String command = cmds.get(st.commandIndex); + sendUpdate(st); + st.ignoreExitCode = false; + st.warnOnError = false; + if (command.startsWith("#NOCHK# ")) { + command = command.replaceFirst("#NOCHK# ", ""); + st.ignoreExitCode = true; + } else if (command.startsWith(WARN)) { + // e.g. a custom script line: a failure is reported, but doesn't stop the script + command = command.substring(WARN.length()); + st.ignoreExitCode = true; + st.warnOnError = true; + } + st.lastCommand = command; + st.lastCommandResult = new StringBuilder(); + final long token = ++dispatchToken; + try { + final List out = new java.util.ArrayList<>(); + final List err = new java.util.ArrayList<>(); + session.newJob().add(command).to(out, err).submit(worker, + result -> onCommandResult(token, result.getCode(), out, err)); + // libsu has no per-command timeout + cancelCommandWatchdog(); + commandWatchdog = worker.schedule(() -> onCommandTimeout(token), WATCHDOG_TIMEOUT_SEC, TimeUnit.SECONDS); + } catch (Exception e) { + Log.e(tag, "Unable to queue command on root shell(" + label + ")", e); + closeSession(); + state = State.FAIL; + finishCurrent(EXIT_NO_ROOT_ACCESS); + } + } + + private void onCommandResult(long token, int exitCode, List output, List stderr) { + if (token != dispatchToken || current == null) { + return; // stale result for a command the stall guard already gave up on + } + final RootCommand st = current; + cancelCommandWatchdog(); + lastProgress = SystemClock.elapsedRealtime(); + if (output != null) { + for (String line : output) { + if (line != null && !line.isEmpty()) { + if (st.res != null) { + st.res.append(line).append("\n"); + } + st.lastCommandResult.append(line).append("\n"); + } + } + } + // iptables reports its errors on stderr; keep them for the failure log + if (stderr != null) { + for (String line : stderr) { + if (line != null && !line.isEmpty()) { + st.lastCommandResult.append(line).append("\n"); + } + } + } + + if (exitCode == Shell.Result.JOB_NOT_EXECUTED) { + // the shell died; nothing more can run on this session + Log.e(tag, "Root shell(" + label + ") could not run '" + st.lastCommand + "'"); + closeSession(); + state = State.FAIL; + finishCurrent(EXIT_SHELL_DIED); + return; + } + + // command not executable (126): retry once with the system iptables binary + if (exitCode == 126 && fallbackToSystemBinary(st)) { + Log.w(tag, "Built-in iptables failed with exit 126, retrying with system iptables"); + G.setBuiltinIptablesFailed(true); + dispatch(); + return; + } + + if (exitCode == st.retryExitCode && st.retryCount < MAX_RETRIES) { + st.retryCount++; + Log.d(tag, "command '" + st.lastCommand + "' exited with status " + exitCode + + ", retrying (attempt " + st.retryCount + "/" + MAX_RETRIES + ")"); + final long retryToken = dispatchToken; + worker.schedule(() -> { + if (current == st && dispatchToken == retryToken && state == State.BUSY) { + lastProgress = SystemClock.elapsedRealtime(); + dispatch(); + } + }, 100L * st.retryCount, TimeUnit.MILLISECONDS); + return; + } + + if (exitCode != 0 && st.warnOnError) { + String result = st.lastCommandResult.toString().trim(); + Log.w(tag, "command '" + st.lastCommand + "' exited with status " + exitCode + ": " + result); + st.warnings.add(st.lastCommand + (result.isEmpty() ? " (exit " + exitCode + ")" : ": " + result)); + } + + st.commandIndex++; + st.retryCount = 0; + + if (exitCode != 0 && !st.ignoreExitCode) { + Log.i(tag, "command '" + st.lastCommand + "' exited with status " + exitCode + + "\nOutput:\n" + st.lastCommandResult); + finishCurrent(exitCode); + } else if (st.commandIndex >= st.getCommmands().size()) { + finishCurrent(0); + } else { + dispatch(); + } + } + + /** + * The command didn't finish within the watchdog time: kill the shell, as libsuperuser's + * watchdog did. A hanging command would otherwise block every later script. + */ + private void onCommandTimeout(long token) { + if (token != dispatchToken || current == null || state != State.BUSY) { + return; // finished in time + } + Log.e(tag, "Command '" + current.lastCommand + "' on root shell(" + label + ") didn't finish within " + + WATCHDOG_TIMEOUT_SEC + "s; closing the shell"); + closeSession(); + state = State.FAIL; + finishCurrent(EXIT_WATCHDOG); + } + + private void cancelCommandWatchdog() { + if (commandWatchdog != null) { + commandWatchdog.cancel(false); + commandWatchdog = null; + } + } + + private void finishCurrent(int exitCode) { + RootCommand st = current; + current = null; + dispatchToken++; + if (state == State.BUSY) { + state = State.READY; + } + if (st != null) { + complete(st, exitCode); + } + pump(); + } + + private void complete(RootCommand st, int exitCode) { + st.exitCode = exitCode; + st.done = true; + try { + if (st.cb != null) { + st.cb.cbFunc(st); + } + } catch (Throwable t) { + // a failing callback must never wedge the queue + Log.e(tag, "RootCommand callback failed: " + android.util.Log.getStackTraceString(t)); + } + try { + if (appCtx != null) { + if (exitCode == 0 && st.successToast != RootShellService.NO_TOAST) { + Api.sendToastBroadcast(appCtx, appCtx.getString(st.successToast)); + } else if (exitCode != 0 && st.failureToast != RootShellService.NO_TOAST) { + Api.sendToastBroadcast(appCtx, appCtx.getString(st.failureToast)); + } + } + if (FirewallService.isInstanceRunning()) { + FirewallService.refreshNotification(); + } + } catch (Throwable t) { + Log.e(tag, "Error while reporting RootCommand completion: " + android.util.Log.getStackTraceString(t)); + } + } + + private void startStallGuard() { + if (stallGuardStarted) { + return; + } + stallGuardStarted = true; + worker.scheduleWithFixedDelay(this::checkStall, STALL_CHECK_INTERVAL_MS, STALL_CHECK_INTERVAL_MS, + TimeUnit.MILLISECONDS); + } + + private void checkStall() { + if (state != State.BUSY && state != State.OPENING) { + return; + } + long idle = SystemClock.elapsedRealtime() - lastProgress; + if (idle < STALL_TIMEOUT_MS) { + return; + } + Log.e(tag, "Root shell(" + label + ") made no progress for " + idle + "ms in state " + state + + (current != null ? " on '" + current.lastCommand + "'" : "") + "; resetting"); + boolean wasOpening = state == State.OPENING; + openToken++; + closeSession(); + state = State.FAIL; + if (wasOpening) { + failQueued(); + } else { + finishCurrent(EXIT_STALLED); + } + } + + private void closeSession() { + closeQuietly(session); + session = null; + } + + private void closeQuietly(Shell shell) { + if (shell != null) { + try { + shell.close(); + } catch (Exception e) { + Log.w(tag, "Error closing root shell(" + label + "): " + e.getMessage()); + } + } + } + + private void sendUpdate(RootCommand st) { + if (appCtx == null) { + return; + } + Intent intent = new Intent(st.isv6 ? "UPDATEUI6" : "UPDATEUI4"); + intent.putExtra("SIZE", st.getCommmands().size()); + intent.putExtra("INDEX", st.commandIndex); + LocalBroadcastManager.getInstance(appCtx).sendBroadcast(intent); + } + + /** + * Replace built-in iptables/ip6tables paths with system paths in the current command. + * + * @return true if the command was rewritten and should be retried + */ + private boolean fallbackToSystemBinary(RootCommand st) { + if (appCtx == null || st.lastCommand == null || st.lastCommand.contains(FALLBACK_MARKER)) { + return false; + } + String builtinDir = appCtx.getDir("bin", 0).getAbsolutePath(); + if (!st.lastCommand.contains(builtinDir)) { + return false; + } + String systemIptables = Api.findSystemBinary("iptables"); + String systemIp6tables = Api.findSystemBinary("ip6tables"); + String updated = st.lastCommand; + if (systemIptables != null) { + updated = updated.replace(builtinDir + "/iptables", systemIptables); + } + if (systemIp6tables != null) { + updated = updated.replace(builtinDir + "/ip6tables", systemIp6tables); + } + if (updated.equals(st.lastCommand)) { + Log.w(tag, "No system iptables found for fallback"); + return false; + } + List commands = st.getCommmands(); + if (st.commandIndex >= commands.size()) { + return false; + } + String original = commands.get(st.commandIndex); + String prefix = original.startsWith("#NOCHK# ") ? "#NOCHK# " : ""; + commands.set(st.commandIndex, prefix + updated + FALLBACK_MARKER); + Log.i(tag, "Fallback applied: " + st.lastCommand + " -> " + updated); + return true; + } +} diff --git a/app/src/main/java/dev/ukanth/ufirewall/service/RootShellService.java b/app/src/main/java/dev/ukanth/ufirewall/service/RootShellService.java index acc36c80f..3c7cecaa1 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/service/RootShellService.java +++ b/app/src/main/java/dev/ukanth/ufirewall/service/RootShellService.java @@ -22,243 +22,31 @@ package dev.ukanth.ufirewall.service; -import static dev.ukanth.ufirewall.service.RootShellService.ShellState.INIT; - -import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.app.PendingIntent; import android.app.Service; -import android.content.Context; import android.content.Intent; -import android.os.Build; import android.os.IBinder; import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; -import androidx.core.app.TaskStackBuilder; -import androidx.localbroadcastmanager.content.LocalBroadcastManager; import java.util.ArrayList; -import java.util.Date; -import java.util.LinkedList; import java.util.List; -import java.util.ListIterator; -import java.util.NoSuchElementException; -import java.util.Timer; -import java.util.TimerTask; -import dev.ukanth.ufirewall.Api; -import dev.ukanth.ufirewall.MainActivity; import dev.ukanth.ufirewall.R; import dev.ukanth.ufirewall.log.Log; -import dev.ukanth.ufirewall.util.G; -import eu.chainfire.libsuperuser.Debug; -import eu.chainfire.libsuperuser.Shell; - -public class RootShellService extends Service implements Cloneable { +/** + * Single root shell used for all root commands (IPv4 and IPv6). The work is done by a + * {@link RootShellEngine}, which runs every RootCommand in submission order; this Service only + * exists for the manifest entry. + */ +public class RootShellService extends Service { public static final String TAG = "AFWall"; - public static final int NOTIFICATION_ID = 33347; - public static final int EXIT_NO_ROOT_ACCESS = -1; + public static final int NOTIFICATION_ID = 1; + public static final int EXIT_NO_ROOT_ACCESS = RootShellEngine.EXIT_NO_ROOT_ACCESS; public static final int NO_TOAST = -1; - /* write command completion times to logcat */ - private static final boolean enableProfiling = false; - //number of retries - increase the count - private final static int MAX_RETRIES = 10; - private static Shell.Interactive rootSession; - private Context mContext; - private NotificationManager notificationManager; - private static ShellState rootState = INIT; - private final LinkedList waitQueue = new LinkedList<>(); - - private void complete(final RootCommand state, int exitCode) { - if (enableProfiling) { - Log.d(TAG, "RootShell: " + state.getCommmands().size() + " commands completed in " + - (new Date().getTime() - state.startTime.getTime()) + " ms"); - } - state.exitCode = exitCode; - state.done = true; - if (state.cb != null) { - state.cb.cbFunc(state); - } - - if (exitCode == 0 && state.successToast != NO_TOAST) { - Api.sendToastBroadcast(mContext, mContext.getString(state.successToast)); - } else if (exitCode != 0 && state.failureToast != NO_TOAST) { - Api.sendToastBroadcast(mContext, mContext.getString(state.failureToast)); - } - - if (notificationManager != null) { - notificationManager.cancel(NOTIFICATION_ID); - } - } - - private void runNextSubmission() { - - do { - RootCommand state; - try { - state = waitQueue.remove(); - } catch (NoSuchElementException e) { - // nothing left to do - if (rootState == ShellState.BUSY) { - rootState = ShellState.READY; - } - break; - } - if (state != null) { - //same as last one. ignore it - Log.i(TAG, "Start processing next state(4)"); - if (enableProfiling) { - state.startTime = new Date(); - } - if (rootState == ShellState.FAIL) { - // if we don't have root, abort all queued commands - complete(state, EXIT_NO_ROOT_ACCESS); - //continue; - } else if (rootState == ShellState.READY) { - rootState = ShellState.BUSY; - if (G.isRun()) { - createNotification(mContext); - } - processCommands(state); - } - } - } while (false); - } - - private void processCommands(final RootCommand state) { - if (state.commandIndex < state.getCommmands().size() && state.getCommmands().get(state.commandIndex) != null) { - String command = state.getCommmands().get(state.commandIndex); - //Log.i("AFWall", command); - - //not to send conflicting status - if (!state.isv6) { - sendUpdate(state); - } - if (command != null) { - state.ignoreExitCode = false; - - if (command.startsWith("#NOCHK# ")) { - command = command.replaceFirst("#NOCHK# ", ""); - state.ignoreExitCode = true; - } - state.lastCommand = command; - state.lastCommandResult = new StringBuilder(); - try { - rootSession.addCommand(command, 0, (Shell.OnCommandResultListener2) (commandCode, exitCode, output, STDERR)-> { - ListIterator iter = output.listIterator(); - while (iter.hasNext()) { - String line = iter.next(); - if (line != null && !line.equals("")) { - if (state.res != null) { - state.res.append(line).append("\n"); - } - state.lastCommandResult.append(line).append("\n"); - } - } - if (exitCode >= 0 && exitCode == state.retryExitCode && state.retryCount < MAX_RETRIES) { - //lets wait for few ms before trying ? - state.retryCount++; - Log.d(TAG, "command '" + state.lastCommand + "' exited with status " + exitCode + - ", retrying (attempt " + state.retryCount + "/" + MAX_RETRIES + ")"); - processCommands(state); - return; - } - - state.commandIndex++; - state.retryCount = 0; - - boolean errorExit = exitCode != 0 && !state.ignoreExitCode; - if (state.commandIndex >= state.getCommmands().size() || errorExit) { - complete(state, exitCode); - if (exitCode < 0) { - rootState = ShellState.FAIL; - Log.e(TAG, "libsuperuser error " + exitCode + " on command '" + state.lastCommand + "'"); - } else { - if (errorExit) { - Log.i(TAG, "command '" + state.lastCommand + "' exited with status " + exitCode + - "\nOutput:\n" + state.lastCommandResult); - } - rootState = ShellState.READY; - } - runNextSubmission(); - } else { - processCommands(state); - } - }); - } catch (NullPointerException | ArrayIndexOutOfBoundsException e) { - Log.e(TAG, e.getMessage(), e); - } - } - } else { - complete(state, 0); - } - } - - private void sendUpdate(final RootCommand state2) { - new Thread(() -> { - Intent broadcastIntent = new Intent(); - broadcastIntent.setAction("UPDATEUI4"); - broadcastIntent.putExtra("SIZE", state2.getCommmands().size()); - broadcastIntent.putExtra("INDEX", state2.commandIndex); - LocalBroadcastManager.getInstance(mContext).sendBroadcast(broadcastIntent); - }).start(); - } - - private void createNotification(Context context) { - - String CHANNEL_ID = "firewall.apply"; - notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID); - Intent appIntent = new Intent(context, MainActivity.class); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - /* Create or update. */ - NotificationChannel channel = new NotificationChannel(CHANNEL_ID, context.getString(R.string.runNotification), - NotificationManager.IMPORTANCE_LOW); - channel.setDescription(""); - channel.setShowBadge(false); - channel.setSound(null, null); - channel.enableLights(false); - channel.enableVibration(false); - notificationManager.createNotificationChannel(channel); - } - - TaskStackBuilder stackBuilder = TaskStackBuilder.create(context); - stackBuilder.addParentStack(MainActivity.class); - stackBuilder.addNextIntent(appIntent); - PendingIntent resultPendingIntent = stackBuilder.getPendingIntent(0, PendingIntent.FLAG_IMMUTABLE); - builder.setContentIntent(resultPendingIntent); - - - int notifyType = G.getNotificationPriority(); - - Notification notification = builder.setSmallIcon(R.drawable.ic_apply) - .setAutoCancel(false) - .setContentTitle(context.getString(R.string.applying_rules)) - .setTicker(context.getString(R.string.app_name)) - .setChannelId(CHANNEL_ID) - .setCategory(Notification.CATEGORY_SERVICE) - .setVisibility(NotificationCompat.VISIBILITY_SECRET) - .setOnlyAlertOnce(true) - .setPriority(NotificationCompat.PRIORITY_LOW) - .setPriority(NotificationManager.IMPORTANCE_LOW) - .setContentText("").build(); - /*switch (notifyType) { - case 0: - notification.priority = NotificationCompat.PRIORITY_LOW; - break; - case 1: - notification.priority = NotificationCompat.PRIORITY_MIN; - break; - }*/ - builder.setProgress(0, 0, true); - notificationManager.notify(NOTIFICATION_ID, notification); - } + static final RootShellEngine ENGINE = new RootShellEngine(TAG, "main"); @Override public int onStartCommand(Intent intent, int flags, int startId) { @@ -272,97 +60,9 @@ public int onStartCommand(Intent intent, int flags, int startId) { return Service.START_STICKY; } - private void setupLogging() { - Debug.setDebug(false); - Debug.setLogTypeEnabled(Debug.LOG_ALL, false); - Debug.setLogTypeEnabled(Debug.LOG_GENERAL, false); - Debug.setSanityChecksEnabled(false); - Debug.setOnLogListener((type, typeIndicator, message) -> Log.i(TAG, "[libsuperuser] " + message)); - } - - - private void startShellInBackground() { - Log.d(TAG, "Starting root shell(4)..."); - setupLogging(); - //start only rootSession is null - if (rootSession == null) { - rootSession = new Shell.Builder(). - useSU(). - setWatchdogTimeout(5). - open((success, reason) -> { - if (reason < 0) { - Log.e(TAG, "Can't open root shell: exitCode " + reason); - rootState = ShellState.FAIL; - } else { - Log.d(TAG, "Root shell(4) is open"); - rootState = ShellState.READY; - } - runNextSubmission(); - }); - } - } - - private void reOpenShell(Context context) { - if (rootState == null || rootState != ShellState.READY || rootState == ShellState.FAIL) { - if (notificationManager != null) { - notificationManager.cancel(NOTIFICATION_ID); - } - rootState = ShellState.BUSY; - startShellInBackground(); - try { - Intent intent = new Intent(context, RootShellService.class); - context.startService(intent); - } catch (Exception e){ - Log.e(TAG, e.getMessage(),e); - } - } - } - - - public void runScriptAsRoot(Context ctx, List cmds, RootCommand state) { - Log.i(TAG, "Received cmds: #" + cmds.size()); - state.setCommmands(cmds); - state.commandIndex = 0; - state.retryCount = 0; - if (mContext == null) { - mContext = ctx.getApplicationContext(); - } - //already in memory and applied - //add it to queue - Log.d(TAG, "Hashing4...." + state.isv6); - - waitQueue.add(state); - - if (rootState == INIT || (rootState == ShellState.FAIL && state.reopenShell)) { - reOpenShell(ctx); - } else if (rootState != ShellState.BUSY) { - runNextSubmission(); - } else { - new Timer().schedule(new TimerTask() { - @Override - public void run() { - Log.i(TAG, "State of rootShell(4): " + rootState); - if (rootState == ShellState.BUSY) { - //try resetting state to READY forcefully - Log.i(TAG, "Forcefully changing the state " + rootState); - rootState = ShellState.READY; - } - runNextSubmission(); - } - }, 1000); - } - } - @Nullable @Override public IBinder onBind(Intent intent) { return null; } - - public enum ShellState { - INIT, - READY, - BUSY, - FAIL - } -} \ No newline at end of file +} diff --git a/app/src/main/java/dev/ukanth/ufirewall/service/RootShellService2.java b/app/src/main/java/dev/ukanth/ufirewall/service/RootShellService2.java deleted file mode 100644 index c82ec50bd..000000000 --- a/app/src/main/java/dev/ukanth/ufirewall/service/RootShellService2.java +++ /dev/null @@ -1,357 +0,0 @@ -/** - * Keep a persistent root shell running in the background - *

- * Copyright (C) 2013 Kevin Cernekee - *

- * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - *

- * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - *

- * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - * @author Kevin Cernekee - * @version 1.0 - */ - -package dev.ukanth.ufirewall.service; - -import static dev.ukanth.ufirewall.service.RootShellService2.ShellState2.INIT; - -import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.app.Service; -import android.content.Context; -import android.content.Intent; -import android.os.Build; -import android.os.IBinder; - -import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; -import androidx.core.app.TaskStackBuilder; -import androidx.localbroadcastmanager.content.LocalBroadcastManager; - -import java.util.ArrayList; -import java.util.Date; -import java.util.LinkedList; -import java.util.List; -import java.util.ListIterator; -import java.util.NoSuchElementException; -import java.util.Timer; -import java.util.TimerTask; - -import dev.ukanth.ufirewall.Api; -import dev.ukanth.ufirewall.MainActivity; -import dev.ukanth.ufirewall.R; -import dev.ukanth.ufirewall.log.Log; -import dev.ukanth.ufirewall.util.G; -import eu.chainfire.libsuperuser.Debug; -import eu.chainfire.libsuperuser.Shell; - - -public class RootShellService2 extends Service { - - public static final String TAG = "AFWall6"; - public static final int NOTIFICATION_ID = 33347; - public static final int EXIT_NO_ROOT_ACCESS = -1; - public static final int NO_TOAST = -1; - /* write command completion times to logcat */ - private static final boolean enableProfiling = false; - //number of retries - increase the count - private final static int MAX_RETRIES = 10; - private static Shell.Interactive rootSession2; - private Context mContext; - private NotificationManager notificationManager; - private static ShellState2 rootState = INIT; - private final LinkedList waitQueue = new LinkedList<>(); - - private void complete(final RootCommand state, int exitCode) { - if (enableProfiling) { - Log.d(TAG, "RootShell6: " + state.getCommmands().size() + " commands completed in " + - (new Date().getTime() - state.startTime.getTime()) + " ms"); - } - state.exitCode = exitCode; - state.done = true; - if (state.cb != null) { - state.cb.cbFunc(state); - } - - if (exitCode == 0 && state.successToast != NO_TOAST) { - Api.sendToastBroadcast(this.mContext, mContext.getString(state.successToast)); - } else if (exitCode != 0 && state.failureToast != NO_TOAST) { - Api.sendToastBroadcast(mContext, mContext.getString(state.failureToast)); - } - - if (notificationManager != null) { - notificationManager.cancel(NOTIFICATION_ID); - } - } - - private void runNextSubmission() { - - do { - RootCommand state; - try { - state = waitQueue.remove(); - } catch (NoSuchElementException e) { - // nothing left to do - if (rootState == ShellState2.BUSY) { - rootState = ShellState2.READY; - } - break; - } - if (state != null) { - //same as last one. ignore it - Log.i(TAG, "Start processing next state(6)"); - if (enableProfiling) { - state.startTime = new Date(); - } - if (rootState == ShellState2.FAIL) { - // if we don't have root, abort all queued commands - complete(state, EXIT_NO_ROOT_ACCESS); - //continue; - } else if (rootState == ShellState2.READY) { - rootState = ShellState2.BUSY; - if (G.isRun()) { - createNotification(mContext); - } - processCommands(state); - } - } - } while (false); - } - - private void processCommands(final RootCommand state) { - if (state.commandIndex < state.getCommmands().size() && state.getCommmands().get(state.commandIndex) != null) { - String command = state.getCommmands().get(state.commandIndex); - //Log.i("AFWall", command); - //not to send conflicting status - sendUpdate(state); - - if (command != null) { - state.ignoreExitCode = false; - - if (command.startsWith("#NOCHK# ")) { - command = command.replaceFirst("#NOCHK# ", ""); - state.ignoreExitCode = true; - } - state.lastCommand = command; - state.lastCommandResult = new StringBuilder(); - try { - rootSession2.addCommand(command, 0, (Shell.OnCommandResultListener2) (commandCode, exitCode, output, STDERR) -> { - ListIterator iter = output.listIterator(); - while (iter.hasNext()) { - String line = iter.next(); - if (line != null && !line.equals("")) { - if (state.res != null) { - state.res.append(line).append("\n"); - } - state.lastCommandResult.append(line).append("\n"); - } - } - if (exitCode >= 0 && exitCode == state.retryExitCode && state.retryCount < MAX_RETRIES) { - //lets wait for few ms before trying ? - state.retryCount++; - Log.d(TAG, "command '" + state.lastCommand + "' exited with status " + exitCode + - ", retrying (attempt " + state.retryCount + "/" + MAX_RETRIES + ")"); - processCommands(state); - return; - } - - state.commandIndex++; - state.retryCount = 0; - - boolean errorExit = exitCode != 0 && !state.ignoreExitCode; - if (state.commandIndex >= state.getCommmands().size() || errorExit) { - complete(state, exitCode); - if (exitCode < 0) { - rootState = ShellState2.FAIL; - Log.e(TAG, "libsuperuser error " + exitCode + " on command '" + state.lastCommand + "'"); - } else { - if (errorExit) { - Log.i(TAG, "command '" + state.lastCommand + "' exited with status " + exitCode + - "\nOutput:\n" + state.lastCommandResult); - } - rootState = ShellState2.READY; - } - runNextSubmission(); - } else { - processCommands(state); - } - }); - } catch (NullPointerException | ArrayIndexOutOfBoundsException e) { - Log.e(TAG, e.getMessage(), e); - } - } - } else { - complete(state, 0); - } - } - - private void sendUpdate(final RootCommand state2) { - new Thread(() -> { - Intent broadcastIntent = new Intent(); - broadcastIntent.setAction("UPDATEUI6"); - broadcastIntent.putExtra("SIZE", state2.getCommmands().size()); - broadcastIntent.putExtra("INDEX", state2.commandIndex); - LocalBroadcastManager.getInstance(this.mContext).sendBroadcast(broadcastIntent); - }).start(); - } - - private void createNotification(Context context) { - - String CHANNEL_ID = "firewall.apply"; - notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID); - - Intent appIntent = new Intent(context, MainActivity.class); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - /* Create or update. */ - NotificationChannel channel = new NotificationChannel(CHANNEL_ID, context.getString(R.string.runNotification), - NotificationManager.IMPORTANCE_LOW); - channel.setDescription(""); - channel.setShowBadge(false); - channel.setSound(null, null); - channel.enableLights(false); - channel.enableVibration(false); - if(G.getNotificationPriority() == 0) { - channel.setImportance(NotificationManager.IMPORTANCE_DEFAULT); - } - notificationManager.createNotificationChannel(channel); - } - - TaskStackBuilder stackBuilder = TaskStackBuilder.create(context); - stackBuilder.addParentStack(MainActivity.class); - stackBuilder.addNextIntent(appIntent); - PendingIntent resultPendingIntent = stackBuilder.getPendingIntent(0, PendingIntent.FLAG_IMMUTABLE); - builder.setContentIntent(resultPendingIntent); - - - int notifyType = G.getNotificationPriority(); - - Notification notification = builder.setSmallIcon(R.drawable.ic_apply) - .setAutoCancel(false) - .setContentTitle(context.getString(R.string.applying_rules)) - .setTicker(context.getString(R.string.app_name)) - .setPriority(NotificationCompat.PRIORITY_LOW) - .setChannelId(CHANNEL_ID) - .setCategory(Notification.CATEGORY_SERVICE) - .setVisibility(NotificationCompat.VISIBILITY_SECRET) - .setOnlyAlertOnce(true) - .setPriority(NotificationManager.IMPORTANCE_LOW) - .setContentText("").build(); - builder.setProgress(0, 0, true); - notificationManager.notify(NOTIFICATION_ID, notification); - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - if (intent == null) { // if crash restart... - Log.i(TAG, "Restarting RootShell..."); - List cmds = new ArrayList<>(); - cmds.add("true"); - new RootCommand().setFailureToast(R.string.error_su) - .setReopenShell(true).run(getApplicationContext(), cmds); - } - return Service.START_STICKY; - } - - private void setupLogging() { - Debug.setDebug(false); - Debug.setLogTypeEnabled(Debug.LOG_ALL, false); - Debug.setLogTypeEnabled(Debug.LOG_GENERAL, false); - Debug.setSanityChecksEnabled(false); - Debug.setOnLogListener((type, typeIndicator, message) -> Log.i(TAG, "[libsuperuser] " + message)); - } - - - private void startShellInBackground() { - Log.d(TAG, "Starting root shell(6)..."); - setupLogging(); - //start only rootSession is null - if (rootSession2 == null) { - rootSession2 = new Shell.Builder(). - useSU(). - setWatchdogTimeout(5). - open((success, reason) -> { - if (reason < 0) { - Log.e(TAG, "Can't open root shell: exitCode " + reason); - rootState = ShellState2.FAIL; - } else { - Log.d(TAG, "Root shell(6) is open"); - rootState = ShellState2.READY; - } - runNextSubmission(); - }); - } - - } - - private void reOpenShell(Context context) { - if (rootState == null || rootState != ShellState2.READY || rootState == ShellState2.FAIL) { - if (notificationManager != null) { - notificationManager.cancel(NOTIFICATION_ID); - } - rootState = ShellState2.BUSY; - startShellInBackground(); - Intent intent = new Intent(context, RootShellService2.class); - context.startService(intent); - } - } - - - public void runScriptAsRoot(Context ctx, List cmds, RootCommand state) { - Log.i(TAG, "Received cmds: #" + cmds.size()); - state.setCommmands(cmds); - state.commandIndex = 0; - state.retryCount = 0; - if (mContext == null) { - mContext = ctx.getApplicationContext(); - } - //already in memory and applied - //add it to queue - - waitQueue.add(state); - - if (rootState == INIT || (rootState == ShellState2.FAIL && state.reopenShell)) { - reOpenShell(ctx); - } else if (rootState != ShellState2.BUSY) { - runNextSubmission(); - } else { - new Timer().schedule(new TimerTask() { - @Override - public void run() { - Log.i(TAG, "State of rootShell(6): " + rootState); - if (rootState == ShellState2.BUSY) { - //try resetting state to READY forcefully - Log.i(TAG, "Forcefully changing the state " + rootState); - rootState = ShellState2.READY; - } - runNextSubmission(); - } - }, 1000); - } - } - - @Nullable - @Override - public IBinder onBind(Intent intent) { - return null; - } - - public enum ShellState2 { - INIT, - READY, - BUSY, - FAIL - } -} \ No newline at end of file diff --git a/app/src/main/java/dev/ukanth/ufirewall/service/RulesApplyService.java b/app/src/main/java/dev/ukanth/ufirewall/service/RulesApplyService.java index 51a9ab358..d3780a91d 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/service/RulesApplyService.java +++ b/app/src/main/java/dev/ukanth/ufirewall/service/RulesApplyService.java @@ -28,13 +28,10 @@ protected void onHandleIntent(Intent intent) { Log.d(Api.TAG, "Applying rules on connectivity change"); InterfaceTracker.applyRulesOnChange(context, InterfaceTracker.CONNECTIVITY_CHANGE); } - final Intent logIntent = new Intent(context, LogService.class); if (G.enableLogService()) { - context.stopService(logIntent); - context.startService(logIntent); + LogService.ensureRunning(context); } else { - context.stopService(logIntent); - //Api.cleanupUid(); + context.stopService(new Intent(context, LogService.class)); } } } diff --git a/app/src/main/java/dev/ukanth/ufirewall/service/ToggleTileService.java b/app/src/main/java/dev/ukanth/ufirewall/service/ToggleTileService.java index 5668e00c6..65ea2184e 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/service/ToggleTileService.java +++ b/app/src/main/java/dev/ukanth/ufirewall/service/ToggleTileService.java @@ -1,117 +1,109 @@ package dev.ukanth.ufirewall.service; +import android.annotation.SuppressLint; +import android.app.PendingIntent; +import android.content.ComponentName; import android.content.Context; -import android.content.SharedPreferences; +import android.content.Intent; import android.graphics.drawable.Icon; import android.os.Build; import android.service.quicksettings.Tile; import android.service.quicksettings.TileService; -import android.widget.Toast; import androidx.annotation.RequiresApi; import dev.ukanth.ufirewall.Api; import dev.ukanth.ufirewall.R; import dev.ukanth.ufirewall.log.Log; +import dev.ukanth.ufirewall.util.FirewallActions; import dev.ukanth.ufirewall.util.G; +import dev.ukanth.ufirewall.widget.WidgetActionActivity; @RequiresApi(api = Build.VERSION_CODES.N) public class ToggleTileService extends TileService { - @Override - public void onDestroy() { - super.onDestroy(); - } - - @Override - public void onTileAdded() { - super.onTileAdded(); - } - @Override - public void onTileRemoved() { - super.onTileRemoved(); + /** + * Ask the system to redraw the tile (it calls onStartListening) after the state changed. + */ + public static void requestRefresh(Context ctx) { + try { + requestListeningState(ctx, new ComponentName(ctx, ToggleTileService.class)); + } catch (Exception e) { + Log.w(G.TAG, "Unable to refresh the quick settings tile: " + e.getMessage()); + } } @Override public void onStartListening() { super.onStartListening(); - boolean status = Api.isEnabled(this); - Tile tile = getQsTile(); // this is getQsTile() method form java, used in Kotlin as a property - if (tile != null) { - if (!status) { - tile.setLabel(getString(R.string.inactive)); - tile.setIcon(Icon.createWithResource(this, R.drawable.notification_error)); - tile.setState(Tile.STATE_INACTIVE); - } else { - tile.setLabel(getString(R.string.active)); - tile.setIcon(Icon.createWithResource(this, R.drawable.notification)); - tile.setState(Tile.STATE_ACTIVE); - } - tile.updateTile(); - } + showState(Api.isEnabled(this)); } - @Override - public void onStopListening() { - super.onStopListening(); + private void showState(boolean enabled) { + Tile tile = getQsTile(); + if (tile == null) { + return; + } + if (enabled) { + tile.setLabel(getString(R.string.active)); + tile.setIcon(Icon.createWithResource(this, R.drawable.notification)); + tile.setState(Tile.STATE_ACTIVE); + } else { + tile.setLabel(getString(R.string.inactive)); + tile.setIcon(Icon.createWithResource(this, R.drawable.notification_error)); + tile.setState(Tile.STATE_INACTIVE); + } + tile.updateTile(); } - @Override public void onClick() { super.onClick(); - Context context = this; - //Start main activity - final SharedPreferences prefs = context.getSharedPreferences(Api.PREF_FIREWALL_STATUS, 0); - final boolean enabled = !prefs.getBoolean(Api.PREF_ENABLED, true); - + // quick settings are reachable on the lock screen: don't change the firewall there + if (isLocked()) { + unlockAndRun(this::toggle); + } else { + toggle(); + } + } - if (!G.protectionLevel().equals("p0") || G.enableDeviceCheck()) { - Toast.makeText(context, R.string.widget_disable_fail, Toast.LENGTH_SHORT).show(); + private void toggle() { + final boolean enable = !Api.isEnabled(this); + boolean mustAsk = (!enable && G.enableConfirm()) || isAppLocked(); + if (mustAsk) { + // the app lock / confirmation need a screen + launch(WidgetActionActivity.toggleIntent(this)); return; } + FirewallActions.setEnabled(this, enable, true, ok -> { + // the tile may no longer be listening: have it redrawn from the real state + requestRefresh(getApplicationContext()); + }); + } - Tile tile = getQsTile(); + private boolean isAppLocked() { + if (G.enableDeviceCheck()) { + return true; + } + switch (G.protectionLevel()) { + case "p1": + return G.profile_pwd().length() > 0; + case "p2": + return G.sPrefs.getString("LockPassword", "").length() > 0; + case "p3": + return G.isFingerprintEnabled(); + default: + return false; + } + } - if (tile != null) { - if (enabled) { - Api.applySavedIptablesRules(context, true, new RootCommand() - .setSuccessToast(R.string.toast_enabled) - .setFailureToast(R.string.toast_error_enabling) - .setReopenShell(true) - .setCallback(new RootCommand.Callback() { - public void cbFunc(RootCommand state) { - // setEnabled always sends us a STATUS_CHANGED_MSG intent to update the icon - try { - Api.setEnabled(context, state.exitCode == 0, true); - } catch (Exception e) { - Log.e(G.TAG, e.getLocalizedMessage(), e ); - } - tile.setState(Tile.STATE_ACTIVE); - tile.setLabel(getString(R.string.active)); - tile.setIcon(Icon.createWithResource(context, R.drawable.notification)); - tile.updateTile(); - } - })); - } else { - Api.purgeIptables(context, true, new RootCommand() - .setSuccessToast(R.string.toast_disabled) - .setFailureToast(R.string.toast_error_disabling) - .setReopenShell(true) - .setCallback(new RootCommand.Callback() { - public void cbFunc(RootCommand state) { - try { - Api.setEnabled(context, state.exitCode != 0, true); - } catch (Exception e) { - Log.e(G.TAG, e.getLocalizedMessage(), e ); - } - tile.setState(Tile.STATE_INACTIVE);// e() method form java, used in Kotlin as a property - tile.setLabel(getString(R.string.inactive)); - tile.setIcon(Icon.createWithResource(context, R.drawable.notification_error)); - tile.updateTile(); - } - })); - } + @SuppressLint("StartActivityAndCollapseDeprecated") + private void launch(Intent intent) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startActivityAndCollapse(PendingIntent.getActivity(this, 0, intent, + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT)); + } else { + startActivityAndCollapse(intent); } } } diff --git a/app/src/main/java/dev/ukanth/ufirewall/ui/about/AboutFragment.java b/app/src/main/java/dev/ukanth/ufirewall/ui/about/AboutFragment.java deleted file mode 100644 index 1dc883fbd..000000000 --- a/app/src/main/java/dev/ukanth/ufirewall/ui/about/AboutFragment.java +++ /dev/null @@ -1,116 +0,0 @@ -package dev.ukanth.ufirewall.ui.about; - -import android.os.Bundle; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.webkit.WebView; -import android.widget.TextView; - -import androidx.fragment.app.Fragment; - -import java.io.IOException; - -import dev.ukanth.ufirewall.Api; -import dev.ukanth.ufirewall.BuildConfig; -import dev.ukanth.ufirewall.R; -import dev.ukanth.ufirewall.util.G; - - -public class AboutFragment extends Fragment { - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup group, - Bundle saved) { - return inflater.inflate(R.layout.help_about_content, group, false); - } - - @Override - public void onActivityCreated(Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - - String version = BuildConfig.VERSION_NAME; - - TextView text = getActivity().findViewById(R.id.afwall_title); - String versionText = getString(R.string.app_name) + " (v" + version + ")"; - if(G.isDoKey(getActivity().getApplicationContext()) || BuildConfig.APPLICATION_ID.equals("dev.ukanth.ufirewall.donate")) { - versionText = versionText + " (Donate) " + getActivity().getString(R.string.donate_thanks)+ ":)"; - } - text.setText(versionText); - - WebView creditsWebView = getActivity().findViewById(R.id.about_thirdsparty_credits); - try { - String data = Api.loadData(getActivity().getBaseContext(), "about"); - creditsWebView.loadDataWithBaseURL(null, data, "text/html","UTF-8",null); - } catch (IOException ioe) { - Log.e(Api.TAG, "Error reading changelog file!", ioe); - } - } - - - /*class ActivitySwipeDetector implements View.OnTouchListener { - static final String logTag = "ActivitySwipeDetector"; - static final int MIN_DISTANCE = 100; - private float downX, downY, upX, upY; - - public ActivitySwipeDetector() { - } - - public void onRightToLeftSwipe(View v) { - Log.i(logTag, "RightToLeftSwipe!"); - } - - public void onLeftToRightSwipe(View v){ - Log.i(logTag, "LeftToRightSwipe!"); - } - - public void onTopToBottomSwipe(View v){ - Log.i(logTag, "onTopToBottomSwipe!"); - } - - public void onBottomToTopSwipe(View v){ - Log.i(logTag, "onBottomToTopSwipe!"); - Toast.makeText(getActivity(),"Swipe Works great",Toast.LENGTH_LONG).show(); - } - - public boolean onTouch(View v, MotionEvent event) { - switch(event.getAction()){ - case MotionEvent.ACTION_DOWN: { - downX = event.getX(); - downY = event.getY(); - return true; - } - case MotionEvent.ACTION_UP: { - upX = event.getX(); - upY = event.getY(); - - float deltaX = downX - upX; - float deltaY = downY - upY; - - // swipe horizontal? - if(Math.abs(deltaX) > MIN_DISTANCE){ - // left or right - if(deltaX < 0) { this.onLeftToRightSwipe(v); return true; } - if(deltaX > 0) { this.onRightToLeftSwipe(v); return true; } - } - else { - Log.i(logTag, "Swipe was only " + Math.abs(deltaX) + " long, need at least " + MIN_DISTANCE); - } - - // swipe vertical? - if(Math.abs(deltaY) > MIN_DISTANCE){ - // top or down - if(deltaY < 0) { this.onTopToBottomSwipe(v); return true; } - if(deltaY > 0) { this.onBottomToTopSwipe(v); return true; } - } - else { - Log.i(logTag, "Swipe was only " + Math.abs(deltaX) + " long, need at least " + MIN_DISTANCE); - v.performClick(); - } - } - } - return false; - } - }*/ -} diff --git a/app/src/main/java/dev/ukanth/ufirewall/ui/about/FAQFragment.java b/app/src/main/java/dev/ukanth/ufirewall/ui/about/FAQFragment.java deleted file mode 100644 index c95157dae..000000000 --- a/app/src/main/java/dev/ukanth/ufirewall/ui/about/FAQFragment.java +++ /dev/null @@ -1,43 +0,0 @@ -package dev.ukanth.ufirewall.ui.about; - -import android.os.Bundle; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.webkit.WebView; -import android.widget.TextView; - -import androidx.fragment.app.Fragment; - -import java.io.IOException; - -import dev.ukanth.ufirewall.Api; -import dev.ukanth.ufirewall.R; - - -public class FAQFragment extends Fragment { - private static final String TAG = "FAQFragment"; - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup group, - Bundle saved) { - return inflater.inflate(R.layout.help_faq_content, group, false); - } - - @Override - public void onActivityCreated(Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - - TextView text = getActivity().findViewById(R.id.faq_afwall_title); - text.setText(R.string.faq); - - WebView creditsWebView = getActivity().findViewById(R.id.faq_webview); - try { - String data = Api.loadData(getActivity().getBaseContext(), "faq"); - creditsWebView.loadDataWithBaseURL(null, data, "text/html","UTF-8",null); - } catch (IOException ioe) { - Log.e(TAG, "Error reading changelog file!", ioe); - } - } -} diff --git a/app/src/main/java/dev/ukanth/ufirewall/ui/about/ViewPagerAdapter.java b/app/src/main/java/dev/ukanth/ufirewall/ui/about/ViewPagerAdapter.java deleted file mode 100644 index ff6df3ae6..000000000 --- a/app/src/main/java/dev/ukanth/ufirewall/ui/about/ViewPagerAdapter.java +++ /dev/null @@ -1,41 +0,0 @@ -package dev.ukanth.ufirewall.ui.about; - -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; -import androidx.fragment.app.FragmentStatePagerAdapter; - -/** - * Created by ukanth on 2/5/15. - */ -public class ViewPagerAdapter extends FragmentStatePagerAdapter { - - CharSequence[] pageTitles; - int noOfTabs; - - - // Build a Constructor and assign the passed Values to appropriate values in the class - public ViewPagerAdapter(FragmentManager fm, CharSequence[] mTitles, int mNumbOfTabsumb) { - super(fm); - this.pageTitles = mTitles; - this.noOfTabs = mNumbOfTabsumb; - - } - @Override - public Fragment getItem(int position) { - if(position == 0) { - return new AboutFragment(); - } else { - return new FAQFragment(); - } - } - - @Override - public CharSequence getPageTitle(int position) { - return pageTitles[position]; - } - - @Override - public int getCount() { - return noOfTabs; - } -} diff --git a/app/src/main/java/dev/ukanth/ufirewall/util/ApkInfo.java b/app/src/main/java/dev/ukanth/ufirewall/util/ApkInfo.java new file mode 100644 index 000000000..a3f1eb99b --- /dev/null +++ b/app/src/main/java/dev/ukanth/ufirewall/util/ApkInfo.java @@ -0,0 +1,98 @@ +package dev.ukanth.ufirewall.util; + +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; + +/** + * Packages that PackageManager doesn't show us (package visibility, apps only in a work profile / + * Private Space) are listed with root "pm list packages -f -U". Their APK is readable without root, + * so their label and icon can be read from it. + */ +public final class ApkInfo { + + private ApkInfo() { + } + + /** + * One line of "pm list packages -U", with "-f" also the APK path. + */ + public static final class Line { + public final String packageName; + /** null without "-f" */ + public final String apkPath; + /** the first UID listed (the one of the user asked for with "--user") */ + public final int uid; + + Line(String packageName, String apkPath, int uid) { + this.packageName = packageName; + this.apkPath = apkPath; + this.uid = uid; + } + } + + /** + * Parse "package:com.example uid:10123" or "package:/data/app/.../base.apk=com.example + * uid:10123,1010123". + * + * @return null if the line isn't a package line + */ + public static Line parse(String line) { + if (line == null || !line.startsWith("package:")) { + return null; + } + String rest = line.substring("package:".length()); + int uidAt = rest.lastIndexOf(" uid:"); + if (uidAt <= 0) { + return null; + } + String body = rest.substring(0, uidAt).trim(); + String uids = rest.substring(uidAt + " uid:".length()).trim(); + int uid; + try { + int comma = uids.indexOf(','); + uid = Integer.parseInt(comma > 0 ? uids.substring(0, comma) : uids); + } catch (NumberFormatException e) { + return null; + } + String path = null; + String pkg = body; + // a package name has no '=', a path may + int eq = body.lastIndexOf('='); + if (body.startsWith("/") && eq > 0) { + path = body.substring(0, eq); + pkg = body.substring(eq + 1); + } + if (pkg.isEmpty() || pkg.contains(" ") || pkg.contains("/")) { + return null; + } + return new Line(pkg, path, uid); + } + + /** + * @return the application of {@code apkPath} for label and icon, with {@code uid}; null if + * the APK can't be read + */ + public static ApplicationInfo load(PackageManager pm, String apkPath, int uid) { + if (pm == null || apkPath == null) { + return null; + } + try { + PackageInfo info = pm.getPackageArchiveInfo(apkPath, 0); + if (info == null || info.applicationInfo == null) { + return null; + } + ApplicationInfo app = info.applicationInfo; + // needed to load resources (label, icon) from an APK that isn't "installed" for us + app.sourceDir = apkPath; + app.publicSourceDir = apkPath; + app.uid = uid; + if (!apkPath.startsWith("/data/")) { + app.flags |= ApplicationInfo.FLAG_SYSTEM; // preinstalled + } + return app; + } catch (Exception e) { + return null; + } + } +} diff --git a/app/src/main/java/dev/ukanth/ufirewall/util/AppIcons.java b/app/src/main/java/dev/ukanth/ufirewall/util/AppIcons.java new file mode 100644 index 000000000..8bdf254c8 --- /dev/null +++ b/app/src/main/java/dev/ukanth/ufirewall/util/AppIcons.java @@ -0,0 +1,198 @@ +package dev.ukanth.ufirewall.util; + +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.os.UserHandle; +import android.util.LruCache; +import android.view.View; + +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import androidx.core.content.ContextCompat; +import androidx.core.graphics.drawable.DrawableCompat; + +import dev.ukanth.ufirewall.Api; +import dev.ukanth.ufirewall.R; + +/** + * App icons by UID for lists that only know the UID (the log). Loaded off the main thread and + * cached; apps of a work profile / Private Space get the profile badge. + */ +public final class AppIcons { + + private static final int PER_USER_RANGE = 100000; + private static final LruCache cache = new LruCache<>(150); + private static final ExecutorService executor = Executors.newFixedThreadPool(2); + private static final Handler main = new Handler(Looper.getMainLooper()); + + private AppIcons() { + } + + /** + * Show the icon of {@code uid} as the background of {@code view}: the default icon at once, + * the app's icon when loaded (unless the view shows another UID by then). + */ + public static void showAsBackground(Context ctx, int uid, View view) { + final Context app = ctx.getApplicationContext(); + view.setTag(R.id.app_icon, uid); + Drawable special = special(ctx, uid); + if (special != null) { + view.setBackground(special); + return; + } + Drawable cached = cache.get(uid); + if (cached != null) { + view.setBackground(copy(app, cached)); + return; + } + view.setBackground(ThemeHelper.defaultAndroidIcon(ctx)); + executor.execute(() -> { + Drawable loaded = load(app, uid); + if (loaded == null && uid > 0 && uid < android.os.Process.FIRST_APPLICATION_UID) { + loaded = systemService(app); // a system UID without an app icon (dns, network stack, ...) + } + if (loaded == null) { + return; + } + final Drawable icon = loaded; + cache.put(uid, icon); + main.post(() -> { + Object shown = view.getTag(R.id.app_icon); + if (shown instanceof Integer && (Integer) shown == uid) { + view.setBackground(copy(app, icon)); + } + }); + }); + } + + /** + * @return the icon of a special entry of the app list (kernel, root, tethering, ...), tinted + * like the default icon; null if {@code uid} isn't one + */ + public static Drawable special(Context ctx, int uid) { + int res; + switch (uid) { + case Api.SPECIAL_UID_ANY: res = R.drawable.ic_special_any; break; + case Api.SPECIAL_UID_KERNEL: res = R.drawable.ic_special_kernel; break; + case Api.SPECIAL_UID_TETHER: res = R.drawable.ic_tether; break; + case Api.SPECIAL_UID_NTP: res = R.drawable.ic_special_time; break; + case 0: // root + case 1011: // adb + case 2000: // shell + res = R.drawable.ic_special_code; break; + case 1013: res = R.drawable.ic_special_media; break; + case 1016: res = R.drawable.ic_vpn; break; + case 1019: res = R.drawable.ic_special_lock; break; + case 1021: res = R.drawable.ic_special_location; break; + default: + return null; + } + return tinted(ctx, res); + } + + /** + * @return the icon of an Android service without an app (dns, mdnsr, clat, manufacturer + * services, ...) + */ + public static Drawable systemService(Context ctx) { + return tinted(ctx, R.drawable.ic_special_service); + } + + private static Drawable tinted(Context ctx, int res) { + Drawable d = ContextCompat.getDrawable(ctx, res); + if (d == null) { + return null; + } + d = DrawableCompat.wrap(d.mutate()); + DrawableCompat.setTint(d, G.defaultIconColor(ctx)); + return d; + } + + /** + * Run {@code load} off the main thread, then {@code done} on it. + */ + public static void load(Runnable load, Runnable done) { + executor.execute(() -> { + try { + load.run(); + } catch (Exception e) { + return; + } + main.post(done); + }); + } + + /** + * Add the work profile / Private Space badge for an app of another user. + */ + public static Drawable badge(PackageManager pm, Drawable icon, int uid) { + if (icon == null || uid < PER_USER_RANGE || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + return icon; + } + try { + return pm.getUserBadgedIcon(icon, UserHandle.getUserHandleForUid(uid)); + } catch (Exception e) { + return icon; + } + } + + /** + * @return the app icon of {@code uid}, null if it has none (special entries, unknown UIDs) + */ + private static Drawable load(Context ctx, int uid) { + if (uid < 0) { + return null; + } + PackageManager pm = ctx.getPackageManager(); + ApplicationInfo info = findApp(pm, uid); + if (info == null && uid >= PER_USER_RANGE) { + // an app of another user not in the list: the same app of the main user + info = findApp(pm, uid % PER_USER_RANGE); + } + if (info == null || info.icon == 0) { + return null; + } + try { + return badge(pm, pm.getApplicationIcon(info), uid); + } catch (Exception e) { + return null; + } + } + + private static ApplicationInfo findApp(PackageManager pm, int uid) { + // the app list knows the apps PackageManager doesn't show us (read from their APK) + List apps = Api.applications; + if (apps != null) { + try { + for (Api.PackageInfoData data : apps) { + if (data.uid == uid && data.appinfo != null && data.appinfo.icon != 0) { + return data.appinfo; + } + } + } catch (RuntimeException ignored) { + // the list changed meanwhile: PackageManager below + } + } + try { + String[] pkgs = pm.getPackagesForUid(uid); + if (pkgs != null && pkgs.length > 0) { + return pm.getApplicationInfo(pkgs[0], 0); + } + } catch (Exception ignored) { + } + return null; + } + + private static Drawable copy(Context ctx, Drawable icon) { + // one drawable must not be shown by two views + Drawable.ConstantState state = icon.getConstantState(); + return state != null ? state.newDrawable(ctx.getResources()) : icon; + } +} diff --git a/app/src/main/java/dev/ukanth/ufirewall/util/AppListArrayAdapter.java b/app/src/main/java/dev/ukanth/ufirewall/util/AppListArrayAdapter.java index 11fe46c01..f3647ce06 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/util/AppListArrayAdapter.java +++ b/app/src/main/java/dev/ukanth/ufirewall/util/AppListArrayAdapter.java @@ -6,8 +6,6 @@ import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; import android.graphics.drawable.Drawable; -import android.graphics.drawable.ScaleDrawable; -import android.os.AsyncTask; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -16,15 +14,39 @@ import android.widget.CompoundButton; import android.widget.ImageView; import android.widget.TextView; +import android.widget.LinearLayout; import java.util.List; +import java.util.HashSet; +import java.util.Set; + +import com.raizlabs.android.dbflow.sql.language.SQLite; import dev.ukanth.ufirewall.Api; import dev.ukanth.ufirewall.Api.PackageInfoData; import dev.ukanth.ufirewall.MainActivity; import dev.ukanth.ufirewall.R; import dev.ukanth.ufirewall.activity.AppDetailActivity; +import dev.ukanth.ufirewall.activity.AppRulesActivity; import dev.ukanth.ufirewall.log.Log; +import dev.ukanth.ufirewall.log.LogPreference; +import dev.ukanth.ufirewall.log.LogPreference_Table; +import dev.ukanth.ufirewall.log.LogData; +import dev.ukanth.ufirewall.log.LogData_Table; +import dev.ukanth.ufirewall.util.AppRuleHelper; +import dev.ukanth.ufirewall.util.G; +import dev.ukanth.ufirewall.util.DataUsageParser; +import dev.ukanth.ufirewall.util.ThemeHelper; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import android.content.res.ColorStateList; +import androidx.core.content.ContextCompat; +import androidx.core.widget.CompoundButtonCompat; public class AppListArrayAdapter extends ArrayAdapter { @@ -35,6 +57,7 @@ public class AppListArrayAdapter extends ArrayAdapter { private final Activity activity; private boolean useOld = false; + private Set expandedPositions = new HashSet<>(); //final int color = G.sysColor(); //final int defaultColor = Color.WHITE; @@ -46,6 +69,7 @@ public AppListArrayAdapter(MainActivity activity, Context context, List apps) { super(context, R.layout.main_list, apps); this.activity = activity; @@ -133,39 +157,31 @@ public View getView(final int position, View convertView, ViewGroup parent) { holder.app = listApps.get(position); - if (G.showUid()) { - holder.text.setText(holder.app.toStringWithUID()); - } else { - holder.text.setText(holder.app.toString()); - } + holder.text.setText(holder.app.toStringForList(G.showUid(), G.showPackageName())); final int id = holder.app.uid; - holder.icon.setOnClickListener(v -> StartAppDetailActivityIntent(v,holder,id)); - holder.text.setOnClickListener(v -> StartAppDetailActivityIntent(v,holder,id)); + final View finalConvertView = convertView; + final int finalPosition = position; + holder.icon.setOnClickListener(v -> toggleExpansion(finalConvertView, finalPosition)); + holder.text.setOnClickListener(v -> toggleExpansion(finalConvertView, finalPosition)); ApplicationInfo info = holder.app.appinfo; if (info != null && (info.flags & ApplicationInfo.FLAG_SYSTEM) == 0) { //user app - holder.text.setTextColor(G.userColor()); + holder.text.setTextColor(G.userColor(context)); } else { //system app - holder.text.setTextColor(G.sysColor()); + holder.text.setTextColor(G.sysColor(context)); } if (!G.disableIcons()) { - if(holder.app.pkgName.startsWith("dev.afwall.special.")) { - holder.icon.setImageDrawable(context.getDrawable(R.drawable.ic_unknown)); + if (usesDefaultAndroidIcon(holder.app)) { + holder.icon.setImageDrawable(specialIcon(holder.app)); } else { holder.icon.setImageDrawable(holder.app.cached_icon); if (!holder.app.icon_loaded && info != null) { - // this icon has not been loaded yet - load it on a - // separated thread - try { - new LoadIconTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, holder.app, - context.getPackageManager(), convertView); - } catch (Exception r) { - } + loadIcon(holder.app, convertView); } } @@ -199,11 +215,306 @@ public View getView(final int position, View convertView, ViewGroup parent) { holder.box_tor = addSupport(holder.box_tor, holder.app, 3); } + applyCheckboxTint(holder); + + setupExpandableView(holder, convertView, position); addEventListenter(holder); return convertView; } + private void toggleExpansion(View convertView, int position) { + AppStateHolder holder = (AppStateHolder) convertView.getTag(); + if (expandedPositions.contains(position)) { + expandedPositions.remove(position); + holder.expandedOptions.setVisibility(View.GONE); + } else { + expandedPositions.add(position); + holder.expandedOptions.setVisibility(View.VISIBLE); + updateLogStatistics(holder); + updateDataUsageStats(holder); + } + } + + private void setupExpandableView(AppStateHolder holder, View convertView, int position) { + holder.expandedOptions = convertView.findViewById(R.id.expanded_options); + holder.actionToggleLog = convertView.findViewById(R.id.action_toggle_log); + holder.actionOpenApp = convertView.findViewById(R.id.action_open_app); + holder.actionViewLogs = convertView.findViewById(R.id.action_view_logs); + holder.actionDirectRules = convertView.findViewById(R.id.action_direct_rules); + holder.blockedCount = convertView.findViewById(R.id.blocked_count); + holder.lastActivity = convertView.findViewById(R.id.last_activity); + holder.lastBlockedDestination = convertView.findViewById(R.id.last_blocked_destination); + holder.dataUsage = convertView.findViewById(R.id.data_usage); + + if (expandedPositions.contains(position)) { + holder.expandedOptions.setVisibility(View.VISIBLE); + updateLogStatistics(holder); + updateDataUsageStats(holder); + } else { + holder.expandedOptions.setVisibility(View.GONE); + } + + updateLogNotificationIcon(holder); + updateLogsIconVisibility(holder); + applyThemeColors(holder); + + holder.actionToggleLog.setOnClickListener(v -> { + Log.d(TAG, "Notification toggle clicked for UID: " + holder.app.uid); + toggleLogNotification(holder); + }); + holder.actionOpenApp.setOnClickListener(v -> { + Log.d(TAG, "Open app settings clicked for: " + holder.app.pkgName); + openAppSettings(holder); + }); + holder.actionViewLogs.setOnClickListener(v -> { + Log.d(TAG, "View logs clicked for UID: " + holder.app.uid); + openFirewallLogs(holder); + }); + if (holder.actionDirectRules != null) { + holder.actionDirectRules.setVisibility(G.enableCustomRules() + && AppRuleHelper.supportsUid(holder.app.uid) ? View.VISIBLE : View.GONE); + holder.actionDirectRules.setOnClickListener(v -> { + Log.d(TAG, "Direct rules clicked for UID: " + holder.app.uid); + openDirectRules(holder); + }); + } + } + + private void updateLogNotificationIcon(AppStateHolder holder) { + try { + LogPreference logPreference = SQLite.select() + .from(LogPreference.class) + .where(LogPreference_Table.uid.eq(holder.app.uid)).querySingle(); + + boolean isDisabled = logPreference != null && logPreference.isDisable(); + + holder.actionToggleLog.setImageResource( + isDisabled ? R.drawable.ic_notifications_off_black_24dp + : R.drawable.ic_notifications_on_black_24dp + ); + } catch (Exception e) { + Log.e(TAG, "Error updating notification icon", e); + holder.actionToggleLog.setImageResource(R.drawable.ic_notifications_on_black_24dp); + } + } + + /** + * Icon of an entry without an app icon: its own for the special entries (kernel, root, ...), + * one for Android services without an app, else the default one. + */ + private Drawable specialIcon(Api.PackageInfoData app) { + Drawable icon = null; + if (app.pkgName.startsWith("dev.afwall.special.")) { + icon = AppIcons.special(context, app.uid); + if (icon == null && app.uid > 0 && app.uid < android.os.Process.FIRST_APPLICATION_UID) { + icon = AppIcons.systemService(context); + } + } + return icon != null ? icon : ThemeHelper.defaultAndroidIcon(context); + } + + // apps whose icon is being loaded, so a row bound again meanwhile doesn't load it twice + private final Set iconsLoading = + java.util.Collections.newSetFromMap(new java.util.concurrent.ConcurrentHashMap<>()); + + private void loadIcon(Api.PackageInfoData app, View row) { + if (!iconsLoading.add(app)) { + return; + } + final PackageManager pm = context.getPackageManager(); + AppIcons.load(() -> { + try { + // work profile / Private Space apps get the profile badge + app.cached_icon = AppIcons.badge(pm, pm.getApplicationIcon(app.appinfo), app.uid); + } catch (Exception e) { + app.cached_icon = ThemeHelper.defaultAndroidIcon(context); + } + app.icon_loaded = true; + }, () -> { + iconsLoading.remove(app); + Object tag = row.getTag(); + // the row may show another app by now + if (tag instanceof AppStateHolder && ((AppStateHolder) tag).app == app) { + ((AppStateHolder) tag).icon.setImageDrawable(app.cached_icon); + } + }); + } + + private boolean usesDefaultAndroidIcon(Api.PackageInfoData app) { + // appinfo is null for packages hidden from PackageManager (found via root) + return app.pkgName.startsWith("dev.afwall.special.") + || app.appinfo == null || app.appinfo.icon == 0; + } + + private void applyThemeColors(AppStateHolder holder) { + int iconColor = G.userColor(context); + int textColor = G.userColor(context); + + // Apply color filter to icons using setColorFilter on ImageView, not the Drawable + // This preserves click functionality + holder.actionToggleLog.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN); + holder.actionOpenApp.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN); + holder.actionViewLogs.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN); + if (holder.actionDirectRules != null) { + holder.actionDirectRules.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN); + } + + // Apply text colors + if (holder.blockedCount != null) { + holder.blockedCount.setTextColor(textColor); + } + if (holder.lastActivity != null) { + holder.lastActivity.setTextColor(textColor); + } + if (holder.lastBlockedDestination != null) { + holder.lastBlockedDestination.setTextColor(textColor); + } + if (holder.dataUsage != null) { + holder.dataUsage.setTextColor(textColor); + } + } + + private void toggleLogNotification(AppStateHolder holder) { + try { + LogPreference logPreference = SQLite.select() + .from(LogPreference.class) + .where(LogPreference_Table.uid.eq(holder.app.uid)).querySingle(); + + // Current state: if logPreference exists and isDisable() is true, notifications are disabled + boolean currentlyDisabled = logPreference != null && logPreference.isDisable(); + + // Toggle: if currently disabled, enable (false); if currently enabled, disable (true) + boolean newDisabledState = !currentlyDisabled; + + Log.d(TAG, "Toggling log notification for UID " + holder.app.uid + + ": currently disabled=" + currentlyDisabled + ", new disabled state=" + newDisabledState); + + G.updateLogNotification(holder.app.uid, newDisabledState); + updateLogNotificationIcon(holder); + applyThemeColors(holder); + } catch (Exception e) { + Log.e(TAG, "Error toggling log notification", e); + } + } + + private void openAppSettings(AppStateHolder holder) { + if (!holder.app.pkgName.startsWith("dev.afwall.special.")) { + Api.showInstalledAppDetails(context, holder.app.pkgName); + } + } + + private void updateLogsIconVisibility(AppStateHolder holder) { + try { + // Check if logs exist for this app + long logCount = SQLite.selectCountOf() + .from(LogData.class) + .where(LogData_Table.uid.eq(holder.app.uid)) + .count(); + + holder.actionViewLogs.setVisibility(logCount > 0 ? View.VISIBLE : View.GONE); + } catch (Exception e) { + Log.e(TAG, "Error checking log availability", e); + holder.actionViewLogs.setVisibility(View.GONE); + } + } + + private void openFirewallLogs(AppStateHolder holder) { + try { + Intent intent = new Intent(context, dev.ukanth.ufirewall.activity.LogDetailActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.putExtra("DATA", holder.app.uid); + context.startActivity(intent); + } catch (Exception e) { + Log.e(TAG, "Error opening firewall logs", e); + } + } + + private void updateLogStatistics(AppStateHolder holder) { + try { + int uid = holder.app.uid; + + // Get blocked count + long blockedCountValue = SQLite.selectCountOf() + .from(LogData.class) + .where(LogData_Table.uid.eq(uid)) + .count(); + + holder.blockedCount.setText("Blocked: " + blockedCountValue); + + // Get most recent log entry + LogData lastLogEntry = SQLite.select() + .from(LogData.class) + .where(LogData_Table.uid.eq(uid)) + .orderBy(LogData_Table.timestamp, false) + .querySingle(); + + if (lastLogEntry != null) { + // Format last activity time + SimpleDateFormat sdf = new SimpleDateFormat("MMM dd, HH:mm", Locale.getDefault()); + String formattedTime = sdf.format(new Date(lastLogEntry.getTimestamp())); + holder.lastActivity.setText("Last: " + formattedTime); + + // Show last blocked destination + String destination = lastLogEntry.getDst(); + if (destination != null && !destination.isEmpty()) { + String hostname = lastLogEntry.getHostname(); + String displayDestination = hostname != null && !hostname.isEmpty() ? + hostname : destination; + holder.lastBlockedDestination.setText("Last blocked: " + displayDestination); + } else { + holder.lastBlockedDestination.setText("Last blocked: -"); + } + } else { + holder.lastActivity.setText("Last activity: -"); + holder.lastBlockedDestination.setText("Last blocked: -"); + } + + } catch (Exception e) { + Log.e(TAG, "Error updating log statistics", e); + holder.blockedCount.setText("Blocked: -"); + holder.lastActivity.setText("Last activity: -"); + holder.lastBlockedDestination.setText("Last blocked: -"); + } + } + + private void updateDataUsageStats(AppStateHolder holder) { + // Run in background thread to avoid blocking UI + new Thread(() -> { + try { + DataUsageParser.DataUsageStats stats = DataUsageParser.getDataUsageForUID(holder.app.uid); + String dataUsageText = DataUsageParser.formatWifiMobileUsage(stats); + + // Update UI on main thread + if (activity != null) { + activity.runOnUiThread(() -> { + if (holder.dataUsage != null) { + holder.dataUsage.setText("Data: " + dataUsageText); + } + }); + } + } catch (Exception e) { + Log.e(TAG, "Error updating data usage stats", e); + if (activity != null) { + activity.runOnUiThread(() -> { + if (holder.dataUsage != null) { + holder.dataUsage.setText("Data: Not available"); + } + }); + } + } + }).start(); + } + + private void openDirectRules(AppStateHolder holder) { + Intent intent = new Intent(context, AppRulesActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.putExtra(AppRulesActivity.EXTRA_UID, holder.app.uid); + intent.putExtra(AppRulesActivity.EXTRA_PACKAGE, holder.app.pkgName); + intent.putExtra(AppRulesActivity.EXTRA_LABEL, holder.app.toString().trim()); + context.startActivity(intent); + } + private void StartAppDetailActivityIntent(View v, AppStateHolder holder, Integer id) { Intent intent = new Intent(context, AppDetailActivity.class); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); @@ -332,6 +643,7 @@ public void onCheckedChanged(CompoundButton compoundButton, boolean isChecked) { } }); } + } private CheckBox addSupport(CheckBox check, PackageInfoData app, int flag) { @@ -373,6 +685,30 @@ private CheckBox removeSupport(View convertView, int id) { return check; } + private ColorStateList checkboxTint; + + /** + * Tint the row checkboxes with the theme's control colors (unchecked: secondary text color, + * checked: accent; pure black on the e-paper theme). The rows are inflated from the + * application context, so they don't pick up the activity theme's control colors and were + * left with near-invisible library defaults on dark backgrounds. + */ + private void applyCheckboxTint(AppStateHolder holder) { + if (checkboxTint == null) { + // the effective theme: a donor-only theme without the key falls back to the dark one + checkboxTint = "LHC".equals(G.getEffectiveSelectedTheme(context)) + ? ColorStateList.valueOf(Color.BLACK) // maximum contrast on e-paper + : ThemeHelper.controlTint(context); + } + CheckBox[] boxes = {holder.box_wifi, holder.box_3g, holder.box_roam, holder.box_vpn, + holder.box_tether, holder.box_lan, holder.box_tor}; + for (CheckBox box : boxes) { + if (box != null) { + CompoundButtonCompat.setButtonTintList(box, checkboxTint); + } + } + } + static class AppStateHolder { private CheckBox box_lan; @@ -382,49 +718,18 @@ static class AppStateHolder { private CheckBox box_vpn; private CheckBox box_tether; private CheckBox box_tor; + private ImageView actionDirectRules; private TextView text; private ImageView icon; private PackageInfoData app; - } - - /** - * Asynchronous task used to load icons in a background thread. - */ - private static class LoadIconTask extends AsyncTask { - @Override - protected View doInBackground(Object... params) { - try { - final PackageInfoData app = (PackageInfoData) params[0]; - final PackageManager pkgMgr = (PackageManager) params[1]; - final View viewToUpdate = (View) params[2]; - if (!app.icon_loaded) { - Drawable d = new ScaleDrawable(pkgMgr.getApplicationIcon(app.appinfo), 0, 32, 32).getDrawable(); - d.setBounds(0, 0, 32, 32); - app.cached_icon = d; - app.icon_loaded = true; - } - // Return the view to update at "onPostExecute" - // Note that we cannot be sure that this view still references - // "app" - return viewToUpdate; - } catch (Exception e) { - Log.e(TAG, "Error loading icon", e); - return null; - } - } - - protected void onPostExecute(View viewToUpdate) { - try { - // This is executed in the UI thread, so it is safe to use - // viewToUpdate.getTag() - // and modify the UI - final AppStateHolder entryToUpdate = (AppStateHolder) viewToUpdate.getTag(); - entryToUpdate.icon.setImageDrawable(entryToUpdate.app.cached_icon); - } catch (Exception e) { - Log.e(TAG, "Error showing icon", e); - } - } - + private LinearLayout expandedOptions; + private ImageView actionToggleLog; + private ImageView actionOpenApp; + private ImageView actionViewLogs; + private TextView blockedCount; + private TextView lastActivity; + private TextView lastBlockedDestination; + private TextView dataUsage; } /** diff --git a/app/src/main/java/dev/ukanth/ufirewall/util/AppRuleHelper.java b/app/src/main/java/dev/ukanth/ufirewall/util/AppRuleHelper.java new file mode 100644 index 000000000..b005f4a8e --- /dev/null +++ b/app/src/main/java/dev/ukanth/ufirewall/util/AppRuleHelper.java @@ -0,0 +1,500 @@ +package dev.ukanth.ufirewall.util; + +import com.raizlabs.android.dbflow.sql.language.SQLite; + +import java.util.HashSet; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; + +import dev.ukanth.ufirewall.Api; +import dev.ukanth.ufirewall.customrules.CustomRule; +import dev.ukanth.ufirewall.customrules.CustomRule_Table; +import dev.ukanth.ufirewall.log.Log; + +public final class AppRuleHelper { + + private static final String RULE_PREFIX = "direct-rule:"; + private static final String DEFAULT_PROFILE = "AFWallPrefs"; + private static final Pattern IPV4_PATTERN = Pattern.compile( + "^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(?:/(?:[0-9]|[1-2][0-9]|3[0-2]))?$"); + private static final Pattern IPV6_CHARS = Pattern.compile("^[0-9A-Fa-f:.]*:[0-9A-Fa-f:.]*$"); + private static final Pattern PORT_PATTERN = Pattern.compile( + "^(?:[1-9][0-9]{0,4})(?::(?:[1-9][0-9]{0,4}))?$"); + + private AppRuleHelper() { + } + + public static String rulePrefixForUid(int uid) { + return rulePrefixForUid(currentProfileName(), uid); + } + + private static String rulePrefixForUid(String profile, int uid) { + return RULE_PREFIX + profile + ":" + uid + ":"; + } + + /** + * A direct rule, as encoded in its name. + */ + public static final class ParsedRule { + public final String profile; + public final int uid; + public final String destination; + public final String protocol; + public final String port; + + ParsedRule(String profile, int uid, String destination, String protocol, String port) { + this.profile = profile; + this.uid = uid; + this.destination = destination; + this.protocol = protocol; + this.port = port; + } + } + + /** + * Parse "direct-rule:<profile>:<uid>: allow dst=.. proto=.. dport=.." (or the legacy + * form without the profile, which belongs to the default profile). + * + * @return null if {@code name} is not a direct rule + */ + public static ParsedRule parseRuleName(String name) { + if (name == null || !name.startsWith(RULE_PREFIX)) { + return null; + } + String profile; + int uidStart; + if (isLegacyRuleName(name)) { + profile = DEFAULT_PROFILE; + uidStart = RULE_PREFIX.length(); + } else { + int sep = name.indexOf(':', RULE_PREFIX.length()); + if (sep <= RULE_PREFIX.length()) { + return null; + } + profile = name.substring(RULE_PREFIX.length(), sep); + uidStart = sep + 1; + } + int uid = parseUidAfterPrefix(name, uidStart); + if (uid == Integer.MIN_VALUE) { + return null; + } + String destination = ""; + String protocol = "any"; + String port = ""; + for (String token : name.substring(name.indexOf(':', uidStart) + 1).trim().split("\\s+")) { + if (token.startsWith("dst=")) { + destination = token.substring(4); + } else if (token.startsWith("proto=")) { + protocol = token.substring(6); + } else if (token.startsWith("dport=")) { + port = token.substring(6); + } + } + return new ParsedRule(profile, uid, destination, protocol, port); + } + + /** + * @return true if direct rules can be made for {@code uid}: an app/system UID or "any app". + * The other special entries (kernel, tethering, NTP, ...) have no UID iptables can match, a + * rule for them would break the whole rule load. + */ + public static boolean supportsUid(int uid) { + return uid >= 0 || uid == Api.SPECIAL_UID_ANY; + } + + /** + * The rule as stored with the direct rule (shown in the rule list, and applied by versions + * before 4.2.0); the rules that are applied are built by {@link #buildRule}. + */ + public static String buildAllowRule(int uid, String destinationValue, String protocolValue, String portValue) { + return buildRule("afwall", uid, destinationValue, protocolValue, portValue); + } + + /** + * @param chain main chain of the user ("afwall", or "afwall<userId>" in multi-user mode) + * @return the rule for the IPv4 or IPv6 table, or null if the rule doesn't apply to it (its + * destination is of the other family) or can't be made + */ + public static String buildRule(ParsedRule parsed, String chain, boolean ipv6) { + if (parsed == null || !supportsUid(parsed.uid)) { + return null; + } + String destination = normalize(parsed.destination); + if (!destination.isEmpty() && isIpv6Destination(destination) != ipv6) { + return null; + } + return buildRule(chain, parsed.uid, destination, parsed.protocol, parsed.port); + } + + private static String buildRule(String chain, int uid, String destinationValue, String protocolValue, + String portValue) { + String destination = normalize(destinationValue); + String protocol = normalize(protocolValue).toLowerCase(Locale.US); + String port = normalize(portValue); + + StringBuilder rule = new StringBuilder("-A ").append(chain); + if (uid != Api.SPECIAL_UID_ANY) { + rule.append(" -m owner --uid-owner ").append(uid); + } + if (!destination.isEmpty()) { + rule.append(" -d ").append(destination); + } + if (!"any".equals(protocol) && !protocol.isEmpty()) { + rule.append(" -p ").append(protocol); + } + if (!port.isEmpty()) { + rule.append(" --dport ").append(port); + } + rule.append(" -j RETURN"); + return rule.toString(); + } + + public static String buildAllowRuleName(int uid, String destinationValue, String protocolValue, String portValue) { + return buildAllowRuleName(currentProfileName(), uid, destinationValue, protocolValue, portValue); + } + + public static String buildAllowRuleName(String profile, int uid, String destinationValue, String protocolValue, + String portValue) { + String destination = normalize(destinationValue); + String protocol = normalize(protocolValue).toLowerCase(Locale.US); + String port = normalize(portValue); + + StringBuilder name = new StringBuilder(rulePrefixForUid(profile, uid)); + name.append(" allow"); + if (!destination.isEmpty()) { + name.append(" dst=").append(destination); + } + if (!"any".equals(protocol) && !protocol.isEmpty()) { + name.append(" proto=").append(protocol); + } + if (!port.isEmpty()) { + name.append(" dport=").append(port); + } + return name.toString(); + } + + /** + * @return true for an IPv4 or IPv6 address or CIDR range + */ + public static boolean isValidDestination(String value) { + String v = normalize(value); + return IPV4_PATTERN.matcher(v).matches() || isValidIpv6(v); + } + + public static boolean isIpv6Destination(String value) { + return normalize(value).contains(":"); + } + + private static boolean isValidIpv6(String value) { + String address = value; + int slash = value.indexOf('/'); + if (slash >= 0) { + address = value.substring(0, slash); + try { + int prefix = Integer.parseInt(value.substring(slash + 1)); + if (prefix < 0 || prefix > 128) { + return false; + } + } catch (NumberFormatException e) { + return false; + } + } + if (!IPV6_CHARS.matcher(address).matches()) { + return false; + } + try { + // a literal with ':' is parsed as IPv6, never looked up (IPv4-mapped addresses come + // back as IPv4 objects, ip6tables takes them as IPv6) + return java.net.InetAddress.getByName(address) != null; + } catch (Exception e) { + return false; + } + } + + public static boolean isValidPortRange(String value) { + String port = normalize(value); + if (!PORT_PATTERN.matcher(port).matches()) { + return false; + } + String[] parts = port.split(":"); + int start = parsePort(parts[0]); + int end = parts.length == 2 ? parsePort(parts[1]) : start; + return start >= 1 && start <= 65535 && end >= 1 && end <= 65535 && start <= end; + } + + public static String normalizeProtocol(String protocolValue) { + String protocol = normalize(protocolValue).toLowerCase(Locale.US); + if ("tcp".equals(protocol) || "udp".equals(protocol)) { + return protocol; + } + return "any"; + } + + public static List getRulesForUid(int uid) { + try { + List rules = SQLite.select() + .from(CustomRule.class) + .queryList(); + List matchingRules = new java.util.ArrayList<>(); + String currentPrefix = rulePrefixForUid(uid); + String legacyPrefix = legacyRulePrefixForUid(uid); + for (CustomRule rule : rules) { + String name = rule.getName(); + if (name == null) { + continue; + } + if (name.startsWith(currentPrefix) || (isDefaultProfile() && name.startsWith(legacyPrefix))) { + matchingRules.add(rule); + } + } + return matchingRules; + } catch (Exception e) { + return Collections.emptyList(); + } + } + + public static boolean belongsToCurrentProfile(CustomRule customRule) { + if (customRule == null) { + return false; + } + String name = customRule.getName(); + if (name == null || !name.startsWith(RULE_PREFIX)) { + return false; + } + if (name.startsWith(RULE_PREFIX + currentProfileName() + ":")) { + return true; + } + return isDefaultProfile() && isLegacyRuleName(name); + } + + public static String displayNameForRule(int uid, String ruleName) { + if (ruleName == null) { + return ""; + } + String currentPrefix = rulePrefixForUid(uid); + if (ruleName.startsWith(currentPrefix)) { + return ruleName.substring(currentPrefix.length()); + } + String legacyPrefix = legacyRulePrefixForUid(uid); + if (ruleName.startsWith(legacyPrefix)) { + return ruleName.substring(legacyPrefix.length()); + } + return ruleName; + } + + public static boolean hasRulesForUid(int uid) { + return !getRulesForUid(uid).isEmpty(); + } + + public static boolean hasActiveRulesForUid(int uid) { + for (CustomRule rule : getRulesForUid(uid)) { + if (rule.isActive()) { + return true; + } + } + return false; + } + + public static Set getRuleUidsForCurrentProfile(boolean activeOnly) { + Set uids = new HashSet<>(); + try { + List rules = activeOnly + ? SQLite.select().from(CustomRule.class).where(CustomRule_Table.active.eq(true)).queryList() + : SQLite.select().from(CustomRule.class).queryList(); + for (CustomRule rule : rules) { + if (activeOnly && !rule.isActive()) { + continue; + } + if (!belongsToCurrentProfile(rule)) { + continue; + } + int uid = uidFromRuleName(rule.getName()); + if (uid != Integer.MIN_VALUE) { + uids.add(uid); + } + } + } catch (Exception e) { + return Collections.emptySet(); + } + return uids; + } + + /** + * Delete the direct rules of {@code uid} in every profile. Used when an app is uninstalled, + * so a later app that is given the same UID does not inherit them. + * + * @return number of rules deleted + */ + public static int deleteRulesForUidInAllProfiles(int uid) { + int deleted = 0; + try { + for (CustomRule rule : SQLite.select().from(CustomRule.class).queryList()) { + if (isRuleForUidInAnyProfile(rule.getName(), uid)) { + rule.delete(); + deleted++; + } + } + } catch (Exception e) { + Log.e(Api.TAG, "Unable to delete direct rules for uid " + uid, e); + } + return deleted; + } + + /** + * @return true if {@code name} is a direct rule of {@code uid}, in any profile: + * "direct-rule:<profile>:<uid>:..." or legacy "direct-rule:<uid>:..." + */ + static boolean isRuleForUidInAnyProfile(String name, int uid) { + if (name == null || !name.startsWith(RULE_PREFIX)) { + return false; + } + String uidStr = String.valueOf(uid); + String[] parts = name.substring(RULE_PREFIX.length()).split(":"); + return isLegacyRuleName(name) + ? parts.length > 0 && uidStr.equals(parts[0]) + : parts.length > 1 && uidStr.equals(parts[1]); + } + + /** + * Delete the direct rules of a deleted profile, so a new profile with the same name doesn't + * get them. + */ + public static int deleteRulesForProfile(String profile) { + String id = normalize(profile); + if (id.isEmpty() || DEFAULT_PROFILE.equals(id)) { + return 0; // the default profile can't be deleted + } + int deleted = 0; + try { + for (CustomRule rule : SQLite.select().from(CustomRule.class).queryList()) { + ParsedRule parsed = parseRuleName(rule.getName()); + if (parsed != null && parsed.profile.equals(id)) { + rule.delete(); + deleted++; + } + } + } catch (Exception e) { + Log.e(Api.TAG, "Unable to delete direct rules of profile " + id, e); + } + return deleted; + } + + /** + * Copy the direct rules of a profile to a cloned one. + */ + public static int copyRulesToProfile(String fromProfile, String toProfile) { + String from = profileId(fromProfile); + String to = profileId(toProfile); + int copied = 0; + if (from.equals(to)) { + return 0; + } + try { + for (CustomRule rule : SQLite.select().from(CustomRule.class).queryList()) { + ParsedRule parsed = parseRuleName(rule.getName()); + if (parsed == null || !parsed.profile.equals(from)) { + continue; + } + String name = buildAllowRuleName(to, parsed.uid, parsed.destination, parsed.protocol, parsed.port); + CustomRule copy = SQLite.select().from(CustomRule.class) + .where(CustomRule_Table.name.eq(name)).querySingle(); + if (copy == null) { + copy = new CustomRule(name, rule.getRule()); + } + copy.setRule(rule.getRule()); + copy.setActive(rule.isActive()); + copy.save(); + copied++; + } + } catch (Exception e) { + Log.e(Api.TAG, "Unable to copy direct rules from " + from + " to " + to, e); + } + return copied; + } + + /** + * The default profile has an empty identifier in some places; its rules use "AFWallPrefs". + */ + private static String profileId(String profile) { + String id = normalize(profile); + return id.isEmpty() ? DEFAULT_PROFILE : id; + } + + public static void setRulesActiveForUid(int uid, boolean active) { + for (CustomRule rule : getRulesForUid(uid)) { + rule.setActive(active); + rule.save(); + } + } + + private static String normalize(String value) { + return value == null ? "" : value.trim(); + } + + private static String currentProfileName() { + String profileName = Api.PREFS_NAME; + if (profileName == null || profileName.trim().isEmpty()) { + profileName = G.storedProfile(); + } + return normalize(profileName); + } + + private static boolean isDefaultProfile() { + return DEFAULT_PROFILE.equals(currentProfileName()); + } + + private static String legacyRulePrefixForUid(int uid) { + return RULE_PREFIX + uid + ":"; + } + + private static boolean isLegacyRuleName(String name) { + int start = RULE_PREFIX.length(); + int separator = name.indexOf(':', start); + if (separator <= start) { + return false; + } + try { + Integer.parseInt(name.substring(start, separator)); + return true; + } catch (NumberFormatException e) { + return false; + } + } + + private static int uidFromRuleName(String name) { + if (name == null || !name.startsWith(RULE_PREFIX)) { + return Integer.MIN_VALUE; + } + String currentPrefix = RULE_PREFIX + currentProfileName() + ":"; + if (name.startsWith(currentPrefix)) { + return parseUidAfterPrefix(name, currentPrefix.length()); + } + if (isDefaultProfile() && isLegacyRuleName(name)) { + return parseUidAfterPrefix(name, RULE_PREFIX.length()); + } + return Integer.MIN_VALUE; + } + + private static int parseUidAfterPrefix(String name, int start) { + int end = name.indexOf(':', start); + if (end <= start) { + return Integer.MIN_VALUE; + } + try { + return Integer.parseInt(name.substring(start, end)); + } catch (NumberFormatException e) { + return Integer.MIN_VALUE; + } + } + + private static int parsePort(String value) { + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + return -1; + } + } +} diff --git a/app/src/main/java/dev/ukanth/ufirewall/util/ApplicationErrorLog.java b/app/src/main/java/dev/ukanth/ufirewall/util/ApplicationErrorLog.java new file mode 100644 index 000000000..0dabd2042 --- /dev/null +++ b/app/src/main/java/dev/ukanth/ufirewall/util/ApplicationErrorLog.java @@ -0,0 +1,42 @@ +package dev.ukanth.ufirewall.util; + +import android.content.Context; +import android.content.SharedPreferences; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +public final class ApplicationErrorLog { + + private static final String PREF_NAME = "AFWallApplicationErrors"; + private static final String KEY_LOG = "application_errors"; + private static final int MAX_CHARS = 20000; + + private ApplicationErrorLog() { + } + + public static synchronized void add(Context context, String message) { + if (context == null || message == null || message.trim().isEmpty()) { + return; + } + SharedPreferences prefs = context.getApplicationContext() + .getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE); + String timestamp = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(new Date()); + String existing = prefs.getString(KEY_LOG, ""); + String updated = timestamp + " - " + message.trim() + "\n" + existing; + if (updated.length() > MAX_CHARS) { + updated = updated.substring(0, MAX_CHARS); + } + prefs.edit().putString(KEY_LOG, updated).apply(); + } + + public static synchronized String get(Context context) { + if (context == null) { + return ""; + } + return context.getApplicationContext() + .getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + .getString(KEY_LOG, ""); + } +} diff --git a/app/src/main/java/dev/ukanth/ufirewall/util/BackupCodec.java b/app/src/main/java/dev/ukanth/ufirewall/util/BackupCodec.java new file mode 100644 index 000000000..4bc6c5af5 --- /dev/null +++ b/app/src/main/java/dev/ukanth/ufirewall/util/BackupCodec.java @@ -0,0 +1,450 @@ +package dev.ukanth.ufirewall.util; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; + +/** + * Portable form of the backup data (the "v2" section of an export). + *

+ * A UID is only meaningful on the device it came from, so rules are stored by what the UID + * stands for: a special entry name (kernel, root, ...), a fixed system app id, or the packages + * that own it, plus the Android user (work profile, Private Space, ...). Preferences are stored + * with their type. + *

+ * Also holds the rules for reading the preferences of v1 backups, which were written without + * types. Kept free of Android dependencies (org.json only) so it can be unit tested. + */ +public final class BackupCodec { + + public static final int VERSION = 2; + + private static final int PER_USER_RANGE = 100000; + private static final int FIRST_APPLICATION_UID = 10000; + + /** + * Preferences that are never exported or imported: device state, license, and the app lock + * (its password/pattern are not in the backup, so restoring only the setting would turn the + * lock on without a way to unlock, or silently off). + */ + public static final Set NOT_BACKED_UP = new HashSet<>(Arrays.asList( + "appVersion", "hasRoot", "storedProfile", "storedPid", "sort", "fixLeak", "enableLogService", + "logChains", "kingDetect", "fingerprintEnabled", "passSetting", "profilePwd", "pwdEncrypt", + "ipurchaseddonatekey", "profilesmigrated", "plusprofiles")); + + // v1 wrote every preference as a string; these are known to be stored as other types + private static final Set V1_INT_KEYS = new HashSet<>(Arrays.asList("customDelay", "logPingTime")); + private static final Set V1_LONG_KEYS = new HashSet<>(Arrays.asList("multiUserId")); + // and these are strings even when they look like numbers + private static final Set V1_STRING_KEYS = new HashSet<>(Arrays.asList( + "patternMax", "widgetX", "widgetY", "notification_priority", "default", "profile1", "profile2", + "profile3", "BlockMode", "CustomScript", "CustomScript2")); + + /** + * Device lookups needed to convert between UIDs and their portable form. + */ + public interface UidMapper { + /** @return packages that own {@code uid} (empty if unknown) */ + List packagesForUid(int uid); + + /** @return special entry name ("dev.afwall.special...") of {@code uid}, or null */ + String specialNameForUid(int uid); + + /** @return UID of a special entry on this device, or null if it doesn't exist here */ + Integer specialUid(String name); + + /** @return app id (uid % 100000) of an installed package, or null if not installed */ + Integer appIdForPackage(String pkg); + + /** @return Android user ids on this device; empty if they can't be determined */ + Set users(); + + /** @return type of an Android user ("full.SYSTEM", "profile.MANAGED", ...), or null */ + String userType(int userId); + } + + /** + * Receives decoded preferences. + */ + public interface PrefWriter { + void putBoolean(String key, boolean value); + + void putInt(String key, int value); + + void putLong(String key, long value); + + void putFloat(String key, float value); + + void putString(String key, String value); + + void putStringSet(String key, Set value); + } + + private BackupCodec() { + } + + // ---- UIDs ---- + + public static JSONObject encodeUid(int uid, UidMapper m) throws JSONException { + JSONObject o = new JSONObject(); + // kept for information and for fixed (negative) special UIDs + o.put("uid", uid); + String special = m.specialNameForUid(uid); + if (special != null) { + o.put("special", special); + } + if (uid < 0) { + return o; + } + int user = uid / PER_USER_RANGE; + int appId = uid % PER_USER_RANGE; + if (user != 0) { + o.put("user", user); + String type = m.userType(user); + if (type != null) { + o.put("userType", type); + } + } + if (appId < FIRST_APPLICATION_UID) { + // system UIDs are the same on every device + o.put("appId", appId); + } + List pkgs = m.packagesForUid(uid); + if (pkgs != null && !pkgs.isEmpty()) { + o.put("packages", new JSONArray(new TreeSet<>(pkgs))); + } + return o; + } + + /** + * @return the UID of the entry on this device, or null when it doesn't exist here (app not + * installed, no matching user). An app UID is never taken over as is: on another device it + * belongs to a different app. + */ + public static Integer decodeUid(JSONObject o, UidMapper m) { + if (o == null) { + return null; + } + String special = o.optString("special", ""); + if (!special.isEmpty()) { + Integer uid = m.specialUid(special); + if (uid != null) { + return uid; + } + } + int srcUid = o.optInt("uid", Integer.MIN_VALUE); + if (srcUid < 0) { + // special UIDs below 0 are fixed; MIN_VALUE = missing + return srcUid == Integer.MIN_VALUE ? null : srcUid; + } + Integer user = mapUser(o.optInt("user", 0), o.has("userType") ? o.optString("userType") : null, m); + if (user == null) { + return null; + } + if (o.has("appId")) { + int appId = o.optInt("appId", -1); + return appId < 0 ? null : user * PER_USER_RANGE + appId; + } + JSONArray pkgs = o.optJSONArray("packages"); + if (pkgs != null) { + for (int i = 0; i < pkgs.length(); i++) { + Integer appId = m.appIdForPackage(pkgs.optString(i)); + if (appId != null) { + return user * PER_USER_RANGE + appId; + } + } + } + return null; + } + + /** + * Map an Android user of the source device to this device. User ids of profiles differ + * between devices, so a work profile / Private Space is matched by its type. + */ + static Integer mapUser(int user, String type, UidMapper m) { + if (user == 0) { + return 0; + } + Set users = m.users(); + if (users == null || users.isEmpty()) { + return user; // unknown: keep it + } + if (users.contains(user)) { + String localType = m.userType(user); + if (type == null || localType == null || type.equals(localType)) { + return user; + } + } + if (type != null) { + for (Integer candidate : new TreeSet<>(users)) { + if (candidate != 0 && type.equals(m.userType(candidate))) { + return candidate; + } + } + } + return null; + } + + // ---- rules ---- + + /** + * @param lists "|"-separated UID lists, indexed by connection type (the export type codes) + * @return one entry per UID: its portable form plus "on", the connection types it is checked for + */ + public static JSONArray encodeRules(String[] lists, UidMapper m) throws JSONException { + Map> typesByUid = new TreeMap<>(); + for (int type = 0; type < lists.length; type++) { + for (int uid : UidListParser.parse(lists[type])) { + Set types = typesByUid.get(uid); + if (types == null) { + types = new TreeSet<>(); + typesByUid.put(uid, types); + } + types.add(type); + } + } + JSONArray rules = new JSONArray(); + for (Map.Entry> e : typesByUid.entrySet()) { + JSONObject entry = encodeUid(e.getKey(), m); + entry.put("on", new JSONArray(e.getValue())); + rules.put(entry); + } + return rules; + } + + /** + * @param skipped if not null, receives an id (the packages, or the UID) of each entry that + * doesn't exist on this device + * @return "|"-separated UID lists, indexed by connection type + */ + public static String[] decodeRules(JSONArray rules, int typeCount, UidMapper m, Collection skipped) { + List> uids = new ArrayList<>(); + for (int i = 0; i < typeCount; i++) { + uids.add(new LinkedHashSet<>()); + } + if (rules != null) { + for (int i = 0; i < rules.length(); i++) { + JSONObject entry = rules.optJSONObject(i); + if (entry == null) { + continue; + } + Integer uid = decodeUid(entry, m); + if (uid == null) { + if (skipped != null) { + JSONArray pkgs = entry.optJSONArray("packages"); + skipped.add((pkgs != null ? pkgs.toString() : "uid:" + entry.optInt("uid")) + + "@" + entry.optString("userType", String.valueOf(entry.optInt("user", 0)))); + } + continue; + } + JSONArray on = entry.optJSONArray("on"); + for (int j = 0; on != null && j < on.length(); j++) { + int type = on.optInt(j, -1); + if (type >= 0 && type < typeCount) { + uids.get(type).add(uid); + } + } + } + } + String[] lists = new String[typeCount]; + for (int i = 0; i < typeCount; i++) { + lists[i] = join(uids.get(i)); + } + return lists; + } + + private static String join(Collection uids) { + StringBuilder sb = new StringBuilder(); + for (Integer uid : uids) { + if (sb.length() > 0) { + sb.append('|'); + } + sb.append(uid); + } + return sb.toString(); + } + + // ---- preferences ---- + + /** + * @param exclude keys to leave out + * @param prefixes key prefixes to leave out + * @return {key: {"t": type, "v": value}} + */ + public static JSONObject encodePrefs(Map all, Set exclude, List prefixes) + throws JSONException { + JSONObject out = new JSONObject(); + for (String key : new TreeSet<>(all.keySet())) { + if (isExcluded(key, exclude, prefixes)) { + continue; + } + Object value = all.get(key); + JSONObject typed = new JSONObject(); + if (value instanceof Boolean) { + typed.put("t", "b"); + } else if (value instanceof Integer) { + typed.put("t", "i"); + } else if (value instanceof Long) { + typed.put("t", "l"); + } else if (value instanceof Float) { + typed.put("t", "f"); + value = ((Float) value).doubleValue(); + } else if (value instanceof String) { + typed.put("t", "s"); + } else if (value instanceof Set) { + typed.put("t", "set"); + value = new JSONArray(new TreeSet<>(stringSet(value))); + } else { + continue; + } + typed.put("v", value); + out.put(key, typed); + } + return out; + } + + /** + * @return number of preferences written + */ + public static int decodePrefs(JSONObject prefs, Set exclude, List prefixes, PrefWriter w) { + int count = 0; + if (prefs == null) { + return 0; + } + Iterator keys = prefs.keys(); + while (keys.hasNext()) { + String key = keys.next(); + JSONObject typed = prefs.optJSONObject(key); + if (typed == null || isExcluded(key, exclude, prefixes) || !typed.has("v")) { + continue; + } + try { + switch (typed.optString("t")) { + case "b": + w.putBoolean(key, typed.getBoolean("v")); + break; + case "i": + w.putInt(key, typed.getInt("v")); + break; + case "l": + w.putLong(key, typed.getLong("v")); + break; + case "f": + w.putFloat(key, (float) typed.getDouble("v")); + break; + case "s": + w.putString(key, typed.getString("v")); + break; + case "set": { + JSONArray arr = typed.getJSONArray("v"); + Set set = new HashSet<>(); + for (int i = 0; i < arr.length(); i++) { + set.add(arr.getString(i)); + } + w.putStringSet(key, set); + break; + } + default: + continue; + } + count++; + } catch (JSONException e) { + // wrong value for its type: skip it + } + } + return count; + } + + /** + * Write a preference of a v1 backup, which stored every value as a string, with the type the + * app reads it with. + * + * @param existing current value of the key on this device, or null + * @return false if it was skipped (excluded, or not convertible) + */ + public static boolean writeV1Pref(String key, String value, Object existing, PrefWriter w) { + if (key == null || value == null || NOT_BACKED_UP.contains(key)) { + return false; + } + try { + if (V1_INT_KEYS.contains(key)) { + w.putInt(key, Integer.parseInt(value.trim())); + } else if (V1_LONG_KEYS.contains(key)) { + w.putLong(key, Long.parseLong(value.trim())); + } else if (V1_STRING_KEYS.contains(key)) { + w.putString(key, value); + } else if (existing != null) { + return writeAsTypeOf(key, value, existing, w); + } else if (value.equals("true") || value.equals("false")) { + w.putBoolean(key, Boolean.parseBoolean(value)); + } else if (value.matches("-?\\d{1,10}") && fitsInt(value)) { + w.putInt(key, Integer.parseInt(value)); // e.g. theme colors + } else { + w.putString(key, value); + } + return true; + } catch (NumberFormatException e) { + return false; + } + } + + private static boolean writeAsTypeOf(String key, String value, Object existing, PrefWriter w) { + if (existing instanceof Boolean) { + if (!value.equals("true") && !value.equals("false")) { + return false; + } + w.putBoolean(key, Boolean.parseBoolean(value)); + } else if (existing instanceof Integer) { + w.putInt(key, Integer.parseInt(value.trim())); + } else if (existing instanceof Long) { + w.putLong(key, Long.parseLong(value.trim())); + } else if (existing instanceof Float) { + w.putFloat(key, Float.parseFloat(value.trim())); + } else if (existing instanceof String) { + w.putString(key, value); + } else { + return false; // string sets can't be restored from their v1 text form + } + return true; + } + + private static boolean fitsInt(String value) { + try { + Integer.parseInt(value); + return true; + } catch (NumberFormatException e) { + return false; + } + } + + private static boolean isExcluded(String key, Set exclude, List prefixes) { + if (exclude != null && exclude.contains(key)) { + return true; + } + if (prefixes != null) { + for (String prefix : prefixes) { + if (key.startsWith(prefix)) { + return true; + } + } + } + return false; + } + + @SuppressWarnings("unchecked") + private static Set stringSet(Object value) { + return (Set) value; + } +} diff --git a/app/src/main/java/dev/ukanth/ufirewall/util/BackupHelper.java b/app/src/main/java/dev/ukanth/ufirewall/util/BackupHelper.java new file mode 100644 index 000000000..5016c4135 --- /dev/null +++ b/app/src/main/java/dev/ukanth/ufirewall/util/BackupHelper.java @@ -0,0 +1,568 @@ +package dev.ukanth.ufirewall.util; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; + +import com.raizlabs.android.dbflow.sql.language.SQLite; +import com.topjohnwu.superuser.Shell; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import dev.ukanth.ufirewall.Api; +import dev.ukanth.ufirewall.customrules.CustomRule; +import dev.ukanth.ufirewall.log.Log; +import dev.ukanth.ufirewall.log.LogPreference; +import dev.ukanth.ufirewall.preferences.DefaultConnectionPref; +import dev.ukanth.ufirewall.profiles.ProfileData; +import dev.ukanth.ufirewall.profiles.ProfileHelper; + +/** + * Export / import of the "v2" backup section (see {@link BackupCodec}), and the typed import of + * v1 preferences. + *

+ * The v1 sections are still written next to "v2", so older AFWall+ versions can import new + * backups; a v2-aware version only reads "v2" when it is present. + */ +public final class BackupHelper { + + private static final String TAG = Api.TAG; + public static final String V2_KEY = "v2"; + + /** + * Rule list preferences, indexed by the connection type code used in backups (same codes as + * the v1 format). + */ + static final String[] RULE_PREFS = { + Api.PREF_WIFI_PKG_UIDS, // 0 + Api.PREF_3G_PKG_UIDS, // 1 + Api.PREF_ROAMING_PKG_UIDS, // 2 + Api.PREF_VPN_PKG_UIDS, // 3 + Api.PREF_LAN_PKG_UIDS, // 4 + Api.PREF_TOR_PKG_UIDS, // 5 + Api.PREF_TETHER_PKG_UIDS, // 6 + }; + + // per-profile keys that are carried by the portable rules, or are only a cache + private static final Set PROFILE_PREFS_EXCLUDED = new HashSet<>(Arrays.asList(RULE_PREFS)); + private static final List PROFILE_PREF_PREFIXES_EXCLUDED = Collections.singletonList("cache.label."); + // int preferences that versions before v2 import as strings and then crash reading them + // (getInt); left out of the v1 section, they are in "v2" + private static final Set V1_UNSAFE_FOR_OLD_VERSIONS = new HashSet<>(Arrays.asList("customDelay", "logPingTime")); + + private BackupHelper() { + } + + /** + * Result of an import, for the message shown to the user. + */ + public static final class ImportStats { + /** apps (per Android user) in the backup that don't exist on this device */ + final Set skipped = new HashSet<>(); + public int directRules; + + public int skippedApps() { + return skipped.size(); + } + } + + // ---- export ---- + + /** + * @return the complete "v2" section: preferences, every profile with its settings and rules, + * direct rules, log mutes and default connections + */ + public static JSONObject exportFull(Context ctx) throws JSONException { + DeviceUidMapper mapper = new DeviceUidMapper(ctx); + JSONObject v2 = new JSONObject(); + v2.put("version", BackupCodec.VERSION); + v2.put("global", BackupCodec.encodePrefs(G.gPrefs.getAll(), BackupCodec.NOT_BACKED_UP, null)); + + JSONArray profiles = new JSONArray(); + profiles.put(exportProfile(ctx, Api.DEFAULT_PREFS_NAME, + G.gPrefs.getString("default", Api.DEFAULT_PREFS_NAME), true, mapper)); + for (ProfileData data : ProfileHelper.getProfiles()) { + profiles.put(exportProfile(ctx, data.getIdentifier(), data.getName(), false, mapper)); + } + v2.put("profiles", profiles); + + JSONArray directRules = new JSONArray(); + for (CustomRule rule : SQLite.select().from(CustomRule.class).queryList()) { + AppRuleHelper.ParsedRule parsed = AppRuleHelper.parseRuleName(rule.getName()); + if (parsed == null) { + continue; + } + JSONObject o = new JSONObject(); + o.put("profile", parsed.profile); + o.put("target", BackupCodec.encodeUid(parsed.uid, mapper)); + o.put("destination", parsed.destination); + o.put("protocol", parsed.protocol); + o.put("port", parsed.port); + o.put("active", rule.isActive()); + directRules.put(o); + } + v2.put("directRules", directRules); + + JSONArray logMutes = new JSONArray(); + for (LogPreference pref : SQLite.select().from(LogPreference.class).queryList()) { + JSONObject o = new JSONObject(); + o.put("target", BackupCodec.encodeUid(pref.getUid(), mapper)); + o.put("disable", pref.isDisable()); + o.put("skip", pref.isSkip()); + o.put("skipInterval", pref.getSkipInterval()); + if (pref.getAppName() != null) { + o.put("appName", pref.getAppName()); + } + logMutes.put(o); + } + v2.put("logMutes", logMutes); + + JSONArray defaults = new JSONArray(); + for (DefaultConnectionPref pref : SQLite.select().from(DefaultConnectionPref.class).queryList()) { + JSONObject o = new JSONObject(); + // "uid" is the connection column, not an app UID + o.put("column", pref.getUid()); + o.put("state", pref.isState()); + o.put("modeType", pref.getModeType()); + if (pref.getConnectionType() != null) { + o.put("connectionType", pref.getConnectionType()); + } + defaults.put(o); + } + v2.put("defaultConnections", defaults); + return v2; + } + + /** + * @return a "v2" section with the rules of one profile only (the "rules only" export) + */ + public static JSONObject exportRules(Context ctx, String prefName) throws JSONException { + SharedPreferences prefs = ctx.getSharedPreferences(prefName, Context.MODE_PRIVATE); + JSONObject v2 = new JSONObject(); + v2.put("version", BackupCodec.VERSION); + v2.put("rules", BackupCodec.encodeRules(ruleLists(prefs), new DeviceUidMapper(ctx))); + v2.put("mode", prefs.getString(Api.PREF_MODE, Api.MODE_WHITELIST)); + return v2; + } + + private static JSONObject exportProfile(Context ctx, String identifier, String name, boolean isDefault, + DeviceUidMapper mapper) throws JSONException { + SharedPreferences prefs = ctx.getSharedPreferences(identifier, Context.MODE_PRIVATE); + JSONObject o = new JSONObject(); + o.put("identifier", identifier); + o.put("name", name); + o.put("default", isDefault); + o.put("prefs", BackupCodec.encodePrefs(prefs.getAll(), PROFILE_PREFS_EXCLUDED, PROFILE_PREF_PREFIXES_EXCLUDED)); + o.put("rules", BackupCodec.encodeRules(ruleLists(prefs), mapper)); + return o; + } + + private static String[] ruleLists(SharedPreferences prefs) { + String[] lists = new String[RULE_PREFS.length]; + for (int i = 0; i < RULE_PREFS.length; i++) { + lists[i] = prefs.getString(RULE_PREFS[i], ""); + } + return lists; + } + + /** + * @return v1 "prefs" array of {@code prefs}, leaving out what must not be backed up + */ + public static JSONArray exportV1Prefs(SharedPreferences prefs) throws JSONException { + return exportV1Prefs(prefs, null); + } + + /** + * @param overrides values to write instead of the stored ones (may add keys); may be null + */ + public static JSONArray exportV1Prefs(SharedPreferences prefs, Map overrides) throws JSONException { + Map all = new TreeMap<>(prefs.getAll()); + if (overrides != null) { + all.putAll(overrides); + } + JSONArray arr = new JSONArray(); + for (Map.Entry entry : all.entrySet()) { + boolean overridden = overrides != null && overrides.containsKey(entry.getKey()); + if ((!overridden && (BackupCodec.NOT_BACKED_UP.contains(entry.getKey()) + || V1_UNSAFE_FOR_OLD_VERSIONS.contains(entry.getKey()))) || entry.getValue() == null) { + continue; + } + JSONObject obj = new JSONObject(); + obj.put(entry.getKey(), entry.getValue().toString()); + arr.put(obj); + } + return arr; + } + + // ---- import ---- + + public static ImportStats importFull(Context ctx, JSONObject v2) throws JSONException { + ImportStats stats = new ImportStats(); + DeviceUidMapper mapper = new DeviceUidMapper(ctx); + + BackupCodec.decodePrefs(v2.optJSONObject("global"), BackupCodec.NOT_BACKED_UP, null, writer(G.gPrefs)); + + // backup identifier -> identifier on this device + Map profileIds = new HashMap<>(); + JSONArray profiles = v2.optJSONArray("profiles"); + for (int i = 0; profiles != null && i < profiles.length(); i++) { + JSONObject p = profiles.optJSONObject(i); + if (p == null) { + continue; + } + String srcId = p.optString("identifier", ""); + String identifier = p.optBoolean("default") || Api.DEFAULT_PREFS_NAME.equals(srcId) + ? Api.DEFAULT_PREFS_NAME + : ProfileHelper.ensureProfile(p.optString("name", srcId), srcId); + if (identifier == null) { + continue; + } + profileIds.put(srcId, identifier); + SharedPreferences prefs = ctx.getSharedPreferences(identifier, Context.MODE_PRIVATE); + BackupCodec.PrefWriter w = writer(prefs); + BackupCodec.decodePrefs(p.optJSONObject("prefs"), PROFILE_PREFS_EXCLUDED, PROFILE_PREF_PREFIXES_EXCLUDED, w); + writeRuleLists(prefs, p.optJSONArray("rules"), mapper, stats); + } + + JSONArray directRules = v2.optJSONArray("directRules"); + for (int i = 0; directRules != null && i < directRules.length(); i++) { + JSONObject o = directRules.optJSONObject(i); + if (o != null && importDirectRule(o, profileIds, mapper)) { + stats.directRules++; + } + } + + JSONArray logMutes = v2.optJSONArray("logMutes"); + for (int i = 0; logMutes != null && i < logMutes.length(); i++) { + JSONObject o = logMutes.optJSONObject(i); + Integer uid = o == null ? null : BackupCodec.decodeUid(o.optJSONObject("target"), mapper); + if (uid == null) { + continue; + } + LogPreference pref = new LogPreference(); + pref.setUid(uid); + pref.setDisable(o.optBoolean("disable")); + pref.setSkip(o.optBoolean("skip")); + pref.setSkipInterval(o.optLong("skipInterval")); + pref.setAppName(o.optString("appName", null)); + pref.setTimestamp(System.currentTimeMillis()); + pref.save(); + } + G.clearLogMuteCache(); + + JSONArray defaults = v2.optJSONArray("defaultConnections"); + for (int i = 0; defaults != null && i < defaults.length(); i++) { + JSONObject o = defaults.optJSONObject(i); + if (o == null) { + continue; + } + DefaultConnectionPref pref = new DefaultConnectionPref(); + pref.setUid(o.optInt("column")); + pref.setState(o.optBoolean("state")); + pref.setModeType(o.optInt("modeType")); + pref.setConnectionType(o.optString("connectionType", null)); + pref.save(); + } + return stats; + } + + /** + * Import the rules of a "rules only" v2 section into {@code prefName}. + */ + public static ImportStats importRules(Context ctx, JSONObject v2, String prefName) { + ImportStats stats = new ImportStats(); + SharedPreferences prefs = ctx.getSharedPreferences(prefName, Context.MODE_PRIVATE); + String mode = v2.optString("mode", ""); + if (Api.MODE_WHITELIST.equals(mode) || Api.MODE_BLACKLIST.equals(mode)) { + prefs.edit().putString(Api.PREF_MODE, mode).apply(); + } + writeRuleLists(prefs, v2.optJSONArray("rules"), new DeviceUidMapper(ctx), stats); + return stats; + } + + private static void writeRuleLists(SharedPreferences prefs, JSONArray rules, DeviceUidMapper mapper, + ImportStats stats) { + String[] lists = BackupCodec.decodeRules(rules, RULE_PREFS.length, mapper, stats.skipped); + SharedPreferences.Editor edit = prefs.edit(); + for (int i = 0; i < RULE_PREFS.length; i++) { + edit.putString(RULE_PREFS[i], lists[i]); + } + edit.apply(); + } + + private static boolean importDirectRule(JSONObject o, Map profileIds, DeviceUidMapper mapper) { + String profile = profileIds.get(o.optString("profile", Api.DEFAULT_PREFS_NAME)); + Integer uid = BackupCodec.decodeUid(o.optJSONObject("target"), mapper); + if (profile == null || uid == null || !AppRuleHelper.supportsUid(uid)) { + return false; + } + // the backup file is untrusted: rebuild the rule from validated fields + String destination = o.optString("destination", "").trim(); + String port = o.optString("port", "").trim(); + String protocol = AppRuleHelper.normalizeProtocol(o.optString("protocol", "any")); + if ((!destination.isEmpty() && !AppRuleHelper.isValidDestination(destination)) + || (!port.isEmpty() && !AppRuleHelper.isValidPortRange(port)) + || (!port.isEmpty() && "any".equals(protocol))) { + return false; + } + String rule = Api.validateCustomRuleForStorage(AppRuleHelper.buildAllowRule(uid, destination, protocol, port)); + if (rule == null) { + return false; + } + String name = AppRuleHelper.buildAllowRuleName(profile, uid, destination, protocol, port); + CustomRule existing = SQLite.select().from(CustomRule.class) + .where(dev.ukanth.ufirewall.customrules.CustomRule_Table.name.eq(name)).querySingle(); + CustomRule customRule = existing != null ? existing : new CustomRule(name, rule); + customRule.setRule(rule); + customRule.setActive(o.optBoolean("active", true)); + customRule.save(); + return true; + } + + /** + * Write the preferences of a v1 "prefs" / "profilePrefs" array with the types the app reads + * them with (v1 stored every value as a string). + */ + public static void importV1Prefs(JSONArray prefArray, SharedPreferences prefs) throws JSONException { + importV1Prefs(prefArray, prefs, false); + } + + /** + * @param profilePrefs true for a profile's preferences: its rule lists are left out, as they + * hold UIDs of the source device (the rules sections carry them portably) + */ + public static void importV1Prefs(JSONArray prefArray, SharedPreferences prefs, boolean profilePrefs) + throws JSONException { + if (prefArray == null) { + return; + } + Map current = prefs.getAll(); + BackupCodec.PrefWriter w = writer(prefs); + for (int i = 0; i < prefArray.length(); i++) { + JSONObject prefObj = prefArray.getJSONObject(i); + java.util.Iterator keys = prefObj.keys(); + while (keys.hasNext()) { + String key = keys.next(); + if (profilePrefs && (PROFILE_PREFS_EXCLUDED.contains(key) + || key.startsWith(PROFILE_PREF_PREFIXES_EXCLUDED.get(0)))) { + continue; + } + BackupCodec.writeV1Pref(key, prefObj.optString(key, null), current.get(key), w); + } + } + } + + private static BackupCodec.PrefWriter writer(final SharedPreferences prefs) { + return new BackupCodec.PrefWriter() { + @Override + public void putBoolean(String key, boolean value) { + prefs.edit().putBoolean(key, value).apply(); + } + + @Override + public void putInt(String key, int value) { + prefs.edit().putInt(key, value).apply(); + } + + @Override + public void putLong(String key, long value) { + prefs.edit().putLong(key, value).apply(); + } + + @Override + public void putFloat(String key, float value) { + prefs.edit().putFloat(key, value).apply(); + } + + @Override + public void putString(String key, String value) { + prefs.edit().putString(key, value).apply(); + } + + @Override + public void putStringSet(String key, Set value) { + prefs.edit().putStringSet(key, value).apply(); + } + }; + } + + // ---- device lookups ---- + + /** + * {@link BackupCodec.UidMapper} for this device. PackageManager doesn't show every package + * (package visibility), so root "pm list packages -U" is used as a fallback. Root lookups are + * done lazily and at most once. + */ + public static final class DeviceUidMapper implements BackupCodec.UidMapper { + private static final Pattern PACKAGE_UID = Pattern.compile("package:(\\S+) uid:(\\d+)"); + private static final Pattern USER_TYPE = Pattern.compile("id=(\\d+),.*?type=([\\w.]+)"); + + private final PackageManager pm; + private final Map specialUids; + private Map> rootPackagesByAppId; + private Map rootAppIdByPackage; + private Map userTypes; + private Map appIdBySharedUser; + + public DeviceUidMapper(Context ctx) { + pm = ctx.getPackageManager(); + specialUids = Api.getSpecialAppUids(); + } + + /** + * @return app id of a shared user id ("com.google.uid.shared", ...) on this device, or null + */ + public Integer appIdForSharedUser(String sharedUserId) { + if (appIdBySharedUser == null) { + appIdBySharedUser = new HashMap<>(); + try { + for (android.content.pm.PackageInfo info : pm.getInstalledPackages(PackageManager.MATCH_UNINSTALLED_PACKAGES)) { + if (info.sharedUserId != null && info.applicationInfo != null) { + appIdBySharedUser.put(info.sharedUserId, info.applicationInfo.uid % 100000); + } + } + } catch (Exception e) { + Log.w(TAG, "Backup: unable to list shared user ids: " + e.getMessage()); + } + } + return appIdBySharedUser.get(sharedUserId); + } + + @Override + public List packagesForUid(int uid) { + List result = new ArrayList<>(); + try { + String[] pkgs = pm.getPackagesForUid(uid); + if (pkgs != null) { + result.addAll(Arrays.asList(pkgs)); + } + } catch (Exception ignored) { + } + if (result.isEmpty() && uid % 100000 >= android.os.Process.FIRST_APPLICATION_UID) { + loadRootPackages(); + List pkgs = rootPackagesByAppId.get(uid % 100000); + if (pkgs != null) { + result.addAll(pkgs); + } + } + return result; + } + + @Override + public String specialNameForUid(int uid) { + String found = null; + for (Map.Entry e : specialUids.entrySet()) { + // several names can't share a UID in practice; pick a stable one if they do + if (e.getValue() != null && e.getValue() == uid && (found == null || e.getKey().compareTo(found) < 0)) { + found = e.getKey(); + } + } + return found; + } + + @Override + public Integer specialUid(String name) { + Integer uid = specialUids.get(name); + // -1: the account doesn't exist on this device + return uid == null || uid == -1 ? null : uid; + } + + @Override + public Integer appIdForPackage(String pkg) { + if (pkg == null || pkg.isEmpty()) { + return null; + } + try { + ApplicationInfo info = pm.getApplicationInfo(pkg, PackageManager.MATCH_UNINSTALLED_PACKAGES); + return info.uid % 100000; + } catch (Exception ignored) { + } + loadRootPackages(); + return rootAppIdByPackage.get(pkg); + } + + @Override + public Set users() { + loadUserTypes(); + return userTypes.keySet(); + } + + @Override + public String userType(int userId) { + loadUserTypes(); + return userTypes.get(userId); + } + + private void loadUserTypes() { + if (userTypes != null) { + return; + } + userTypes = new HashMap<>(); + try { + Shell.Result result = Shell.cmd("cmd user list -v").exec(); + for (String line : result.getOut()) { + Matcher m = USER_TYPE.matcher(line); + if (m.find()) { + userTypes.put(Integer.parseInt(m.group(1)), m.group(2)); + } + } + } catch (Exception e) { + Log.w(TAG, "Backup: unable to list users: " + e.getMessage()); + } + } + + private void loadRootPackages() { + if (rootPackagesByAppId != null) { + return; + } + rootPackagesByAppId = new HashMap<>(); + rootAppIdByPackage = new HashMap<>(); + List commands = new ArrayList<>(); + commands.add("pm list packages -U"); + for (Integer user : users()) { + if (user != 0) { + commands.add("pm list packages -U --user " + user); + } + } + for (String command : commands) { + try { + Shell.Result result = Shell.cmd(command).exec(); + for (String line : result.getOut()) { + Matcher m = PACKAGE_UID.matcher(line); + if (!m.find()) { + continue; + } + String pkg = m.group(1); + int appId = Integer.parseInt(m.group(2)) % 100000; + if (!rootAppIdByPackage.containsKey(pkg)) { + rootAppIdByPackage.put(pkg, appId); + List pkgs = rootPackagesByAppId.get(appId); + if (pkgs == null) { + pkgs = new ArrayList<>(); + rootPackagesByAppId.put(appId, pkgs); + } + pkgs.add(pkg); + } + } + } catch (Exception e) { + Log.w(TAG, "Backup: unable to list packages: " + e.getMessage()); + } + } + } + } +} diff --git a/app/src/main/java/dev/ukanth/ufirewall/util/BootRuleManager.java b/app/src/main/java/dev/ukanth/ufirewall/util/BootRuleManager.java index b30cb574b..81debeb84 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/util/BootRuleManager.java +++ b/app/src/main/java/dev/ukanth/ufirewall/util/BootRuleManager.java @@ -48,7 +48,15 @@ public class BootRuleManager { public static void initializeBootRuleApplication(Context context) { synchronized (ruleApplicationLock) { Log.i(TAG, "Initializing boot rule application"); - + + if (!shouldApplyBootRules(context)) { + Log.i(TAG, "Firewall disabled or inactive at boot; skipping boot rule application"); + // no rules will replace the fix leak script's DROP policies + Api.liftBootLeakProtection(context); + markBootComplete(); + return; + } + // Mark boot as in progress isBootInProgress.set(true); initialBootRulesApplied.set(false); @@ -94,11 +102,13 @@ private static void checkFixLeakScript(Context context) { */ private static void applyInitialBootRules(Context context) { Log.i(TAG, "Applying initial boot rules"); - - InterfaceTracker.applyBootRules(InterfaceTracker.BOOT_COMPLETED + "_INITIAL"); + + InterfaceTracker.getCurrentCfg(context, true); + + InterfaceTracker.applyBootRules(context, InterfaceTracker.BOOT_COMPLETED + "_INITIAL"); initialBootRulesApplied.set(true); - Log.i(TAG, "Initial boot rules applied"); + Log.i(TAG, "Initial boot rules submitted (result is logged by InterfaceTracker)"); } /** @@ -112,12 +122,17 @@ private static void scheduleDelayedBootRules(Context context) { Runnable delayedRules = () -> { synchronized (ruleApplicationLock) { if (isBootInProgress.get()) { + if (!shouldApplyBootRules(context)) { + Log.i(TAG, "Firewall disabled before delayed boot apply; skipping delayed rules"); + markBootComplete(); + return; + } Log.i(TAG, "Applying delayed boot rules"); try { // Force interface configuration refresh for delayed rules InterfaceTracker.getCurrentCfg(context, true); - InterfaceTracker.applyBootRules(InterfaceTracker.BOOT_COMPLETED + "_DELAYED"); - Log.i(TAG, "Delayed boot rules applied successfully"); + InterfaceTracker.applyBootRules(context, InterfaceTracker.BOOT_COMPLETED + "_DELAYED"); + Log.i(TAG, "Delayed boot rules submitted (result is logged by InterfaceTracker)"); } catch (Exception e) { Log.e(TAG, "Error applying delayed boot rules: " + e.getMessage()); } finally { @@ -146,6 +161,12 @@ private static void cancelDelayedBootRules() { delayedBootRulesScheduled.set(false); } + private static boolean shouldApplyBootRules(Context context) { + // BootRuleManager calls applyBootRules directly, so keep the same enabled checks + // that protect normal connectivity-change rule application. + return Api.isEnabled(context) && G.activeRules() && !Api.isDisableInProgress(); + } + /** * Mark boot process as complete */ @@ -178,7 +199,7 @@ public static boolean shouldProcessNetworkChange(Context context, String reason) Log.d(TAG, "Network change during boot delay period (" + reason + ") - allowing limited processing"); // Allow processing but don't trigger a full rule reapplication // The delayed boot rules will handle the final state - return false; + return true; } // Boot rules applied but no delay configured, allow network change processing diff --git a/app/src/main/java/dev/ukanth/ufirewall/util/DataUsageParser.java b/app/src/main/java/dev/ukanth/ufirewall/util/DataUsageParser.java new file mode 100644 index 000000000..088477946 --- /dev/null +++ b/app/src/main/java/dev/ukanth/ufirewall/util/DataUsageParser.java @@ -0,0 +1,280 @@ +package dev.ukanth.ufirewall.util; + +import com.topjohnwu.superuser.Shell; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import dev.ukanth.ufirewall.log.Log; + +/** + * Utility class to parse network data usage from /proc/net/xt_qtaguid + * Provides WiFi/Mobile separation and caching for performance + */ +public class DataUsageParser { + private static final String TAG = "DataUsageParser"; + + // Cache for data usage stats to avoid frequent root shell calls + private static final Map dataUsageCache = new ConcurrentHashMap<>(); + private static long lastCacheUpdate = 0; + private static final long CACHE_VALIDITY_MS = 30000; // 30 seconds cache + + public static class DataUsageStats { + public long wifiRxBytes = 0; + public long wifiTxBytes = 0; + public long mobileRxBytes = 0; + public long mobileTxBytes = 0; + public long totalRxBytes = 0; + public long totalTxBytes = 0; + + public long getTotalWifiBytes() { + return wifiRxBytes + wifiTxBytes; + } + + public long getTotalMobileBytes() { + return mobileRxBytes + mobileTxBytes; + } + + public long getTotalBytes() { + return totalRxBytes + totalTxBytes; + } + } + + /** + * Get data usage statistics for a specific UID + * @param uid The application UID + * @return DataUsageStats object with WiFi/Mobile breakdown + */ + public static DataUsageStats getDataUsageForUID(int uid) { + // Check cache first + if (isCacheValid() && dataUsageCache.containsKey(uid)) { + return dataUsageCache.get(uid); + } + + // Refresh cache if needed + if (!isCacheValid()) { + refreshDataUsageCache(); + } + + DataUsageStats stats = dataUsageCache.get(uid); + return stats != null ? stats : new DataUsageStats(); + } + + /** + * Check if the cache is still valid + */ + private static boolean isCacheValid() { + return (System.currentTimeMillis() - lastCacheUpdate) < CACHE_VALIDITY_MS; + } + + /** + * Refresh the entire data usage cache by parsing /proc/net/xt_qtaguid + */ + private static void refreshDataUsageCache() { + try { + dataUsageCache.clear(); + + // Try different possible locations for xt_qtaguid + String[] possiblePaths = { + "/proc/net/xt_qtaguid/stats", + "/proc/net/xt_qtaguid/ctrl", + "/proc/net/xt_qtaguid", + "/sys/kernel/debug/xt_qtaguid/stats" + }; + + String qtaguidData = null; + for (String path : possiblePaths) { + Shell.Result result = Shell.cmd("test -f " + path + " && cat " + path).exec(); + if (result.isSuccess() && !result.getOut().isEmpty()) { + qtaguidData = String.join("\n", result.getOut()); + Log.d(TAG, "Found xt_qtaguid data at: " + path); + break; + } + } + + if (qtaguidData != null) { + parseQtaguidData(qtaguidData); + } else { + // Fallback to TrafficStats if xt_qtaguid is not available + Log.w(TAG, "xt_qtaguid not available, using fallback method"); + useFallbackMethod(); + } + + lastCacheUpdate = System.currentTimeMillis(); + + } catch (Exception e) { + Log.e(TAG, "Error refreshing data usage cache", e); + useFallbackMethod(); + } + } + + /** + * Parse the xt_qtaguid data format + * Format: idx iface acct_tag_hex uid_tag_int cnt_set rx_bytes rx_packets tx_bytes tx_packets rx_tcp_bytes rx_tcp_packets tx_tcp_packets rx_udp_bytes rx_udp_packets tx_udp_bytes tx_udp_packets rx_other_bytes rx_other_packets tx_other_bytes tx_other_packets + */ + private static void parseQtaguidData(String qtaguidData) { + String[] lines = qtaguidData.split("\n"); + + for (String line : lines) { + if (line.trim().isEmpty() || line.startsWith("idx")) { + continue; // Skip empty lines and header + } + + String[] parts = line.trim().split("\\s+"); + if (parts.length < 8) { + continue; // Skip malformed lines + } + + try { + // Parse fields based on xt_qtaguid format + String iface = parts[1]; + String uidTagStr = parts[3]; + long rxBytes = Long.parseLong(parts[5]); + long txBytes = Long.parseLong(parts[7]); + + // Extract UID from uid_tag_int (format: uid << 32 | tag) + long uidTag = Long.parseLong(uidTagStr); + int uid = (int) (uidTag >> 32); + + if (uid <= 0) { + continue; // Skip invalid UIDs + } + + // Get or create stats for this UID + DataUsageStats stats = dataUsageCache.get(uid); + if (stats == null) { + stats = new DataUsageStats(); + dataUsageCache.put(uid, stats); + } + + // Classify interface type and accumulate data + if (isWiFiInterface(iface)) { + stats.wifiRxBytes += rxBytes; + stats.wifiTxBytes += txBytes; + } else if (isMobileInterface(iface)) { + stats.mobileRxBytes += rxBytes; + stats.mobileTxBytes += txBytes; + } + + // Update totals + stats.totalRxBytes += rxBytes; + stats.totalTxBytes += txBytes; + + } catch (NumberFormatException e) { + Log.w(TAG, "Failed to parse line: " + line); + } + } + + Log.d(TAG, "Parsed data usage for " + dataUsageCache.size() + " UIDs"); + } + + /** + * Check if interface is WiFi-related + */ + private static boolean isWiFiInterface(String iface) { + return iface != null && ( + iface.startsWith("wlan") || + iface.startsWith("wifi") || + iface.equals("wl0") || + iface.equals("eth0") // Sometimes WiFi appears as eth0 + ); + } + + /** + * Check if interface is mobile data-related + */ + private static boolean isMobileInterface(String iface) { + return iface != null && ( + iface.startsWith("rmnet") || + iface.startsWith("ccmni") || + iface.startsWith("pdp") || + iface.startsWith("ppp") || + iface.startsWith("mobile") || + iface.startsWith("radio") || + iface.matches("rmnet\\d+") || + iface.matches("rmnet_data\\d+") + ); + } + + /** + * Fallback method when xt_qtaguid is not available + * Uses /proc/uid_stat as AFWall+ already does + */ + private static void useFallbackMethod() { + // This provides total data only, no WiFi/Mobile separation + Shell.Result result = Shell.cmd("find /proc/uid_stat -name '[0-9]*' -type d 2>/dev/null").exec(); + + if (result.isSuccess()) { + for (String uidDir : result.getOut()) { + try { + String uidStr = uidDir.substring(uidDir.lastIndexOf('/') + 1); + int uid = Integer.parseInt(uidStr); + + // Read rx and tx bytes + Shell.Result rxResult = Shell.cmd("cat " + uidDir + "/tcp_rcv 2>/dev/null || echo 0").exec(); + Shell.Result txResult = Shell.cmd("cat " + uidDir + "/tcp_snd 2>/dev/null || echo 0").exec(); + + if (rxResult.isSuccess() && txResult.isSuccess()) { + long rxBytes = Long.parseLong(rxResult.getOut().get(0).trim()); + long txBytes = Long.parseLong(txResult.getOut().get(0).trim()); + + DataUsageStats stats = new DataUsageStats(); + stats.totalRxBytes = rxBytes; + stats.totalTxBytes = txBytes; + // Cannot separate WiFi/Mobile in fallback mode + + dataUsageCache.put(uid, stats); + } + + } catch (NumberFormatException e) { + Log.w(TAG, "Failed to parse UID directory: " + uidDir); + } + } + } + + Log.d(TAG, "Fallback method parsed " + dataUsageCache.size() + " UIDs"); + } + + /** + * Clear the cache to force refresh on next request + */ + public static void clearCache() { + dataUsageCache.clear(); + lastCacheUpdate = 0; + } + + /** + * Get human readable data usage string + */ + public static String formatDataUsage(long bytes) { + if (bytes < 0) return "0 B"; + if (bytes < 1024) return bytes + " B"; + + int unit = 1024; + int exp = (int) (Math.log(bytes) / Math.log(unit)); + String pre = "KMGTPE".charAt(exp - 1) + ""; + return String.format("%.1f %sB", bytes / Math.pow(unit, exp), pre); + } + + /** + * Format WiFi/Mobile breakdown for display + */ + public static String formatWifiMobileUsage(DataUsageStats stats) { + if (stats.getTotalWifiBytes() == 0 && stats.getTotalMobileBytes() == 0) { + return "No data usage"; + } + + StringBuilder sb = new StringBuilder(); + if (stats.getTotalWifiBytes() > 0) { + sb.append("📶 ").append(formatDataUsage(stats.getTotalWifiBytes())); + } + if (stats.getTotalMobileBytes() > 0) { + if (sb.length() > 0) sb.append(" | "); + sb.append("📱 ").append(formatDataUsage(stats.getTotalMobileBytes())); + } + + return sb.toString(); + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/ukanth/ufirewall/util/FileDialog.java b/app/src/main/java/dev/ukanth/ufirewall/util/FileDialog.java index 8fcf82304..5477da0e7 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/util/FileDialog.java +++ b/app/src/main/java/dev/ukanth/ufirewall/util/FileDialog.java @@ -2,17 +2,20 @@ import android.app.Activity; import android.app.Dialog; +import android.os.Build; import android.os.Environment; +import android.util.Log; import com.afollestad.materialdialogs.MaterialDialog; import com.topjohnwu.superuser.Shell; +import dev.ukanth.ufirewall.R; + import java.io.File; import java.io.FilenameFilter; import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import java.util.regex.Matcher; import java.util.regex.Pattern; /** @@ -20,6 +23,9 @@ */ public class FileDialog { private static final String PARENT_DIR = ".."; + // Matches all AFWall+ backup filenames (rules-only and all-prefs, plus legacy formats) + private static final Pattern BACKUP_FILE_PATTERN = Pattern.compile( + "afwall-backup(-[a-z]+)?-\\d{4}-\\S+\\.json|[a-z]+[_\\.][a-z]+\\.json"); private final String TAG = getClass().getName(); public void setFlag(boolean flag) { this.flag = flag; @@ -48,7 +54,15 @@ public interface DirectorySelectedListener { */ public FileDialog(Activity activity, File path, boolean flag) { this.activity = activity; - if (!path.exists()) path = Environment.getExternalStorageDirectory(); + if (!path.exists()) { + // On Android 11+, external storage root is not accessible due to scoped storage + File fallback = activity.getExternalFilesDir(null); + if (fallback != null && fallback.exists()) { + path = fallback; + } else { + path = Environment.getExternalStorageDirectory(); + } + } setFlag(flag); loadFileList(path,flag); } @@ -60,27 +74,46 @@ public Dialog createFileDialog() { Dialog dialog = null; //MaterialDialog.Builder - MaterialDialog.Builder builder = new MaterialDialog.Builder(activity); + MaterialDialog.Builder builder; + try { + builder = new MaterialDialog.Builder(activity); + } catch (Exception e) { + android.util.Log.e(TAG, "MaterialDialog.Builder failed due to Android compatibility issue", e); + // Return null to indicate dialog creation failed + return null; + } builder.title(currentPath.getPath()); if (selectDirectoryOption) { - + builder.positiveText(R.string.select_dir); + builder.negativeText(R.string.Cancel); builder.onPositive((dialog12, which) -> fireDirectorySelectedEvent(currentPath)); } builder.items(fileList); builder.itemsCallback((dialog1, view, which, text) -> { - String fileChosen = fileList[which]; - File chosenFile = getChosenFile(fileChosen); - if (chosenFile.isDirectory()) { - loadFileList(chosenFile,flag); - dialog1.cancel(); - dialog1.dismiss(); - showDialog(); - } else fireFileSelectedEvent(chosenFile); + try { + String fileChosen = fileList[which]; + File chosenFile = getChosenFile(fileChosen); + if (chosenFile != null && chosenFile.exists() && chosenFile.isDirectory()) { + loadFileList(chosenFile,flag); + dialog1.cancel(); + dialog1.dismiss(); + showDialog(); + } else if (chosenFile != null && chosenFile.exists()) { + fireFileSelectedEvent(chosenFile); + } + } catch (Exception e) { + Log.e(TAG, "Error in file selection callback", e); + } }); - dialog = builder.show(); + try { + dialog = builder.show(); + } catch (Exception e) { + android.util.Log.e(TAG, "MaterialDialog.show() failed due to Android compatibility issue", e); + return null; + } return dialog; } @@ -109,7 +142,13 @@ public void removeDirectoryListener(DirectorySelectedListener listener) { * Show file dialog */ public void showDialog() { - createFileDialog().show(); + Dialog dialog = createFileDialog(); + if (dialog != null) { + dialog.show(); + } else { + android.util.Log.e(TAG, "Cannot show file dialog due to MaterialDialog compatibility issue"); + // Could implement alternative file picker here if needed + } } private void fireFileSelectedEvent(final File file) { @@ -129,26 +168,7 @@ private void loadFileList(File path,final boolean flag) { File sel = new File(dir, filename); if (!sel.canRead()) return false; if (selectDirectoryOption) return sel.isDirectory(); - //backup.json - [a-z]+.json - else { - boolean endsWith; - if(flag) { - Pattern p1 = Pattern.compile("[a-z]+.json"); - Matcher m1 = p1.matcher(filename); - - Pattern p2 = Pattern.compile("[a-z]+-[a-z]+-\\d+-\\S*"); - Matcher m2 = p2.matcher(filename); - endsWith = m2.matches() || m1.matches(); - } else { - Pattern p1 = Pattern.compile("[a-z]+_[a-z]+.json"); - Matcher m1 = p1.matcher(filename); - - Pattern p2 = Pattern.compile("[a-z]+-[a-z]+-[a-z]+-\\d+-\\S*"); - Matcher m2 = p2.matcher(filename); - endsWith = m2.matches() || m1.matches(); - } - return endsWith || sel.isDirectory(); - } + return BACKUP_FILE_PATTERN.matcher(filename).matches() || sel.isDirectory(); }; String[] fileList1 = path.list(filter); if(fileList1 != null) { @@ -156,28 +176,14 @@ private void loadFileList(File path,final boolean flag) { } } //copied ones from old afwall - File[] listFilesInDir = currentPath.listFiles(); - if(listFilesInDir !=null && listFilesInDir.length > 0){ - for(File files: listFilesInDir) { - String name = files.getName(); - boolean endsWith; - if(flag) { - Pattern p1 = Pattern.compile("[a-z]+.json"); - Matcher m1 = p1.matcher(name); - - Pattern p2 = Pattern.compile("[a-z]+-[a-z]+-\\d+-\\S*"); - Matcher m2 = p2.matcher(name); - endsWith = m2.matches() || m1.matches(); - } else { - Pattern p1 = Pattern.compile("[a-z]+_[a-z]+.json"); - Matcher m1 = p1.matcher(name); - - Pattern p2 = Pattern.compile("[a-z]+-[a-z]+-[a-z]+-\\d+-\\S*"); - Matcher m2 = p2.matcher(name); - endsWith = m2.matches() || m1.matches(); - } - if (!r.contains(files) && endsWith) { - r.add(name); + if (!selectDirectoryOption) { + File[] listFilesInDir = currentPath.listFiles(); + if(listFilesInDir !=null && listFilesInDir.length > 0){ + for(File files: listFilesInDir) { + String name = files.getName(); + if (!r.contains(name) && BACKUP_FILE_PATTERN.matcher(name).matches()) { + r.add(name); + } } } } @@ -213,8 +219,14 @@ private void loadFileList(File path,final boolean flag) { } private File getChosenFile(String fileChosen) { - if (fileChosen.equals(PARENT_DIR)) return currentPath.getParentFile(); - else return new File(currentPath, fileChosen); + if (currentPath == null) { + return null; + } + if (fileChosen.equals(PARENT_DIR)) { + return currentPath.getParentFile(); // Can return null if at root + } else { + return new File(currentPath, fileChosen); + } } /*public void setFileEndsWith(String[] fileEndsWith,String notContains) { diff --git a/app/src/main/java/dev/ukanth/ufirewall/util/FingerprintUtil.java b/app/src/main/java/dev/ukanth/ufirewall/util/FingerprintUtil.java index 7b3117b5b..06786de9c 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/util/FingerprintUtil.java +++ b/app/src/main/java/dev/ukanth/ufirewall/util/FingerprintUtil.java @@ -89,6 +89,7 @@ public static class FingerprintDialog extends Dialog { // callbacks OnFingerprintFailure failureCallback; OnFingerprintSuccess successCallback; + OnFingerprintUnavailable unavailableCallback; @RequiresApi(api = Build.VERSION_CODES.M) public FingerprintDialog(Context context) { @@ -186,6 +187,10 @@ public void setOnFingerprintSuccess(OnFingerprintSuccess doSomething){ successCallback = doSomething; } + public void setOnFingerprintUnavailable(OnFingerprintUnavailable mayHappen){ + unavailableCallback = mayHappen; + } + /** * Created by whit3hawks on 11/16/16. * Modified by vzool on 1/14/17. @@ -200,19 +205,23 @@ void startReadFingerTip(){ * because we already checked if device support fingerprint before enable it. * We just leave it as-is for the days, who knows! :) */ - errorText.setText(R.string.device_with_no_fingerprint_sensor); + handleUnavailableFingerprint(R.string.device_with_no_fingerprint_sensor, + "Fingerprint hardware is not available"); }else { // Checks whether fingerprint permission is set on manifest if (ActivityCompat.checkSelfPermission(getContext(), Manifest.permission.USE_FINGERPRINT) != PackageManager.PERMISSION_GRANTED) { - errorText.setText(R.string.fingerprint_permission_manifest_missing); + handleUnavailableFingerprint(R.string.fingerprint_permission_manifest_missing, + "Fingerprint permission is missing"); }else{ // Check whether at least one fingerprint is registered if (!fingerprintManager.hasEnrolledFingerprints()) { - errorText.setText(R.string.register_at_least_one_fingerprint); + handleUnavailableFingerprint(R.string.register_at_least_one_fingerprint, + "No enrolled fingerprints are available"); }else{ // Checks whether lock screen security is enabled or not if (!keyguardManager.isKeyguardSecure()) { - errorText.setText(R.string.lock_screen_not_enabled); + handleUnavailableFingerprint(R.string.lock_screen_not_enabled, + "Lock screen security is not enabled"); }else{ generateKey(); @@ -227,6 +236,9 @@ void startReadFingerTip(){ } helper.startAuth(fingerprintManager, cryptoObject); + } else { + handleUnavailableFingerprint(R.string.fingerprint_security_changed, + "Fingerprint keystore key was invalidated"); } } } @@ -252,6 +264,24 @@ private void triggerSuccess(){ } } + private void handleUnavailableFingerprint(int messageResId, String reason) { + // Fingerprint enrollment or lock-screen changes invalidate the saved keystore key. + Log.e(TAG, reason); + G.isFingerprintEnabled(false); + if (errorText != null) { + errorText.setText(messageResId); + } + Api.toast(getContext(), getContext().getString(R.string.fingerprint_security_changed)); + if(isShowing()) { + dismiss(); + } + if(unavailableCallback != null){ + unavailableCallback.then(); + } else if(failureCallback != null){ + failureCallback.then(); + } + } + @TargetApi(Build.VERSION_CODES.M) private void generateKey() { try { @@ -304,6 +334,7 @@ private boolean cipherInit() { cipher.init(Cipher.ENCRYPT_MODE, key); return true; } catch (KeyPermanentlyInvalidatedException e) { + Log.e(TAG, "Fingerprint keystore key permanently invalidated", e); return false; } catch (KeyStoreException | CertificateException | UnrecoverableKeyException | IOException | NoSuchAlgorithmException | InvalidKeyException e) { throw new RuntimeException("Failed to init Cipher", e); @@ -394,4 +425,9 @@ public interface OnFingerprintFailure{ public interface OnFingerprintSuccess{ void then(); } + + // interface for callback when fingerprint setup is no longer usable + public interface OnFingerprintUnavailable{ + void then(); + } } diff --git a/app/src/main/java/dev/ukanth/ufirewall/util/FirewallActions.java b/app/src/main/java/dev/ukanth/ufirewall/util/FirewallActions.java new file mode 100644 index 000000000..49e3c9405 --- /dev/null +++ b/app/src/main/java/dev/ukanth/ufirewall/util/FirewallActions.java @@ -0,0 +1,145 @@ +package dev.ukanth.ufirewall.util; + +import android.content.Context; + +import dev.ukanth.ufirewall.Api; +import dev.ukanth.ufirewall.R; +import dev.ukanth.ufirewall.profiles.ProfileData; +import dev.ukanth.ufirewall.profiles.ProfileHelper; +import dev.ukanth.ufirewall.service.RootCommand; + +/** + * Enable / disable / profile switch as started from outside the app (widgets, tile, Tasker). + * The enabled state is always taken from the result, and a profile switch only applies rules + * while the firewall is enabled. + */ +public final class FirewallActions { + + /** + * Called with the result, on the root shell thread. + */ + public interface Done { + void done(boolean success); + } + + private FirewallActions() { + } + + public static void setEnabled(Context ctx, boolean enable, boolean showToasts, Done done) { + final Context app = ctx.getApplicationContext(); + RootCommand cmd = new RootCommand() + .setReopenShell(true) + .setCallback(new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + boolean ok = state.exitCode == 0; + if (ok) { + // a failed disable leaves rules loaded: keep the state "enabled" then + Api.setEnabled(app, enable, true); + } + if (done != null) { + done.done(ok); + } + } + }); + if (showToasts) { + cmd.setSuccessToast(enable ? R.string.toast_enabled : R.string.toast_disabled) + .setFailureToast(enable ? R.string.toast_error_enabling : R.string.toast_error_disabling); + } + if (enable) { + Api.applySavedIptablesRules(app, true, cmd); + } else { + Api.purgeIptables(app, true, cmd); + } + } + + /** + * Make {@code identifier} the active profile and, if the firewall is enabled, apply its rules. + * While the firewall is disabled only the selection changes (applying would load rules while + * the app says it is off). + * + * @param done called with the apply result, or with true right away when nothing is applied + */ + public static void switchProfile(Context ctx, String identifier, boolean showToasts, Done done) { + final Context app = ctx.getApplicationContext(); + G.setProfile(true, identifier); + if (!Api.isEnabled(app)) { + if (showToasts) { + Api.toast(app, app.getString(R.string.profile_selected_firewall_off)); + } + if (done != null) { + done.done(true); + } + return; + } + RootCommand cmd = new RootCommand() + .setCallback(new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + if (done != null) { + done.done(state.exitCode == 0); + } + } + }); + if (showToasts) { + cmd.setSuccessToast(R.string.rules_applied).setFailureToast(R.string.error_apply); + } + Api.applySavedIptablesRules(app, true, cmd); + } + + /** + * Allow or block an app on the connection types in use, in the active profile, and apply the + * rules if the firewall is enabled. In allow-list mode the lists hold allowed apps, in block-list + * mode blocked ones. + */ + public static void setAppAccess(Context ctx, int uid, boolean allow) { + final Context app = ctx.getApplicationContext(); + boolean whitelist = Api.MODE_WHITELIST.equals(G.pPrefs.getString(Api.PREF_MODE, Api.MODE_WHITELIST)); + boolean listed = whitelist == allow; + java.util.List keys = new java.util.ArrayList<>(java.util.Arrays.asList( + Api.PREF_WIFI_PKG_UIDS, Api.PREF_3G_PKG_UIDS)); + if (G.enableRoam()) { + keys.add(Api.PREF_ROAMING_PKG_UIDS); + } + if (G.enableVPN()) { + keys.add(Api.PREF_VPN_PKG_UIDS); + } + if (G.enableLAN()) { + keys.add(Api.PREF_LAN_PKG_UIDS); + } + if (G.enableTether()) { + keys.add(Api.PREF_TETHER_PKG_UIDS); + } + android.content.SharedPreferences.Editor edit = G.pPrefs.edit(); + for (String key : keys) { + java.util.Set uids = new java.util.TreeSet<>(UidListParser.parse(G.pPrefs.getString(key, ""))); + if (listed) { + uids.add(uid); + } else { + uids.remove(uid); + } + edit.putString(key, android.text.TextUtils.join("|", uids)); + } + edit.apply(); + Api.applications = null; // the app list shows the new state + if (Api.isEnabled(app)) { + Api.applySavedIptablesRules(app, true, new RootCommand().setFailureToast(R.string.error_apply)); + } + } + + /** + * @return identifier of the profile shown as {@code name}; the default profile's name maps to + * the default profile. Null if there is no such profile. + */ + public static String identifierForName(Context ctx, String name) { + if (name == null) { + return null; + } + String defaultName = G.gPrefs.getString("default", ctx.getString(R.string.defaultProfile)); + if (name.equals(defaultName) || name.equals(Api.DEFAULT_PREFS_NAME)) { + return Api.DEFAULT_PREFS_NAME; + } + ProfileData data = ProfileHelper.getProfileByName(name); + return data != null ? data.getIdentifier() : null; + } +} diff --git a/app/src/main/java/dev/ukanth/ufirewall/util/G.java b/app/src/main/java/dev/ukanth/ufirewall/util/G.java index 36d6aee15..71a59cd25 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/util/G.java +++ b/app/src/main/java/dev/ukanth/ufirewall/util/G.java @@ -34,6 +34,7 @@ import android.net.ConnectivityManager; import android.net.LinkProperties; import android.net.Network; +import android.net.NetworkCapabilities; import android.net.NetworkRequest; import android.os.Build; import android.os.Bundle; @@ -46,10 +47,13 @@ import com.raizlabs.android.dbflow.config.FlowManager; import com.raizlabs.android.dbflow.sql.language.SQLite; +import dev.ukanth.ufirewall.MainActivity; + import java.io.File; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.HashSet; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -59,11 +63,13 @@ import dev.ukanth.ufirewall.BuildConfig; import dev.ukanth.ufirewall.InterfaceTracker; import dev.ukanth.ufirewall.MainActivity; +import dev.ukanth.ufirewall.R; import dev.ukanth.ufirewall.log.Log; import dev.ukanth.ufirewall.log.LogPreference; import dev.ukanth.ufirewall.log.LogPreferenceDB; import dev.ukanth.ufirewall.log.LogPreference_Table; import dev.ukanth.ufirewall.preferences.DefaultConnectionPref; +import dev.ukanth.ufirewall.profiles.ProfileHelper; import dev.ukanth.ufirewall.preferences.DefaultConnectionPrefDB; public class G extends Application implements Application.ActivityLifecycleCallbacks{ @@ -73,12 +79,13 @@ public class G extends Application implements Application.ActivityLifecycleCallb private static boolean enabledPrivateLink = false; private static boolean isActivityVisible; + + private static Thread.UncaughtExceptionHandler defaultExceptionHandler; static { - //TODO: Remove this line before release - //com.topjohnwu.superuser.Shell.enableVerboseLogging = BuildConfig.DEBUG; com.topjohnwu.superuser.Shell.setDefaultBuilder(com.topjohnwu.superuser.Shell.Builder.create() .setFlags(com.topjohnwu.superuser.Shell.FLAG_REDIRECT_STDERR) + .setTimeout(30) // 30 second timeout for shell operations ); } @@ -95,6 +102,7 @@ public static Context getContext() { private static final String HAS_ROOT = "hasRoot"; private static final String FIX_START_LEAK = "fixLeak"; private static final String DISABLE_TASKER_TOAST = "disableTaskerToast"; + private static final String ALLOW_TASKER_CONTROL = "allowTaskerControl"; private static final String REG_DO = "ipurchaseddonatekey"; private static final String ENABLE_ROAM = "enableRoam"; private static final String ENABLE_VPN = "enableVPN"; @@ -107,7 +115,6 @@ public static Context getContext() { //private static final String BLOCK_IPV6 = "blockIPv6"; private static final String ENABLE_INBOUND = "enableInbound"; private static final String ENABLE_LOG_SERVICE = "enableLogService"; - private static final String LOG_PING_TIMEOUT = "logPingTime"; private static final String ENABLE_ADMIN = "enableAdmin"; private static final String DUAL_APPS = "supportDualApps"; private static final String ENABLE_DEVICE_CHECK = "enableDeviceCheck"; @@ -117,6 +124,7 @@ public static Context getContext() { private static final String NOTIFY_INSTALL = "notifyAppInstall"; private static final String DISABLE_ICONS = "disableIcons"; private static final String IPTABLES_PATH = "ipt_path"; + private static final String IPTABLES_BUILTIN_FAILED = "ipt_builtin_failed"; private static final String PROTECTION_OPTION = "passSetting"; private static final String BUSYBOX_PATH = "bb_path"; private static final String TOAST_POS = "toast_pos"; @@ -124,16 +132,27 @@ public static Context getContext() { //private static final String LOG_DMESG = "logDmesg"; private static final String SORT_BY = "sort"; private static final String LAST_STORED_PROFILE = "storedProfile"; - private static final String STARTUP_DELAY = "addDelayStart"; + private static final String STARTUP_DELAY = "addStartupDelay"; + private static final String STARTUP_DELAY_LEGACY = "addDelayStart"; private static final String SYSTEM_APP_COLOR = "sysColor"; private static final String PRIMARY_COLOR = "primaryColor"; - private static final String PRIMARY_DARK_COLOR = "primaryColor"; + private static final String PRIMARY_DARK_COLOR = "primaryDarkColor"; + private static final String ACCENT_COLOR = "accentColor"; + private static final String BACKGROUND_COLOR = "backgroundColor"; + private static final String TEXT_PRIMARY_COLOR = "textPrimaryColor"; + private static final String TEXT_SECONDARY_COLOR = "textSecondaryColor"; + private static final String USER_APP_COLOR = "userColor"; + private static final String DEFAULT_ICON_COLOR = "defaultIconColor"; + private static final String CUSTOM_THEME_COLORS = "customThemeColors"; + private static final String CUSTOM_THEME_SEED_THEME = "customThemeSeedTheme"; + + private static final String ENABLE_CUSTOM_RULES = "enableCustomRules"; private static final String ACTIVE_RULES = "activeRules"; private static final String ADD_DELAY = "addDelay"; - //private static final String ACTIVE_NOTIFICATION = "activeNotification"; + private static final String ACTIVE_NOTIFICATION = "activeNotification"; private static final String PROFILE_SWITCH = "applyOnSwitchProfiles"; private static final String LOG_TARGET = "logTarget"; private static final String LOG_TARGETS = "logTargets"; @@ -142,6 +161,7 @@ public static Context getContext() { private static final String MULTI_USER = "multiUser"; private static final String MULTI_USER_ID = "multiUserId"; private static final String IS_MIGRATED = "isMigrated"; + private static final String SHOW_PACKAGE_NAME = "showPackageName"; private static final String SHOW_FILTER = "showFilter"; private static final String PATTERN_MAX_TRY = "patternMax"; private static final String PATTERN_STEALTH = "stealthMode"; @@ -151,7 +171,6 @@ public static Context getContext() { private static final String FINGERPRINT_ENABLED = "fingerprintEnabled"; private static final String CUSTOM_DELAY_SECONDS = "customDelay"; private static final String NOTIFICATION_PRIORITY = "notification_priority"; - private static final String RUN_NOTIFICATION = "runNotification"; private static final String COPIED_OLD_EXPORTS = "copyOldExports"; private static final String SHOW_ALL_APPS = "showAllApps"; @@ -167,7 +186,6 @@ public static Context getContext() { private static final String AFWALL_STATUS = "AFWallStatus"; //private static final String BLOCKED_NOTIFICATION = "block_filter_app"; /* Profiles */ - private static final String ADDITIONAL_PROFILES = "plusprofiles"; //private static final String PROFILES = "profiles_json"; private static final String PROFILES_MIGRATED = "profilesmigrated"; private static final String WIDGET_X = "widgetX"; @@ -185,10 +203,6 @@ public static Context getContext() { private static final String INITPATH = "initPath"; - private static final String AFWALL_PROFILE = "AFWallProfile"; - //private static final String SHOW_LOG_TOAST = "showLogToasts"; - public static String[] profiles = {"AFWallPrefs", AFWALL_PROFILE + 1, AFWALL_PROFILE + 2, AFWALL_PROFILE + 3}; - public static String[] default_profiles = {"AFWallProfile1", "AFWallProfile2", "AFWallProfile3"}; public static Context ctx; public static SharedPreferences gPrefs; public static SharedPreferences pPrefs; @@ -203,7 +217,27 @@ public static void storedPid(Set store) { } public static boolean supportDual() { - return gPrefs.getBoolean(DUAL_APPS, false); + return gPrefs.getBoolean(DUAL_APPS, hasOtherProfiles()); + } + + private static Boolean hasOtherProfiles; + + /** + * Default of "dual apps support": on when the device has a work profile / clone profile, so + * those apps are listed without having to find the setting. Only applies until the user sets it. + */ + private static boolean hasOtherProfiles() { + if (hasOtherProfiles == null) { + boolean found = false; + try { + android.os.UserManager um = (android.os.UserManager) ctx.getSystemService(Context.USER_SERVICE); + found = um != null && um.getUserProfiles().size() > 1; + } catch (Exception e) { + Log.w(TAG, "Unable to list user profiles: " + e.getMessage()); + } + hasOtherProfiles = found; + } + return hasOtherProfiles; } public static boolean supportDual(boolean val) { @@ -222,14 +256,6 @@ public static boolean isFaster(boolean val) { } - public static boolean isRun() { - return gPrefs.getBoolean(RUN_NOTIFICATION, true); - } - - public static boolean isRun(boolean val) { - gPrefs.edit().putBoolean(RUN_NOTIFICATION, val).commit(); - return val; - } public static boolean hasCopyOld() { @@ -335,10 +361,88 @@ public static String getSelectedTheme() { } public static String getSelectedTheme(String val) { - gPrefs.edit().putString(THEME, val).commit(); + String theme = normalizeTheme(val); + SharedPreferences.Editor editor = gPrefs.edit().putString(THEME, theme); + seedCustomThemeColors(editor, theme); + editor.commit(); + return theme; + } + + private static void seedCustomThemeColors(SharedPreferences.Editor editor, String theme) { + editor.putInt(SYSTEM_APP_COLOR, defaultSystemAppColor(theme)) + .putInt(PRIMARY_COLOR, defaultPrimaryColor(theme)) + .putInt(PRIMARY_DARK_COLOR, defaultPrimaryDarkColor(theme)) + .putInt(ACCENT_COLOR, defaultAccentColor(theme)) + .putInt(BACKGROUND_COLOR, defaultBackgroundColor(theme)) + .putInt(TEXT_PRIMARY_COLOR, defaultTextPrimaryColor(theme)) + .putInt(TEXT_SECONDARY_COLOR, defaultTextSecondaryColor(theme)) + .putInt(USER_APP_COLOR, defaultUserAppColor(theme)) + .putInt(DEFAULT_ICON_COLOR, defaultAndroidIconColor(theme)) + .putString(CUSTOM_THEME_SEED_THEME, normalizeTheme(theme)); + } + + public static void seedCustomThemeColorsFromSelectedThemeIfNeeded() { + String theme = normalizeTheme(getSelectedTheme()); + if (!theme.equals(gPrefs.getString(CUSTOM_THEME_SEED_THEME, ""))) { + SharedPreferences.Editor editor = gPrefs.edit(); + seedCustomThemeColors(editor, theme); + editor.commit(); + } + } + + public static String normalizeTheme(String val) { + if ("L".equals(val) || "LHC".equals(val) || "B".equals(val) || "A".equals(val) + || "O".equals(val) || "F".equals(val) || "S".equals(val) || "P".equals(val)) { + return val; + } + return "D"; + } + + public static boolean isThemeDonorRequired(String val) { + return !"D".equals(normalizeTheme(val)); + } + + public static boolean canUseDonorFeatures(Context context) { + return isDonate() || (context != null && isDoKey(context)); + } + + public static boolean isThemeAvailable(String val, Context context) { + return !isThemeDonorRequired(val) || canUseDonorFeatures(context); + } + + public static String getEffectiveSelectedTheme(Context context) { + String theme = normalizeTheme(getSelectedTheme()); + return isThemeAvailable(theme, context) ? theme : "D"; + } + + public static boolean customThemeColorsEnabled(boolean val) { + gPrefs.edit().putBoolean(CUSTOM_THEME_COLORS, val).commit(); return val; } + public static boolean customThemeColorsEnabled() { + return gPrefs.getBoolean(CUSTOM_THEME_COLORS, false); + } + + public static boolean isCustomThemeActive(Context context) { + return customThemeColorsEnabled() && canUseDonorFeatures(context); + } + + public static int getSelectedThemeStyle(Context context) { + switch (getEffectiveSelectedTheme(context)) { + case "L": return R.style.AppLightTheme; + case "LHC": return R.style.AppLightHighContrastTheme; + case "B": return R.style.AppBlackTheme; + case "A": return R.style.AppAmberTheme; + case "O": return R.style.AppOceanTheme; + case "F": return R.style.AppForestTheme; + case "S": return R.style.AppSlateTheme; + case "P": return R.style.AppPlumTheme; + case "D": + default: return R.style.AppDarkTheme; + } + } + public static String profile_pwd() { return gPrefs.getString(PROFILE_PWD, ""); } @@ -349,7 +453,11 @@ public static String profile_pwd(String val) { } public static int getNotificationPriority() { - return Integer.parseInt(gPrefs.getString(NOTIFICATION_PRIORITY, "0")); + try { + return Integer.parseInt(gPrefs.getString(NOTIFICATION_PRIORITY, "0")); + } catch (NumberFormatException e) { + return 0; + } } @@ -389,7 +497,7 @@ public static boolean hasRoot(boolean val) { return val; } - /* public static boolean activeNotification() { + public static boolean activeNotification() { return gPrefs.getBoolean(ACTIVE_NOTIFICATION, true); } @@ -398,14 +506,6 @@ public static boolean activeNotification(boolean val) { return val; } - public static boolean showLogToasts() { - return gPrefs.getBoolean(SHOW_LOG_TOAST, false); - } - - public static boolean showLogToasts(boolean val) { - gPrefs.edit().putBoolean(SHOW_LOG_TOAST, val).commit(); - return val; - }*/ public static boolean fixLeak() { return gPrefs.getBoolean(FIX_START_LEAK, false); @@ -415,6 +515,14 @@ public static boolean disableTaskerToast() { return gPrefs.getBoolean(DISABLE_TASKER_TOAST, false); } + /** + * Tasker/Locale actions are accepted from any app (the plug-in API can't tell who sends them); + * on by default so existing setups keep working. + */ + public static boolean allowTaskerControl() { + return gPrefs.getBoolean(ALLOW_TASKER_CONTROL, true); + } + public static boolean enableIPv6() { return gPrefs.getBoolean(ENABLE_IPV6, true); } @@ -452,15 +560,24 @@ public static boolean enableLogService(boolean val) { return val; } - public static int logPingTimeout() { - return Integer.valueOf(gPrefs.getString(LOG_PING_TIMEOUT, "10")); + /** + * Read an int preference that may also be stored as a string: the setting screen stores an int, + * but older versions and old backup imports wrote a string, and getInt() would throw on that. + */ + private static int readInt(String key, int defaultValue) { + Object value = gPrefs.getAll().get(key); + if (value instanceof Integer) { + return (Integer) value; + } + if (value instanceof String) { + try { + return Integer.parseInt(((String) value).trim()); + } catch (NumberFormatException ignored) { + } + } + return defaultValue; } - /*public static void logPingTimeout(int logPingTimeout) { - gPrefs.edit().remove(LOG_PING_TIMEOUT); - gPrefs.edit().putString(LOG_PING_TIMEOUT, logPingTimeout+""); - }*/ - public static boolean enableAdmin() { return gPrefs.getBoolean(ENABLE_ADMIN, false); } @@ -511,6 +628,15 @@ public static boolean showUid(boolean val) { return val; } + public static boolean showPackageName() { + return gPrefs.getBoolean(SHOW_PACKAGE_NAME, false); + } + + public static boolean showPackageName(boolean val) { + gPrefs.edit().putBoolean(SHOW_PACKAGE_NAME, val).commit(); + return val; + } + public static boolean showFilter() { return gPrefs.getBoolean(SHOW_FILTER, false); } @@ -542,6 +668,14 @@ public static String ip_path(String val) { gPrefs.edit().putString(IPTABLES_PATH, val).commit(); return val; } + + public static boolean isBuiltinIptablesFailed() { + return gPrefs.getBoolean(IPTABLES_BUILTIN_FAILED, false); + } + + public static void setBuiltinIptablesFailed(boolean failed) { + gPrefs.edit().putBoolean(IPTABLES_BUILTIN_FAILED, failed).commit(); + } public static String bb_path() { @@ -593,28 +727,167 @@ public static String storedProfile(String val) { } public static int userColor() { - if (G.getSelectedTheme().equals("L")) { - return Color.parseColor("#000000"); - } else { - return Color.parseColor("#FFFFFF"); + return userColor(ctx); + } + + public static int userColor(Context context) { + if (isCustomThemeActive(context)) { + return gPrefs.getInt(USER_APP_COLOR, defaultUserAppColor(getEffectiveSelectedTheme(context))); } + return defaultUserAppColor(getEffectiveSelectedTheme(context)); } public static int sysColor() { - if (G.getSelectedTheme().equals("L")) { - return gPrefs.getInt(SYSTEM_APP_COLOR, Color.parseColor("#000000")); - } else { - return gPrefs.getInt(SYSTEM_APP_COLOR, Color.parseColor("#0F9D58")); + return sysColor(ctx); + } + + public static int sysColor(Context context) { + String theme = getEffectiveSelectedTheme(context); + return gPrefs.getInt(SYSTEM_APP_COLOR, defaultSystemAppColor(theme)); + } + + public static int primaryColor(Context context) { + String theme = getEffectiveSelectedTheme(context); + return isCustomThemeActive(context) + ? gPrefs.getInt(PRIMARY_COLOR, defaultPrimaryColor(theme)) + : defaultPrimaryColor(theme); + } + + public static int primaryDarkColor(Context context) { + String theme = getEffectiveSelectedTheme(context); + return isCustomThemeActive(context) + ? gPrefs.getInt(PRIMARY_DARK_COLOR, defaultPrimaryDarkColor(theme)) + : defaultPrimaryDarkColor(theme); + } + + public static int accentColor(Context context) { + String theme = getEffectiveSelectedTheme(context); + return isCustomThemeActive(context) + ? gPrefs.getInt(ACCENT_COLOR, defaultAccentColor(theme)) + : defaultAccentColor(theme); + } + + public static int backgroundColor(Context context) { + String theme = getEffectiveSelectedTheme(context); + return isCustomThemeActive(context) + ? gPrefs.getInt(BACKGROUND_COLOR, defaultBackgroundColor(theme)) + : defaultBackgroundColor(theme); + } + + public static int textPrimaryColor(Context context) { + String theme = getEffectiveSelectedTheme(context); + return isCustomThemeActive(context) + ? gPrefs.getInt(TEXT_PRIMARY_COLOR, defaultTextPrimaryColor(theme)) + : defaultTextPrimaryColor(theme); + } + + public static int textSecondaryColor(Context context) { + String theme = getEffectiveSelectedTheme(context); + return isCustomThemeActive(context) + ? gPrefs.getInt(TEXT_SECONDARY_COLOR, defaultTextSecondaryColor(theme)) + : defaultTextSecondaryColor(theme); + } + + public static int defaultIconColor() { + return defaultIconColor(ctx); + } + + public static int defaultIconColor(Context context) { + String theme = getEffectiveSelectedTheme(context); + return isCustomThemeActive(context) + ? gPrefs.getInt(DEFAULT_ICON_COLOR, defaultAndroidIconColor(theme)) + : defaultAndroidIconColor(theme); + } + + private static int defaultUserAppColor(String theme) { + return isLightTheme(theme) ? Color.parseColor("#000000") : Color.parseColor("#FFFFFF"); + } + + private static int defaultSystemAppColor(String theme) { + switch (normalizeTheme(theme)) { + case "L": + case "LHC": return Color.parseColor("#000000"); + case "B": return Color.parseColor("#FDDF6C"); + case "A": return Color.parseColor("#FFCA28"); + case "O": return Color.parseColor("#4FC3F7"); + case "F": return Color.parseColor("#81C784"); + case "S": return Color.parseColor("#90A4AE"); + case "P": return Color.parseColor("#CE93D8"); + case "D": + default: return Color.parseColor("#0F9D58"); } } - /*public static int primaryColor() { - return gPrefs.getInt(PRIMARY_COLOR, Color.parseColor("#259b24")); + private static int defaultPrimaryColor(String theme) { + switch (normalizeTheme(theme)) { + case "A": return Color.parseColor("#FFB300"); + case "O": return Color.parseColor("#0277BD"); + case "F": return Color.parseColor("#2E7D32"); + case "S": return Color.parseColor("#455A64"); + case "P": return Color.parseColor("#6A1B9A"); + case "LHC": return Color.parseColor("#FFFFFF"); + default: return Color.parseColor("#259B24"); + } } - public static int primaryDarkColor() { - return gPrefs.getInt(PRIMARY_DARK_COLOR, Color.parseColor("#0a7e07")); - }*/ + private static int defaultPrimaryDarkColor(String theme) { + switch (normalizeTheme(theme)) { + case "A": return Color.parseColor("#FF8F00"); + case "O": return Color.parseColor("#01579B"); + case "F": return Color.parseColor("#1B5E20"); + case "S": return Color.parseColor("#263238"); + case "P": return Color.parseColor("#4A148C"); + case "LHC": return Color.parseColor("#FFFFFF"); + default: return Color.parseColor("#0A7E07"); + } + } + + private static int defaultAccentColor(String theme) { + switch (normalizeTheme(theme)) { + case "L": + case "LHC": return Color.parseColor("#000000"); + case "B": return Color.parseColor("#FDDF6C"); + case "A": + case "O": return Color.parseColor("#00ACC1"); + case "F": return Color.parseColor("#C0CA33"); + case "S": return Color.parseColor("#FFB300"); + case "P": return Color.parseColor("#FF7043"); + case "D": + default: return Color.parseColor("#FFD740"); + } + } + + private static int defaultBackgroundColor(String theme) { + switch (normalizeTheme(theme)) { + case "L": + case "LHC": return Color.parseColor("#FFFFFF"); + case "B": return Color.parseColor("#000000"); + case "A": return Color.parseColor("#2B2415"); + case "O": return Color.parseColor("#102A43"); + case "F": return Color.parseColor("#17251B"); + case "S": return Color.parseColor("#1F2428"); + case "P": return Color.parseColor("#241B2F"); + case "D": + default: return Color.parseColor("#313131"); + } + } + + private static int defaultTextPrimaryColor(String theme) { + return isLightTheme(theme) ? Color.parseColor("#000000") : Color.parseColor("#FFFFFF"); + } + + private static int defaultTextSecondaryColor(String theme) { + return isLightTheme(theme) ? Color.parseColor("#000000") : Color.parseColor("#FFFFFF"); + } + + private static int defaultAndroidIconColor(String theme) { + return defaultSystemAppColor(theme); + } + + private static boolean isLightTheme(String theme) { + String t = normalizeTheme(theme); + return "L".equals(t) || "LHC".equals(t); + } public static boolean activeRules() { return gPrefs.getBoolean(ACTIVE_RULES, true); @@ -629,7 +902,13 @@ public static boolean addDelay() { } public static boolean startupDelay() { - return gPrefs.getBoolean(STARTUP_DELAY, false); + // the setting was saved as "addStartupDelay" but read from "addDelayStart", so it never + // took effect; honour both + return gPrefs.getBoolean(STARTUP_DELAY, false) || gPrefs.getBoolean(STARTUP_DELAY_LEGACY, false); + } + + public static void startupDelay(boolean val) { + gPrefs.edit().putBoolean(STARTUP_DELAY, val).remove(STARTUP_DELAY_LEGACY).commit(); } public static boolean isBootProcessActive() { @@ -641,7 +920,11 @@ public static boolean enableStealthPattern() { } public static int getMaxPatternTry() { - return Integer.parseInt(gPrefs.getString(PATTERN_MAX_TRY, "3")); + try { + return Integer.parseInt(gPrefs.getString(PATTERN_MAX_TRY, "3")); + } catch (NumberFormatException e) { + return 3; + } } public static boolean isMultiUser() { @@ -731,6 +1014,15 @@ public static boolean isDo(boolean val) { return val; } + public static boolean enableCustomRules() { + return gPrefs.getBoolean(ENABLE_CUSTOM_RULES, true); + } + + public static boolean enableCustomRules(boolean val) { + gPrefs.edit().putBoolean(ENABLE_CUSTOM_RULES, val).commit(); + return val; + } + public static boolean enableRoam() { return gPrefs.getBoolean(ENABLE_ROAM, false); } @@ -776,11 +1068,44 @@ public static boolean enableTor(boolean val) { return val; } + private static Boolean ownerModuleAvailable = null; - public static boolean isDonate() { + public static boolean hasOwnerModule() { + if (ownerModuleAvailable == null) { + // Test if owner module is available by attempting a simple command + // This will be cached for the lifetime of the application + try { + String testCmd = "iptables -t filter -N afwall_owner_test 2>/dev/null; iptables -A afwall_owner_test -m owner --uid-owner 0 -j RETURN 2>/dev/null; iptables -F afwall_owner_test 2>/dev/null; iptables -X afwall_owner_test 2>/dev/null"; + // For now, assume owner module is available - this will be tested at runtime + ownerModuleAvailable = true; + } catch (Exception e) { + ownerModuleAvailable = false; + } + } + return ownerModuleAvailable; + } + + public static void resetOwnerModuleCheck() { + ownerModuleAvailable = null; + } + + public static boolean hasDonateBuild() { return BuildConfig.APPLICATION_ID.equals("dev.ukanth.ufirewall.donate"); } + public static boolean hasDonateKey(Context ctx) { + try { + ctx.getPackageManager().getApplicationInfo("dev.ukanth.ufirewall.donatekey", 0); + return true; + } catch (PackageManager.NameNotFoundException | NullPointerException e) { + return false; + } + } + + public static boolean isDonate() { + return hasDonateBuild(); + } + public static boolean isDoKey(Context ctx) { if (!gPrefs.getBoolean(REG_DO, false)) { try { @@ -791,9 +1116,6 @@ public static boolean isDoKey(Context ctx) { } catch (PackageManager.NameNotFoundException | NullPointerException e) { gPrefs.edit().putBoolean(REG_DO, false).commit(); } - /*if(BuildConfig.DONATE){ - gPrefs.edit().putBoolean(REG_DO, true).commit(); - }*/ } return gPrefs.getBoolean(REG_DO, false); } @@ -813,7 +1135,11 @@ public static int getWidgetX(Context ctx) { } public static int getCustomDelay() { - return gPrefs.getInt(CUSTOM_DELAY_SECONDS, 5) * 1000; + return readInt(CUSTOM_DELAY_SECONDS, 5) * 1000; + } + + public static int getNetworkDebounceDelay() { + return gPrefs.getInt("networkDebounceDelay", 2); } public static int getWidgetY(Context ctx) { @@ -852,40 +1178,69 @@ public static void storeBlockedApps(List list) { preference.setUid(uid); preference.setTimestamp(System.currentTimeMillis()); preference.setDisable(true); + logMuteCache.put(uid, true); FlowManager.getDatabase(LogPreferenceDB.class).beginTransactionAsync(databaseWrapper -> preference.save(databaseWrapper)).build().execute(); } } - public static void storeDefaultConnection(List list1, List list2, int modeType) { - // store to DB + // Default connections for new apps. The table's key ("uid") is the connection column; it + // used to be the bare column (0-6) for both modes, so saving one mode overwrote the other. + // Each mode now has its own key range; rows of the old form are still read for the mode they + // were saved in, until that mode is saved again. + private static final int DEFAULT_CONNECTION_KEY_BASE = 1000; - for (Integer uid : list1) { - DefaultConnectionPref preference = new DefaultConnectionPref(); - preference.setUid(uid); - preference.setState(true); - preference.setModeType(modeType); - FlowManager.getDatabase(DefaultConnectionPrefDB.class).beginTransactionAsync(databaseWrapper -> preference.save(databaseWrapper)).build().execute(); + private static int defaultConnectionKey(int modeType, int column) { + return DEFAULT_CONNECTION_KEY_BASE + modeType * 100 + column; + } + + /** + * @param list1 connection columns selected by default + * @param list2 the other columns + * @param modeType 0 = allow-list mode, 1 = block-list mode + */ + public static void storeDefaultConnection(List list1, List list2, int modeType) { + for (DefaultConnectionPref legacy : SQLite.select().from(DefaultConnectionPref.class).queryList()) { + if (legacy.getUid() < DEFAULT_CONNECTION_KEY_BASE && legacy.getModeType() == modeType) { + legacy.delete(); // replaced by this mode's own rows + } } - for (Integer uid : list2) { - DefaultConnectionPref preference = new DefaultConnectionPref(); - preference.setUid(uid); - preference.setState(false); - preference.setModeType(modeType); - FlowManager.getDatabase(DefaultConnectionPrefDB.class).beginTransactionAsync(databaseWrapper -> preference.save(databaseWrapper)).build().execute(); + for (Integer column : list1) { + saveDefaultConnection(modeType, column, true); + } + for (Integer column : list2) { + saveDefaultConnection(modeType, column, false); } } + private static void saveDefaultConnection(int modeType, int column, boolean state) { + DefaultConnectionPref preference = new DefaultConnectionPref(); + preference.setUid(defaultConnectionKey(modeType, column)); + preference.setState(state); + preference.setModeType(modeType); + preference.save(); + } + + /** + * @return connection columns selected by default for new apps in {@code modeType} + */ public static List readDefaultConnection(int modeType) { - List list = SQLite.select() - .from(DefaultConnectionPref.class) - .queryList(); - List listSelected = new ArrayList<>(); - for (DefaultConnectionPref pref : list) { - if (pref.isState() && pref.getModeType() == modeType) { - listSelected.add(pref.getUid()); + List current = new ArrayList<>(); + List legacy = new ArrayList<>(); + boolean hasCurrent = false; + for (DefaultConnectionPref pref : SQLite.select().from(DefaultConnectionPref.class).queryList()) { + if (pref.getModeType() != modeType) { + continue; + } + if (pref.getUid() >= DEFAULT_CONNECTION_KEY_BASE) { + hasCurrent = true; + if (pref.isState()) { + current.add(pref.getUid() % 100); + } + } else if (pref.isState()) { + legacy.add(pref.getUid()); } } - return listSelected; + return hasCurrent ? current : legacy; } public static List readBlockedApps() { @@ -951,6 +1306,35 @@ public void onCreate() { //Shell.setFlags(Shell.ROOT_SHELL); //Shell.setFlags(Shell.FLAG_REDIRECT_STDERR); //Shell.verboseLogging(BuildConfig.DEBUG); + + // Store the default exception handler before replacing it + defaultExceptionHandler = Thread.getDefaultUncaughtExceptionHandler(); + + // Set up global exception handler for uncaught library crashes + Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() { + @Override + public void uncaughtException(Thread thread, Throwable throwable) { + // Check if this is the SuperUser library crash we're trying to prevent + if (throwable instanceof java.util.concurrent.RejectedExecutionException && + thread.getName().startsWith("pool-")) { + Log.w(TAG, "Caught SuperUser library RejectedExecutionException during app shutdown, ignoring to prevent crash"); + return; // Silently ignore this specific crash + } + + // Check for ExecutionException with InterruptedIOException + if (throwable instanceof java.util.concurrent.ExecutionException && + throwable.getCause() instanceof java.io.InterruptedIOException) { + Log.w(TAG, "Caught SuperUser library ExecutionException with InterruptedIOException during app shutdown, ignoring to prevent crash"); + return; // Silently ignore this specific crash + } + + // For all other exceptions, use the default handler + if (defaultExceptionHandler != null) { + defaultExceptionHandler.uncaughtException(thread, throwable); + } + } + }); + registerActivityLifecycleCallbacks(this); super.onCreate(); try { @@ -961,6 +1345,7 @@ public void onCreate() { } ctx = this.getApplicationContext(); reloadPrefs(); + ProfileHelper.migrateProfiles(ctx); //registerNetworkObserver(); } @@ -995,23 +1380,6 @@ public static boolean setProfile(boolean newEnableMultiProfile, String profileNa return true; } - public static void addAdditionalProfile(String profile) { - String previousProfiles = gPrefs.getString(ADDITIONAL_PROFILES, ""); - StringBuilder builder = new StringBuilder(); - if (profile != null && profile.length() > 0) { - profile = profile.trim(); - if (previousProfiles.length() == 0) { - builder.append(profile); - } else { - builder.append(previousProfiles); - builder.append(","); - builder.append(profile); - } - gPrefs.edit().putString(ADDITIONAL_PROFILES, builder.toString()).commit(); - } - } - - public static boolean clearSharedPreferences(Context ctx, String preferenceName) { File dir = new File(ctx.getFilesDir().getParent() + "/shared_prefs/"); String[] children = dir.list(); @@ -1024,50 +1392,35 @@ public static boolean clearSharedPreferences(Context ctx, String preferenceName) return true; } - public static boolean removeAdditionalProfile(String profileName) { - //after remove clear all the data inside the custom profile - if (ctx != null) { - //actually delete the file from disk - if (clearSharedPreferences(ctx, profileName)) { - String previousProfiles = gPrefs.getString(ADDITIONAL_PROFILES, ""); - if (!previousProfiles.isEmpty()) { - List items = new ArrayList(Arrays.asList(previousProfiles.split("\\s*,\\s*"))); - if (items.remove(profileName)) { - gPrefs.edit().putString(ADDITIONAL_PROFILES, TextUtils.join(",", items)).commit(); - return true; - } - } else { - return false; - } - } else { - return false; - } - } - return false; - } - - public static List getAdditionalProfiles() { - String previousProfiles = gPrefs.getString(ADDITIONAL_PROFILES, ""); - List items = new ArrayList<>(); - if (!previousProfiles.isEmpty()) { - items = new ArrayList(Arrays.asList(previousProfiles.split("\\s*,\\s*"))); - } - return items; - } - - public static List getDefaultProfiles() { - return new ArrayList(Arrays.asList(default_profiles)); - } - public static void updateLogNotification(int uid, boolean isChecked) { //update logic here LogPreference preference = new LogPreference(); preference.setUid(uid); preference.setTimestamp(System.currentTimeMillis()); preference.setDisable(isChecked); + logMuteCache.put(uid, isChecked); FlowManager.getDatabase(LogPreferenceDB.class).beginTransactionAsync(databaseWrapper -> preference.save(databaseWrapper)).build().execute(); } + // uid -> log notifications muted; canShow() is called for every logged packet + private static final java.util.concurrent.ConcurrentHashMap logMuteCache = + new java.util.concurrent.ConcurrentHashMap<>(); + + /** + * Forget cached mute states after LogPreference rows were written elsewhere (e.g. an import). + */ + public static void clearLogMuteCache() { + logMuteCache.clear(); + } + + /** + * Notifications for blocked connections (while the log service runs); on by default, as before + * the setting existed. + */ + public static boolean notifyBlocked() { + return gPrefs.getBoolean("notifyBlocked", true); + } + /*public static void isNotificationMigrated(boolean b) { gPrefs.edit().putBoolean("NewDBNotification", b).commit(); gPrefs.edit().putString(BLOCKED_NOTIFICATION, "").commit(); @@ -1078,10 +1431,15 @@ public static boolean isNotificationMigrated() { } public static boolean canShow(int uid) { - LogPreference logPreference = SQLite.select() - .from(LogPreference.class) - .where(LogPreference_Table.uid.eq(uid)).querySingle(); - return (logPreference == null) || !logPreference.isDisable(); + Boolean muted = logMuteCache.get(uid); + if (muted == null) { + LogPreference logPreference = SQLite.select() + .from(LogPreference.class) + .where(LogPreference_Table.uid.eq(uid)).querySingle(); + muted = logPreference != null && logPreference.isDisable(); + logMuteCache.put(uid, muted); + } + return !muted; } public static boolean isActivityVisible() { @@ -1118,7 +1476,10 @@ public void onActivityResumed(Activity activity) { @Override public void onActivityPaused(Activity activity) { - + if (activity instanceof MainActivity) { + isActivityVisible = false; + cleanupShellInstances(); + } } @Override @@ -1169,6 +1530,8 @@ public static boolean getPrivateDnsStatus() { } private static ConnectivityManager.NetworkCallback callback = null; + private static final Object VPN_NETWORK_LOCK = new Object(); + private static final Set vpnNetworks = new HashSet<>(); public static void registerPrivateLink() { if(!enabledPrivateLink) { @@ -1179,13 +1542,37 @@ public static void registerPrivateLink() { public void onLinkPropertiesChanged(Network network, LinkProperties linkProperties) { super.onLinkPropertiesChanged(network, linkProperties); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - if(linkProperties.isPrivateDnsActive() != privateDns) { - Log.i(Api.TAG, "Private DNS status changed: " + privateDns); - privateDns = linkProperties.isPrivateDnsActive(); - InterfaceTracker.applyRules("Private DNS changed.. reapplying rules"); + boolean privateDnsActive = linkProperties.isPrivateDnsActive(); + if(privateDnsActive != privateDns) { + Log.i(Api.TAG, "Private DNS status changed: " + privateDnsActive); + privateDns = privateDnsActive; + scheduleNetworkCallbackApply("Private DNS changed", true); } } } + + @Override + public void onCapabilitiesChanged(Network network, NetworkCapabilities networkCapabilities) { + super.onCapabilitiesChanged(network, networkCapabilities); + boolean isVpn = networkCapabilities != null + && networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN); + boolean wasVpn = updateTrackedVpnNetwork(network, isVpn); + if (isVpn || wasVpn) { + scheduleNetworkCallbackApply(isVpn + ? "VPN network capabilities changed" + : "VPN network capabilities removed", false); + } + } + + @Override + public void onLost(Network network) { + super.onLost(network); + if (removeTrackedVpnNetwork(network)) { + scheduleNetworkCallbackApply("VPN network lost", false); + } else { + Log.d(Api.TAG, "Non-VPN network lost; no VPN rule refresh needed"); + } + } }; } cm.registerNetworkCallback(new NetworkRequest.Builder().build(), callback); @@ -1194,4 +1581,75 @@ public void onLinkPropertiesChanged(Network network, LinkProperties linkProperti Log.i(TAG, "Private link has registered already"); } } + + private static boolean updateTrackedVpnNetwork(Network network, boolean isVpn) { + if (network == null) { + return false; + } + synchronized (VPN_NETWORK_LOCK) { + boolean wasVpn = vpnNetworks.contains(network); + if (isVpn) { + vpnNetworks.add(network); + } else { + vpnNetworks.remove(network); + } + return wasVpn; + } + } + + private static boolean removeTrackedVpnNetwork(Network network) { + if (network == null) { + return false; + } + synchronized (VPN_NETWORK_LOCK) { + return vpnNetworks.remove(network); + } + } + + private static void scheduleNetworkCallbackApply(String reason, boolean force) { + Context context = getContext(); + if (context == null || !Api.isEnabled(context) || !activeRules()) { + Log.d(TAG, reason + ": firewall inactive, not scheduling rule apply"); + return; + } + if (!force && !enableVPN()) { + Log.d(TAG, reason + ": VPN control is disabled, not scheduling rule apply"); + return; + } + Log.i(Api.TAG, reason + ", scheduling network rule refresh"); + // VPN connect/disconnect is not delivered through the legacy connectivity broadcast on all devices. + Api.noteNetworkChange(); + NetworkChangeDebouncer.scheduleNetworkChange(context, InterfaceTracker.CONNECTIVITY_CHANGE); + } + + @Override + public void onTerminate() { + try { + com.topjohnwu.superuser.Shell.getCachedShell().close(); + } catch (Exception e) { + } + super.onTerminate(); + } + + @Override + public void onLowMemory() { + try { + com.topjohnwu.superuser.Shell.getCachedShell().close(); + } catch (Exception e) { + } + super.onLowMemory(); + } + + private static void cleanupShellInstances() { + new Thread(() -> { + try { + com.topjohnwu.superuser.Shell shell = com.topjohnwu.superuser.Shell.getCachedShell(); + if (shell != null && !shell.isAlive()) { + shell.close(); + } + Thread.sleep(100); + } catch (Exception e) { + } + }).start(); + } } diff --git a/app/src/main/java/dev/ukanth/ufirewall/util/ImportApi.java b/app/src/main/java/dev/ukanth/ufirewall/util/ImportApi.java deleted file mode 100644 index 7ff0b7c0a..000000000 --- a/app/src/main/java/dev/ukanth/ufirewall/util/ImportApi.java +++ /dev/null @@ -1,150 +0,0 @@ -package dev.ukanth.ufirewall.util; - -@Deprecated -public class ImportApi { - - /* - private static File getDataDir(Context ctx, String packageName) { - try { - PackageInfo packageInfo = ctx.getPackageManager().getPackageInfo(packageName, 0); - if (packageInfo == null) return null; - ApplicationInfo applicationInfo = packageInfo.applicationInfo; - if (applicationInfo == null) return null; - if (applicationInfo.dataDir == null) return null; - return new File(applicationInfo.dataDir); - } catch (NameNotFoundException ex) { - return null; - } - } - - private static class LoadTask extends AsyncTask { - - private final Context ctx; - boolean[] result = {false}; - - private LoadTask(Context context) { - this.ctx = context; - } - - @Override - protected Boolean doInBackground(Void... voids) { - File sdCard = Environment.getExternalStorageDirectory(); - File dir = new File(sdCard.getAbsolutePath() + "/afwall/"); - dir.mkdirs(); - File shared_prefs = new File(getDataDir(ctx, "com.googlecode.droidwall.free") + File.separator + "shared_prefs" + File.separator + "DroidWallPrefs.xml"); - File file = new File(dir, "DroidWallPrefs.xml"); - RootTools.copyFile(shared_prefs.getPath(), dir.getPath(), true, false); - final Editor prefEdit = ctx.getSharedPreferences(Api.PREFS_NAME, Context.MODE_PRIVATE).edit(); - // write the logic to read the copied xml - String wifi = null, g = null; - try { - String xmlStr = readTextFile(new FileInputStream(file)); - Document doc = XMLfromString(xmlStr); - NodeList nodes = doc.getElementsByTagName("string"); - - for (int i = 0; i < nodes.getLength(); i++) { - Element e = (Element) nodes.item(i); - if (e.getAttribute("name").equals("AllowedUidsWifi")) { - wifi = getElementValue(e); - Log.d("AllowedUidsWifi", wifi); - } else if (e.getAttribute("name").equals("AllowedUids3G")) { - g = getElementValue(e); - Log.d("AllowedUids3G", g); - } - } - - } catch (FileNotFoundException e) { - } - - if (wifi != null) { - prefEdit.putString(Api.PREF_WIFI_PKG, getPackageListFromUID(ctx, wifi)); - prefEdit.putString(Api.PREF_WIFI_PKG_UIDS, wifi); - } - if (g != null) { - prefEdit.putString(Api.PREF_3G_PKG, getPackageListFromUID(ctx, g)); - prefEdit.putString(Api.PREF_3G_PKG_UIDS, g); - } - prefEdit.commit(); - result[0] = true; - return result[0]; - } - - @Override - protected void onPostExecute(Boolean result) { - // result holds what you return from doInBackground - } - } - - public static boolean loadSharedPreferencesFromDroidWall(Context ctx) { - try { - LoadTask task = new LoadTask(ctx); - task.execute(); - return task.result[0]; - } catch (Exception e) { - } - return false; - } - - private static String getElementValue(Node elem) { - Node kid; - if (elem != null) { - if (elem.hasChildNodes()) { - for (kid = elem.getFirstChild(); kid != null; kid = kid.getNextSibling()) { - if (kid.getNodeType() == Node.TEXT_NODE) { - return kid.getNodeValue(); - } - } - } - } - return ""; - } - - private static String readTextFile(InputStream inputStream) { - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - final byte[] buf = new byte[4096]; - int len; - try { - while ((len = inputStream.read(buf)) != -1) { - outputStream.write(buf, 0, len); - } - outputStream.close(); - inputStream.close(); - } catch (IOException e) { - - } - return outputStream.toString(); - } - - private static Document XMLfromString(String v) { - Document doc = null; - DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); - try { - DocumentBuilder db = dbf.newDocumentBuilder(); - InputSource is = new InputSource(); - is.setCharacterStream(new StringReader(v)); - doc = db.parse(is); - } catch (ParserConfigurationException e) { - } catch (SAXException e) { - } catch (IOException e) { - } - return doc; - - } - - private static String getPackageListFromUID(Context ctx, final String uids) { - final PackageManager pm = ctx.getPackageManager(); - final StringBuilder pkg = new StringBuilder(); - final StringTokenizer tok = new StringTokenizer(uids, "|"); - while (tok.hasMoreTokens()) { - final int uid = Integer.parseInt(tok.nextToken()); - String[] pack = pm.getPackagesForUid(uid); - if (pack != null && pack.length == 1) { - pkg.append(pack[0]).append("|"); - } - if (uid == 1000) { - pkg.append("android|"); - } - } - return pkg.toString(); - }*/ -} diff --git a/app/src/main/java/dev/ukanth/ufirewall/util/InputValidator.java b/app/src/main/java/dev/ukanth/ufirewall/util/InputValidator.java new file mode 100644 index 000000000..ef49e3a74 --- /dev/null +++ b/app/src/main/java/dev/ukanth/ufirewall/util/InputValidator.java @@ -0,0 +1,318 @@ +package dev.ukanth.ufirewall.util; + +import android.content.Intent; +import android.os.Bundle; +import android.text.TextUtils; +import android.util.Log; + +import java.util.regex.Pattern; + +/** + * Input validation utility to prevent injection attacks and validate external data + */ +public class InputValidator { + private static final String TAG = "InputValidator"; + + // Patterns for common validation scenarios + private static final Pattern PACKAGE_NAME_PATTERN = Pattern.compile("^[a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*$"); + private static final Pattern UID_PATTERN = Pattern.compile("^[0-9]+$"); + private static final Pattern IP_ADDRESS_PATTERN = Pattern.compile( + "^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$" + ); + private static final Pattern PORT_PATTERN = Pattern.compile("^([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$"); + private static final Pattern INTERFACE_NAME_PATTERN = Pattern.compile("^[a-zA-Z0-9]+$"); + + // Dangerous characters that should not appear in most inputs + private static final Pattern DANGEROUS_CHARS = Pattern.compile("[;&|`$\\r\\n<>\"'\\\\]"); + + // Maximum lengths for various input types + private static final int MAX_PACKAGE_NAME_LENGTH = 256; + private static final int MAX_FILE_PATH_LENGTH = 4096; + private static final int MAX_RULE_LENGTH = 1024; + private static final int MAX_PROFILE_NAME_LENGTH = 64; + + /** + * Validate and sanitize a package name + */ + public static String validatePackageName(String packageName) { + if (TextUtils.isEmpty(packageName)) { + return null; + } + + if (packageName.length() > MAX_PACKAGE_NAME_LENGTH) { + Log.w(TAG, "Package name too long: " + packageName.length() + " characters"); + return null; + } + + if (!PACKAGE_NAME_PATTERN.matcher(packageName).matches()) { + Log.w(TAG, "Invalid package name format: " + packageName); + return null; + } + + return packageName.trim(); + } + + /** + * Validate UID string + */ + public static Integer validateUid(String uidString) { + if (TextUtils.isEmpty(uidString)) { + return null; + } + + String trimmed = uidString.trim(); + if (!UID_PATTERN.matcher(trimmed).matches()) { + Log.w(TAG, "Invalid UID format: " + uidString); + return null; + } + + try { + int uid = Integer.parseInt(trimmed); + if (uid < 0 || uid > 99999) { // Android UID range + Log.w(TAG, "UID out of valid range: " + uid); + return null; + } + return uid; + } catch (NumberFormatException e) { + Log.w(TAG, "Could not parse UID: " + uidString, e); + return null; + } + } + + /** + * Validate IP address + */ + public static String validateIpAddress(String ipAddress) { + if (TextUtils.isEmpty(ipAddress)) { + return null; + } + + String trimmed = ipAddress.trim(); + if (!IP_ADDRESS_PATTERN.matcher(trimmed).matches()) { + Log.w(TAG, "Invalid IP address format: " + ipAddress); + return null; + } + + return trimmed; + } + + /** + * Validate port number + */ + public static Integer validatePort(String portString) { + if (TextUtils.isEmpty(portString)) { + return null; + } + + String trimmed = portString.trim(); + if (!PORT_PATTERN.matcher(trimmed).matches()) { + Log.w(TAG, "Invalid port format: " + portString); + return null; + } + + try { + return Integer.parseInt(trimmed); + } catch (NumberFormatException e) { + Log.w(TAG, "Could not parse port: " + portString, e); + return null; + } + } + + /** + * Validate network interface name + */ + public static String validateInterfaceName(String interfaceName) { + if (TextUtils.isEmpty(interfaceName)) { + return null; + } + + String trimmed = interfaceName.trim(); + if (!INTERFACE_NAME_PATTERN.matcher(trimmed).matches()) { + Log.w(TAG, "Invalid interface name: " + interfaceName); + return null; + } + + if (trimmed.length() > 16) { // Linux interface name limit + Log.w(TAG, "Interface name too long: " + trimmed); + return null; + } + + return trimmed; + } + + /** + * Validate and sanitize file path to prevent directory traversal + */ + public static String validateFilePath(String filePath) { + if (TextUtils.isEmpty(filePath)) { + return null; + } + + if (filePath.length() > MAX_FILE_PATH_LENGTH) { + Log.w(TAG, "File path too long: " + filePath.length() + " characters"); + return null; + } + + String normalized = filePath.trim(); + + // Check for directory traversal attempts + if (normalized.contains("../") || normalized.contains("..\\") || + normalized.contains("/..") || normalized.contains("\\..")) { + Log.w(TAG, "Directory traversal attempt detected: " + filePath); + return null; + } + + // Check for dangerous characters + if (DANGEROUS_CHARS.matcher(normalized).find()) { + Log.w(TAG, "Dangerous characters in file path: " + filePath); + return null; + } + + return normalized; + } + + /** + * Validate custom iptables rule (additional validation beyond sanitizeRule in Api.java) + */ + public static String validateCustomRule(String rule) { + if (TextUtils.isEmpty(rule)) { + return null; + } + + if (rule.length() > MAX_RULE_LENGTH) { + Log.w(TAG, "Custom rule too long: " + rule.length() + " characters"); + return null; + } + + String trimmed = rule.trim(); + + // Additional security checks beyond Api.sanitizeRule + if (trimmed.toLowerCase().contains("exec") || + trimmed.toLowerCase().contains("system") || + trimmed.toLowerCase().contains("eval") || + trimmed.toLowerCase().contains("shell")) { + Log.w(TAG, "Potentially dangerous custom rule: " + rule); + return null; + } + + return trimmed; + } + + /** + * Validate profile name + */ + public static String validateProfileName(String profileName) { + if (TextUtils.isEmpty(profileName)) { + return null; + } + + if (profileName.length() > MAX_PROFILE_NAME_LENGTH) { + Log.w(TAG, "Profile name too long: " + profileName.length() + " characters"); + return null; + } + + String trimmed = profileName.trim(); + + // Only allow alphanumeric characters, spaces, hyphens, and underscores + if (!trimmed.matches("^[a-zA-Z0-9 _-]+$")) { + Log.w(TAG, "Invalid profile name format: " + profileName); + return null; + } + + return trimmed; + } + + /** + * Safely extract string from Intent extras with validation + */ + public static String getValidatedStringExtra(Intent intent, String key, int maxLength) { + if (intent == null || TextUtils.isEmpty(key)) { + return null; + } + + try { + String value = intent.getStringExtra(key); + if (TextUtils.isEmpty(value)) { + return null; + } + + if (value.length() > maxLength) { + Log.w(TAG, "Intent extra too long for key " + key + ": " + value.length() + " characters"); + return null; + } + + // Basic sanitization - remove control characters + String sanitized = value.replaceAll("[\\x00-\\x1F\\x7F]", ""); + return sanitized.trim(); + + } catch (Exception e) { + Log.w(TAG, "Error extracting intent extra for key: " + key, e); + return null; + } + } + + /** + * Safely extract string from Bundle with validation + */ + public static String getValidatedBundleString(Bundle bundle, String key, int maxLength) { + if (bundle == null || TextUtils.isEmpty(key)) { + return null; + } + + try { + String value = bundle.getString(key); + if (TextUtils.isEmpty(value)) { + return null; + } + + if (value.length() > maxLength) { + Log.w(TAG, "Bundle value too long for key " + key + ": " + value.length() + " characters"); + return null; + } + + // Basic sanitization - remove control characters + String sanitized = value.replaceAll("[\\x00-\\x1F\\x7F]", ""); + return sanitized.trim(); + + } catch (Exception e) { + Log.w(TAG, "Error extracting bundle value for key: " + key, e); + return null; + } + } + + /** + * General purpose string sanitization + */ + public static String sanitizeString(String input, int maxLength) { + if (TextUtils.isEmpty(input)) { + return null; + } + + if (input.length() > maxLength) { + Log.w(TAG, "Input too long: " + input.length() + " characters"); + return null; + } + + // Remove control characters and potentially dangerous characters + String sanitized = input.replaceAll("[\\x00-\\x1F\\x7F]", ""); + + if (DANGEROUS_CHARS.matcher(sanitized).find()) { + Log.w(TAG, "Dangerous characters found in input"); + return null; + } + + return sanitized.trim(); + } + + /** + * Check if a string contains only safe characters for use in shell commands + */ + public static boolean isSafeForShell(String input) { + if (TextUtils.isEmpty(input)) { + return false; + } + + // Allow only alphanumeric characters, hyphens, underscores, dots, and forward slashes + return input.matches("^[a-zA-Z0-9._/-]+$"); + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/ukanth/ufirewall/util/IptablesRestorePlanner.java b/app/src/main/java/dev/ukanth/ufirewall/util/IptablesRestorePlanner.java new file mode 100644 index 000000000..7d627fe86 --- /dev/null +++ b/app/src/main/java/dev/ukanth/ufirewall/util/IptablesRestorePlanner.java @@ -0,0 +1,186 @@ +package dev.ukanth.ufirewall.util; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Turns the iptables command list of a full rule apply into one {@code iptables-restore --noflush} + * input plus a few plain commands. + *

+ * The commands are replayed against an in-memory model of the chains they create or flush + * ({@code -N}/{@code -F}), so the restore input contains the exact final state of those chains + * and loads them in one atomic commit per table, instead of one full table rewrite per command. + * Operations on built-in chains (policies, the jumps into our chains) must not touch the rules + * other components keep there, so they are returned unchanged, in their original order, to run + * as plain commands after the restore. + *

+ * Returns {@code null} when the list contains anything that cannot be reproduced exactly (custom + * script lines, unknown operations, chains we didn't create, ...); the caller then runs the + * commands one by one as before. Kept free of Android dependencies so it can be unit tested. + */ +public final class IptablesRestorePlanner { + + private static final String NOCHK = "#NOCHK# "; + private static final Set BUILTIN_CHAINS = new HashSet<>(Arrays.asList( + "INPUT", "OUTPUT", "FORWARD", "PREROUTING", "POSTROUTING")); + + public static final class Plan { + /** input for {@code iptables-restore --noflush} */ + public final String restoreInput; + /** built-in chain commands (same format as the input list) to run after the restore */ + public final List postCommands; + + Plan(String restoreInput, List postCommands) { + this.restoreInput = restoreInput; + this.postCommands = postCommands; + } + } + + private static final class Table { + // chain -> rule specs, in declaration order + final LinkedHashMap> chains = new LinkedHashMap<>(); + } + + private IptablesRestorePlanner() { + } + + /** + * @param cmds commands without the binary name, e.g. "-A afwall -j RETURN", + * "#NOCHK# -N afwall", "-t nat -F afwall-tor", "-P OUTPUT DROP" + * @return the plan, or null if the list can't be expressed exactly + */ + public static Plan plan(List cmds) { + Map tables = new LinkedHashMap<>(); + tables.put("filter", new Table()); + tables.put("nat", new Table()); + List post = new ArrayList<>(); + + for (String raw : cmds) { + if (raw == null) { + continue; + } + boolean nochk = raw.startsWith(NOCHK); + String cmd = (nochk ? raw.substring(NOCHK.length()) : raw).trim(); + if (cmd.isEmpty() || cmd.startsWith("#")) { + return null; // #LITERAL# (custom script) or unknown marker + } + + String table = "filter"; + if (cmd.startsWith("-t ")) { + String[] t = cmd.split("\\s+", 3); + if (t.length < 3) { + return null; + } + table = t[1]; + cmd = t[2]; + } + Table model = tables.get(table); + if (model == null) { + return null; + } + + String[] tok = cmd.split("\\s+", 3); + if (tok.length < 2) { + return null; + } + String op = tok[0]; + String chain = tok[1]; + String spec = tok.length > 2 ? tok[2].trim() : ""; + + if (BUILTIN_CHAINS.contains(chain)) { + if (!op.equals("-P") && !op.equals("-A") && !op.equals("-I") && !op.equals("-D")) { + return null; + } + post.add(raw); + continue; + } + + List rules = model.chains.get(chain); + switch (op) { + case "-N": + if (rules != null) { + if (!nochk) { + return null; // would fail: chain exists + } + } else { + // may already exist in the kernel; declaring it creates or flushes it, + // which is what the -N/-F pairs of a full apply amount to + model.chains.put(chain, new ArrayList<>()); + } + break; + case "-F": + if (rules == null) { + if (!nochk) { + return null; // existence unknown: plain -F fails on a missing chain + } + model.chains.put(chain, new ArrayList<>()); + } else { + rules.clear(); + } + break; + case "-A": + if (rules == null || spec.isEmpty()) { + return null; // chain not created by this list: its current rules are unknown + } + rules.add(spec); + break; + case "-I": { + if (rules == null || spec.isEmpty()) { + return null; + } + int pos = 1; + String[] p = spec.split("\\s+", 2); + if (p[0].matches("\\d+")) { + pos = Integer.parseInt(p[0]); + if (p.length < 2) { + return null; + } + spec = p[1]; + } + if (pos < 1 || pos > rules.size() + 1) { + return null; + } + rules.add(pos - 1, spec); + break; + } + case "-D": + if (rules == null || spec.isEmpty() || spec.matches("\\d+")) { + return null; + } + if (!rules.remove(spec) && !nochk) { + return null; // would fail: no such rule + } + break; + default: + return null; + } + } + + StringBuilder in = new StringBuilder(); + for (Map.Entry e : tables.entrySet()) { + Table t = e.getValue(); + if (t.chains.isEmpty()) { + continue; + } + in.append('*').append(e.getKey()).append('\n'); + for (String chain : t.chains.keySet()) { + in.append(':').append(chain).append(" - [0:0]\n"); + } + for (Map.Entry> c : t.chains.entrySet()) { + for (String spec : c.getValue()) { + in.append("-A ").append(c.getKey()).append(' ').append(spec).append('\n'); + } + } + in.append("COMMIT\n"); + } + if (in.length() == 0) { + return null; + } + return new Plan(in.toString(), post); + } +} diff --git a/app/src/main/java/dev/ukanth/ufirewall/util/IptablesVersion.java b/app/src/main/java/dev/ukanth/ufirewall/util/IptablesVersion.java new file mode 100644 index 000000000..0c3c76685 --- /dev/null +++ b/app/src/main/java/dev/ukanth/ufirewall/util/IptablesVersion.java @@ -0,0 +1,64 @@ +package dev.ukanth.ufirewall.util; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Picks the lock-wait option supported by an iptables version. + *

+ * "-w" appeared in iptables 1.4.20, "-w <seconds>" in 1.6.0 and iptables-restore learned + * "-w" in 1.6.2. Passing "-w 5" to 1.4.20 (Android 6 systems) fails every command with + * "Bad argument '5'". Kept free of Android dependencies so it can be unit tested. + */ +public final class IptablesVersion { + + private static final Pattern VERSION = Pattern.compile("v(\\d+)\\.(\\d+)\\.(\\d+)"); + + private IptablesVersion() { + } + + /** + * @param versionOutput output of "iptables --version", e.g. "iptables v1.8.10 (legacy)" + * @return {major, minor, patch}, or null if it can't be parsed + */ + public static int[] parse(String versionOutput) { + if (versionOutput == null) { + return null; + } + Matcher m = VERSION.matcher(versionOutput); + if (!m.find()) { + return null; + } + return new int[]{Integer.parseInt(m.group(1)), Integer.parseInt(m.group(2)), Integer.parseInt(m.group(3))}; + } + + /** + * @return option to append to iptables commands (with a leading space), "" for none. + * An unknown version gets the modern option, as before. + */ + public static String waitOption(String versionOutput) { + int[] v = parse(versionOutput); + if (v == null || atLeast(v, 1, 6, 0)) { + return " -w 5"; + } + return atLeast(v, 1, 4, 20) ? " -w" : ""; + } + + /** + * @return option to pass to iptables-restore (with a leading space), "" for none + */ + public static String restoreWaitOption(String versionOutput) { + int[] v = parse(versionOutput); + return v == null || atLeast(v, 1, 6, 2) ? " -w 5" : ""; + } + + private static boolean atLeast(int[] v, int major, int minor, int patch) { + if (v[0] != major) { + return v[0] > major; + } + if (v[1] != minor) { + return v[1] > minor; + } + return v[2] >= patch; + } +} diff --git a/app/src/main/java/dev/ukanth/ufirewall/util/LogNetUtil.java b/app/src/main/java/dev/ukanth/ufirewall/util/LogNetUtil.java index 960743523..b913c2315 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/util/LogNetUtil.java +++ b/app/src/main/java/dev/ukanth/ufirewall/util/LogNetUtil.java @@ -8,17 +8,12 @@ import com.afollestad.materialdialogs.DialogAction; import com.afollestad.materialdialogs.MaterialDialog; -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; import java.net.InetAddress; import java.net.UnknownHostException; -import java.util.List; import dev.ukanth.ufirewall.Api; import dev.ukanth.ufirewall.R; import dev.ukanth.ufirewall.log.Log; -import eu.chainfire.libsuperuser.Shell; /** * This file was created to simplify Network Function in AFWall+ log system @@ -36,8 +31,6 @@ public static class NetTask extends AsyncTask { Context context; String output_result = ""; - private static final String PING_CMD = "%s ping -w 1 -W %d %s"; - public NetTask(Context context) { this.context = context; } @@ -68,37 +61,6 @@ protected String doInBackground(NetParam... params) { start_time = System.currentTimeMillis(); try { switch (params[0].type) { - case PING: - // Ping - try { - String shell_result = ""; - String command = ""; - try { - // This command needs permission to allow - // AFWall+ itself to has access to network - // to work probably - command = String.format(PING_CMD, "", G.logPingTimeout(), params[0].address); - Log.d(TAG, "Execute CMD: " + command); - Process process = Runtime.getRuntime().exec(command); - process.waitFor(); - Log.d(TAG, "CMD exit code: " + process.exitValue()); - // check if ping command does not encounter any errors - if (process.exitValue() == 0) { - //The ping was succeeded. - shell_result = parse(process); - } else { - shell_result = su_busyboox_ping(params[0].address); - } - } catch (Exception ping_cmd_ex) { - Log.e(TAG, "Exception(00): " + ping_cmd_ex.getMessage()); - shell_result = su_busyboox_ping(params[0].address); - } - return shell_result; - } catch (Exception eex) { - Log.e(TAG, "Exception(01): " + eex.getMessage()); - // final choice is to use Android API - return normal_ping(params[0].address); - } case RESOLVE: // Resolve try { @@ -156,67 +118,9 @@ public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) } }).show(); } - - private String normal_ping(String ip) { - String result = ""; - try { - if (InetAddress.getByAddress(ip.getBytes()).isReachable(G.logPingTimeout() * 1000)) { // isReachable expect timeout in millisecond - result = String.format(context.getString(R.string.reachable_timeout), finish_time()); - } - } catch (Exception e) { - Log.e(TAG, "Exception(04): " + e.getMessage()); - result = String.format("Currently IP(%s) is not Reachable, timeout: %d ms", ip, finish_time()); - } - return result; - } - - private String su_busyboox_ping(String ip) { - // using libsuperuser to perform ping by Busybox, - // This will need permission in AFWall+ - // "0:(root) Apps running as root" - String result = ""; - String command = String.format(PING_CMD, Api.getBusyBoxPath(context, true), G.logPingTimeout(), ip); - Log.d(TAG, "Execute CMD: " + command); - result = parse(Shell.run("su", new String[]{command}, null, true)); - if (result.isEmpty()) { - - return context.getString(R.string.network_connection_not_available); - } - return result; - } - - private String parse(List output) { - StringBuilder resultBuilder = new StringBuilder(); - for (String line : output) { - resultBuilder.append(line).append(" "); - } - String result = resultBuilder.toString(); - if (result.isEmpty()) { - return context.getString(R.string.output_is_empty); - } - return result; - } - - private String parse(Process process) { - try { - BufferedReader bufferedReader = new BufferedReader( - new InputStreamReader(process.getInputStream())); - // Grab the results - StringBuilder log = new StringBuilder(); - String line; - while ((line = bufferedReader.readLine()) != null) { - log.append(line).append("\n"); - } - return log.toString(); - } catch (IOException e) { - Log.e(TAG, "Exception(05): " + e.getMessage()); - } - return context.getString(R.string.output_is_empty); - } } public enum JobType { - PING, RESOLVE } diff --git a/app/src/main/java/dev/ukanth/ufirewall/util/NetworkChangeDebouncer.java b/app/src/main/java/dev/ukanth/ufirewall/util/NetworkChangeDebouncer.java new file mode 100644 index 000000000..416784042 --- /dev/null +++ b/app/src/main/java/dev/ukanth/ufirewall/util/NetworkChangeDebouncer.java @@ -0,0 +1,218 @@ +/** + * Debounces rapid network connectivity changes to avoid excessive iptables rule applications. + *

+ * When network changes occur rapidly (e.g., switching between WiFi and mobile data), + * this class delays rule application until the network has been stable for a configurable + * period. If new changes arrive before the delay expires, the pending job is cancelled + * and rescheduled. + *

+ * Copyright (C) 2025 Umakanthan Chandran + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * @author Umakanthan Chandran + * @version 1.0 + */ +package dev.ukanth.ufirewall.util; + +import android.content.Context; +import android.os.Handler; +import android.os.Looper; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import dev.ukanth.ufirewall.Api; +import dev.ukanth.ufirewall.InterfaceTracker; +import dev.ukanth.ufirewall.log.Log; + +public class NetworkChangeDebouncer { + private static final String TAG = "AFWall"; + + // Default debounce delay in milliseconds + private static final long DEFAULT_DEBOUNCE_DELAY_MS = 2000; // 2 seconds + //Retry delay when rules are currently being applied + private static final long RETRY_DELAY_MS = 500; // 500ms + + // Maximum retry attempts + private static final int MAX_RETRY_ATTEMPTS = 10; + + // + // Handler for posting delayed tasks + private static final Handler handler = new Handler(Looper.getMainLooper()); + + // Currently scheduled runnable (if any) + private static final AtomicReference pendingRunnable = new AtomicReference<>(null); + + // Latest network change reason + private static final AtomicReference latestReason = new AtomicReference<>(null); + + // Latest context + private static final AtomicReference latestContext = new AtomicReference<>(null); + + // Flag to track if a job is currently scheduled + private static final AtomicBoolean isScheduled = new AtomicBoolean(false); + + // Retry counter + private static volatile int retryCount = 0; + + // Timestamp of last change request + private static volatile long lastChangeTimestamp = 0; + + // Counter for tracking how many changes were coalesced + private static volatile int coalescedCount = 0; + + /** + * Schedule a network change to be processed after the debounce delay. + * If a job is already scheduled, it will be cancelled and replaced with this one. + * + * @param context Application context + * @param reason Reason for the network change (e.g., CONNECTIVITY_CHANGE) + */ + public static void scheduleNetworkChange(Context context, String reason) { + // Cancel any existing pending job + cancelPendingJob(); + + // Store the latest change information + latestContext.set(context.getApplicationContext()); + latestReason.set(reason); + lastChangeTimestamp = System.currentTimeMillis(); + + // Get debounce delay from preferences + long debounceDelay = getDebounceDelay(); + + // Increment coalesced counter + if (isScheduled.get()) { + coalescedCount++; + Log.d(TAG, "Network change coalesced (total: " + coalescedCount + "): " + reason); + } else { + coalescedCount = 0; + retryCount = 0; + Log.d(TAG, "Network change scheduled with " + debounceDelay + "ms delay: " + reason); + } + + // Create new runnable for applying rules + Runnable applyRulesRunnable = new Runnable() { + @Override + public void run() { + try { + Context ctx = latestContext.get(); + String finalReason = latestReason.get(); + + if (ctx != null && finalReason != null) { + // Check if rules are currently being applied + if (Api.isRulesBeingApplied()) { + if (retryCount < MAX_RETRY_ATTEMPTS) { + retryCount++; + Log.d(TAG, "Rules currently being applied, retrying in " + RETRY_DELAY_MS + "ms (attempt " + retryCount + ")"); + // Reschedule with shorter delay + handler.postDelayed(this, RETRY_DELAY_MS); + return; + } else { + Log.w(TAG, "Max retry attempts reached, forcing rule application"); + } + } + + long elapsedTime = System.currentTimeMillis() - lastChangeTimestamp; + if (coalescedCount > 0) { + Log.i(TAG, "Applying rules after debounce (" + elapsedTime + "ms, " + + coalescedCount + " changes coalesced): " + finalReason); + } else { + Log.i(TAG, "Applying rules after debounce (" + elapsedTime + "ms): " + finalReason); + } + + // Apply the rules + InterfaceTracker.applyRulesOnChange(ctx, finalReason); + + // Reset state + coalescedCount = 0; + retryCount = 0; + } else { + Log.w(TAG, "Cannot apply rules: context or reason is null"); + } + } catch (Exception e) { + Log.e(TAG, "Error applying rules after debounce: " + e.getMessage(), e); + } finally { + // Clear scheduled state only if not retrying + if (retryCount == 0 || retryCount >= MAX_RETRY_ATTEMPTS) { + isScheduled.set(false); + pendingRunnable.set(null); + } + } + } + }; + + // Store the runnable and schedule it + pendingRunnable.set(applyRulesRunnable); + isScheduled.set(true); + handler.postDelayed(applyRulesRunnable, debounceDelay); + } + + /** + * Cancel any pending network change job. + */ + private static void cancelPendingJob() { + Runnable existingRunnable = pendingRunnable.getAndSet(null); + if (existingRunnable != null) { + handler.removeCallbacks(existingRunnable); + Log.d(TAG, "Cancelled pending network change job"); + } + isScheduled.set(false); + } + + /** + * Check if a job is currently scheduled. + * + * @return true if a network change job is pending + */ + public static boolean isPending() { + return isScheduled.get(); + } + + /** + * Get the debounce delay from preferences. + * Falls back to default if preference is not set or invalid. + * + * @return Debounce delay in milliseconds + */ + private static long getDebounceDelay() { + try { + // Try to get user-configured delay from preferences + int delaySeconds = G.getNetworkDebounceDelay(); + if (delaySeconds > 0 && delaySeconds <= 30) { + return delaySeconds * 1000L; + } + } catch (Exception e) { + Log.w(TAG, "Error reading debounce delay preference: " + e.getMessage()); + } + return DEFAULT_DEBOUNCE_DELAY_MS; + } + + /** + * Force immediate execution of any pending job (for testing or emergency situations). + */ + public static void flushPending() { + Runnable existingRunnable = pendingRunnable.getAndSet(null); + if (existingRunnable != null) { + handler.removeCallbacks(existingRunnable); + Log.i(TAG, "Flushing pending network change job immediately"); + // Execute immediately on current thread + existingRunnable.run(); + } + } + + /** + * Clear all pending jobs without executing them (for cleanup). + */ + public static void clear() { + cancelPendingJob(); + latestContext.set(null); + latestReason.set(null); + coalescedCount = 0; + retryCount = 0; + Log.d(TAG, "Cleared all pending network change jobs"); + } +} diff --git a/app/src/main/java/dev/ukanth/ufirewall/util/Notifications.java b/app/src/main/java/dev/ukanth/ufirewall/util/Notifications.java new file mode 100644 index 000000000..f225a81c7 --- /dev/null +++ b/app/src/main/java/dev/ukanth/ufirewall/util/Notifications.java @@ -0,0 +1,518 @@ +package dev.ukanth.ufirewall.util; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; + +import androidx.core.app.NotificationCompat; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; + +import dev.ukanth.ufirewall.Api; +import dev.ukanth.ufirewall.MainActivity; +import dev.ukanth.ufirewall.R; +import dev.ukanth.ufirewall.activity.LogActivity; +import dev.ukanth.ufirewall.broadcast.NotificationActionReceiver; +import dev.ukanth.ufirewall.log.LogInfo; +import dev.ukanth.ufirewall.profiles.ProfileHelper; +import dev.ukanth.ufirewall.service.FirewallService; +import dev.ukanth.ufirewall.service.RootCommand; +import dev.ukanth.ufirewall.widget.WidgetActionActivity; + +/** + * All of AFWall+'s notifications: channels, ids and builders in one place. + *

+ * Channel ids are unchanged from earlier versions, so the settings users made for them stay. + */ +public final class Notifications { + + public static final String CHANNEL_SERVICE = "firewall.service"; + public static final String CHANNEL_ERROR = "firewall.error"; + public static final String CHANNEL_APP = "firewall.app.notification"; + public static final String CHANNEL_LOG = "firewall.logservice"; + + public static final int ID_STATUS = 1; + public static final int ID_ERROR = 9; + public static final int ID_SCRIPT_WARNING = 10; + public static final int ID_LOG_WATCHER = 11; + // one notification per new app, tagged with its package name + private static final int ID_NEW_APP = 100; + private static final int ID_NEW_APP_SUMMARY = 101; + public static final int ID_BLOCKED = 109; + private static final String GROUP_NEW_APPS = "dev.ukanth.ufirewall.NEW_APPS"; + + // at most one update of the blocked-connections notification per interval + private static final long BLOCKED_UPDATE_INTERVAL_MS = 3000; + private static final int BLOCKED_MAX_APPS = 20; + + // outcome of the last full rule apply, for the status notification and the error details + private static volatile boolean lastApplyFailed; + private static volatile String lastFailure; + private static volatile int lastExitCode; + + private Notifications() { + } + + // ---- channels ---- + + public static void ensureChannels(Context ctx) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + NotificationManager nm = manager(ctx); + if (nm == null) { + return; + } + // status: silent and collapsed for new installs (it is always there) + createChannel(nm, CHANNEL_SERVICE, ctx.getString(R.string.firewall_service), + ctx.getString(R.string.channel_service_desc), NotificationManager.IMPORTANCE_LOW, true); + createChannel(nm, CHANNEL_ERROR, ctx.getString(R.string.firewall_error_notify), + ctx.getString(R.string.channel_error_desc), NotificationManager.IMPORTANCE_DEFAULT, false); + createChannel(nm, CHANNEL_APP, ctx.getString(R.string.app_notification), + ctx.getString(R.string.channel_app_desc), NotificationManager.IMPORTANCE_DEFAULT, false); + createChannel(nm, CHANNEL_LOG, ctx.getString(R.string.firewall_log_notify), + ctx.getString(R.string.channel_log_desc), NotificationManager.IMPORTANCE_DEFAULT, false); + } + + private static void createChannel(NotificationManager nm, String id, String name, String description, + int newImportance, boolean publicOnLockScreen) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + // An existing channel keeps its importance: the user may have changed it, and recreating it + // with another value would silently change their setup. + NotificationChannel existing = nm.getNotificationChannel(id); + int importance = existing != null ? existing.getImportance() : newImportance; + NotificationChannel channel = new NotificationChannel(id, name, importance); + channel.setDescription(description); + channel.setSound(null, null); + channel.enableLights(false); + channel.enableVibration(false); + channel.setShowBadge(false); + channel.setLockscreenVisibility(publicOnLockScreen ? Notification.VISIBILITY_PUBLIC : Notification.VISIBILITY_PRIVATE); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + channel.setAllowBubbles(false); + } + nm.createNotificationChannel(channel); + } + + // ---- status (the foreground service notification) ---- + + /** + * The status notification, as shown by FirewallService / LogService. + */ + public static Notification buildStatus(Context ctx, boolean logMonitoring) { + ensureChannels(ctx); + boolean enabled = Api.isEnabled(ctx); + String text; + if (!enabled) { + text = ctx.getString(R.string.inactive); + } else if (Api.isApplyInProgress()) { + text = ctx.getString(R.string.notif_applying); + } else if (lastApplyFailed) { + text = ctx.getString(R.string.notif_apply_failed); + } else { + text = ctx.getString(R.string.active); + if (G.enableMultiProfile()) { + text += " (" + ProfileHelper.displayName(ctx, G.storedProfile()) + ")"; + } + } + if (enabled && logMonitoring) { + text += " • " + ctx.getString(R.string.log_monitoring); + } + + NotificationCompat.Builder b = new NotificationCompat.Builder(ctx, CHANNEL_SERVICE) + .setSmallIcon(enabled && !lastApplyFailed ? R.drawable.notification : R.drawable.notification_error) + .setContentTitle(ctx.getString(R.string.app_name)) + .setContentText(text) + .setContentIntent(openApp(ctx, 0)) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setSilent(true) + .setShowWhen(false) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC); + + // actions go through the same confirmation / app lock as the widgets; no Disable here, so + // the firewall isn't one tap away from being turned off in the notification shade + if (!enabled) { + b.addAction(0, ctx.getString(R.string.enable), activity(ctx, 1, WidgetActionActivity.toggleIntent(ctx))); + } + if (enabled && G.enableMultiProfile()) { + b.addAction(0, ctx.getString(R.string.notif_action_profile), + activity(ctx, 2, WidgetActionActivity.profileIntent(ctx))); + } + if (enabled && logMonitoring) { + b.addAction(0, ctx.getString(R.string.show_log), activity(ctx, 3, logIntent(ctx))); + } + return b.build(); + } + + /** + * Redraw the status notification. It belongs to FirewallService; if that isn't running it is + * started (never posted without an owner: it couldn't be dismissed or kept up to date). + */ + public static void refreshStatus(Context ctx) { + if (ctx == null) { + return; + } + if (FirewallService.isInstanceRunning()) { + FirewallService.refreshNotification(); + } else { + FirewallService.ensureRunning(ctx); + } + } + + public static void onApplyStarted(Context ctx) { + refreshStatus(ctx); + } + + /** + * Record the result of a full apply before its callbacks run (they may post the error). + */ + public static void recordApplyResult(RootCommand state) { + lastApplyFailed = state.exitCode != 0; + lastExitCode = state.exitCode; + if (lastApplyFailed) { + String result = state.lastCommandResult != null ? state.lastCommandResult.toString().trim() : ""; + String command = state.lastCommand != null ? state.lastCommand : ""; + lastFailure = (command + (result.isEmpty() ? "" : "\n" + result)).trim(); + if (lastFailure.length() > 400) { + lastFailure = lastFailure.substring(0, 400) + "…"; + } + } else { + lastFailure = null; + } + } + + public static void onApplyFinished(Context ctx, boolean success) { + if (ctx == null) { + return; + } + if (success) { + // the problem is gone: don't leave "error applying rules" behind + cancel(ctx, ID_ERROR); + } + refreshStatus(ctx); + } + + // ---- errors and warnings ---- + + /** + * "Error applying firewall rules", with the failing command when known. + */ + public static void showApplyError(Context ctx) { + String detail = lastFailure; + if ((detail == null || detail.isEmpty()) && lastExitCode == -1) { + // the root shell could not be opened: nothing ran + detail = ctx.getString(R.string.notif_error_no_root); + } + String text = ctx.getString(R.string.error_notification_text); + String big = detail == null || detail.isEmpty() ? text : text + "\n\n" + detail; + show(ctx, ID_ERROR, ctx.getString(R.string.error_notification_title), text, big, null); + } + + /** + * A warning / error on the error channel that opens {@code target} (the app when null). + */ + public static void show(Context ctx, int id, String title, String text, String bigText, Intent target) { + ensureChannels(ctx); + NotificationManager nm = manager(ctx); + if (nm == null) { + return; + } + PendingIntent content = target != null ? activity(ctx, id, target) : openApp(ctx, id); + Notification n = new NotificationCompat.Builder(ctx, CHANNEL_ERROR) + .setSmallIcon(R.drawable.notification_warn) + .setContentTitle(title) + .setContentText(text) + .setStyle(new NotificationCompat.BigTextStyle().bigText(bigText != null ? bigText : text)) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setContentIntent(content) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .build(); + nm.notify(id, n); + } + + // ---- new apps ---- + + /** + * A new app with internet access was installed (in any Android user / profile). + */ + public static void newApp(Context ctx, int uid, String pkg, String label) { + ensureChannels(ctx); + NotificationManager nm = manager(ctx); + if (nm == null) { + return; + } + String name = label != null ? label : pkg; + String text = accessText(ctx, uid); + // distinct PendingIntent request codes per app and Android user (extras don't make them distinct) + int requestBase = 1000 + (uid % 100000) * 16 + ((uid / 100000) % 4) * 4; + + NotificationCompat.Builder b = new NotificationCompat.Builder(ctx, CHANNEL_APP) + .setSmallIcon(R.drawable.notification_quest) + .setContentTitle(ctx.getString(R.string.notif_new_app_title, name)) + .setContentText(text) + .setStyle(new NotificationCompat.BigTextStyle().bigText(text)) + .setCategory(NotificationCompat.CATEGORY_STATUS) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setContentIntent(openApp(ctx, requestBase)) + .setAutoCancel(true) + .setGroup(GROUP_NEW_APPS) + .addAction(0, ctx.getString(R.string.notif_allow), + activity(ctx, requestBase + 1, WidgetActionActivity.appRuleIntent(ctx, uid, pkg, name, true))) + .addAction(0, ctx.getString(R.string.notif_block), + activity(ctx, requestBase + 2, WidgetActionActivity.appRuleIntent(ctx, uid, pkg, name, false))); + nm.notify(newAppTag(uid, pkg), ID_NEW_APP, b.build()); + + // groups several installs into one entry + Notification summary = new NotificationCompat.Builder(ctx, CHANNEL_APP) + .setSmallIcon(R.drawable.notification_quest) + .setContentTitle(ctx.getString(R.string.notif_new_apps_summary)) + .setGroup(GROUP_NEW_APPS) + .setGroupSummary(true) + .setAutoCancel(true) + .setContentIntent(openApp(ctx, ID_NEW_APP_SUMMARY)) + .build(); + nm.notify(ID_NEW_APP_SUMMARY, summary); + } + + /** + * What internet access an app has in the active profile (after any default connections were + * applied): allowed everywhere, nowhere, or on some connection types. + */ + private static String accessText(Context ctx, int uid) { + boolean whitelist = Api.MODE_WHITELIST.equals(G.pPrefs.getString(Api.PREF_MODE, Api.MODE_WHITELIST)); + List types = new ArrayList<>(); // {name, rule list} + types.add(new String[]{ctx.getString(R.string.wifi), Api.PREF_WIFI_PKG_UIDS}); + types.add(new String[]{ctx.getString(R.string.data), Api.PREF_3G_PKG_UIDS}); + if (G.enableLAN()) { + types.add(new String[]{ctx.getString(R.string.lan), Api.PREF_LAN_PKG_UIDS}); + } + if (G.enableRoam()) { + types.add(new String[]{ctx.getString(R.string.roaming), Api.PREF_ROAMING_PKG_UIDS}); + } + if (G.enableVPN()) { + types.add(new String[]{ctx.getString(R.string.vpn), Api.PREF_VPN_PKG_UIDS}); + } + if (G.enableTether()) { + types.add(new String[]{ctx.getString(R.string.tether), Api.PREF_TETHER_PKG_UIDS}); + } + List allowed = new ArrayList<>(); + for (String[] type : types) { + boolean listed = UidListParser.parse(G.pPrefs.getString(type[1], "")).contains(uid); + // the lists hold allowed apps in allow-list mode, blocked apps in block-list mode + if (whitelist == listed) { + allowed.add(type[0]); + } + } + if (allowed.isEmpty()) { + return ctx.getString(R.string.notif_new_app_none); + } + if (allowed.size() == types.size()) { + return ctx.getString(R.string.notif_new_app_all); + } + return ctx.getString(R.string.notif_new_app_some, android.text.TextUtils.join(", ", allowed)); + } + + public static void cancelNewApp(Context ctx, int uid, String pkg) { + NotificationManager nm = manager(ctx); + if (nm == null) { + return; + } + nm.cancel(newAppTag(uid, pkg), ID_NEW_APP); + // a summary without any app left would stay behind + try { + for (android.service.notification.StatusBarNotification sbn : nm.getActiveNotifications()) { + if (sbn.getId() == ID_NEW_APP) { + return; + } + } + } catch (Exception ignored) { + } + nm.cancel(ID_NEW_APP_SUMMARY); + } + + private static String newAppTag(int uid, String pkg) { + return pkg + ":" + (uid / 100000); + } + + // ---- blocked connections ---- + + private static final class Blocked { + final int uid; + final String name; + int count; + String last; + + Blocked(int uid, String name) { + this.uid = uid; + this.name = name; + } + } + + // most recent last; guarded by the class lock + private static final LinkedHashMap blocked = new LinkedHashMap<>(); + private static long lastBlockedPost; + private static boolean blockedPostScheduled; + private static final Handler handler = new Handler(Looper.getMainLooper()); + + /** + * A connection was blocked. Collected into one notification that is updated at most every + * few seconds (posting per packet runs into Android's rate limit, which then drops the app's + * other notifications too). + */ + public static void blocked(Context ctx, LogInfo info) { + final Context app = ctx.getApplicationContext(); + synchronized (Notifications.class) { + Blocked b = blocked.remove(info.uid); + if (b == null) { + String name = info.appName != null && !info.appName.isEmpty() ? info.appName : info.uidString; + b = new Blocked(info.uid, name != null ? name : String.valueOf(info.uid)); + } + b.count++; + if (info.dst != null) { + b.last = info.dst + (info.dpt > 0 ? ":" + info.dpt : "") + + (info.proto != null ? " " + info.proto.toUpperCase(java.util.Locale.US) : ""); + } + blocked.put(info.uid, b); // move to the end: most recent + while (blocked.size() > BLOCKED_MAX_APPS) { + Iterator it = blocked.keySet().iterator(); + it.next(); + it.remove(); + } + if (blockedPostScheduled) { + return; + } + long wait = BLOCKED_UPDATE_INTERVAL_MS - (SystemClock.elapsedRealtime() - lastBlockedPost); + blockedPostScheduled = true; + handler.postDelayed(() -> postBlocked(app), Math.max(0, wait)); + } + } + + private static void postBlocked(Context ctx) { + List apps; + int total = 0; + synchronized (Notifications.class) { + blockedPostScheduled = false; + lastBlockedPost = SystemClock.elapsedRealtime(); + if (blocked.isEmpty()) { + return; + } + apps = new ArrayList<>(); + for (Blocked b : blocked.values()) { + Blocked copy = new Blocked(b.uid, b.name); + copy.count = b.count; + copy.last = b.last; + apps.add(copy); + total += b.count; + } + } + ensureChannels(ctx); + NotificationManager nm = manager(ctx); + if (nm == null) { + return; + } + Blocked latest = apps.get(apps.size() - 1); + String title = ctx.getResources().getQuantityString(R.plurals.notif_blocked_title, total, total); + String text = line(latest); + NotificationCompat.InboxStyle inbox = new NotificationCompat.InboxStyle(); + for (int i = apps.size() - 1; i >= 0 && i >= apps.size() - 6; i--) { + inbox.addLine(line(apps.get(i))); + } + if (apps.size() > 6) { + inbox.setSummaryText(ctx.getString(R.string.notif_blocked_more, apps.size() - 6)); + } + Notification n = new NotificationCompat.Builder(ctx, CHANNEL_LOG) + .setSmallIcon(R.drawable.ic_block_black_24dp) + .setContentTitle(title) + .setContentText(text) + .setStyle(inbox) + .setCategory(NotificationCompat.CATEGORY_EVENT) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setContentIntent(activity(ctx, ID_BLOCKED, logIntent(ctx))) + .setDeleteIntent(NotificationActionReceiver.clearBlockedIntent(ctx)) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .setNumber(total) + .addAction(0, ctx.getString(R.string.notif_mute, latest.name), + NotificationActionReceiver.muteIntent(ctx, latest.uid)) + .build(); + nm.notify(ID_BLOCKED, n); + } + + private static String line(Blocked b) { + return b.name + (b.count > 1 ? " (" + b.count + ")" : "") + (b.last != null ? " → " + b.last : ""); + } + + /** + * Forget the collected blocked connections (the notification was dismissed or opened). + */ + public static void clearBlocked() { + synchronized (Notifications.class) { + blocked.clear(); + } + } + + /** + * An app's blocked connections are no longer notified: drop it from the notification. + */ + public static void forgetBlocked(Context ctx, int uid) { + boolean empty; + synchronized (Notifications.class) { + blocked.remove(uid); + empty = blocked.isEmpty(); + } + if (empty) { + cancel(ctx, ID_BLOCKED); + } else { + postBlocked(ctx.getApplicationContext()); + } + } + + // ---- helpers ---- + + public static void cancel(Context ctx, int id) { + NotificationManager nm = manager(ctx); + if (nm != null) { + nm.cancel(id); + } + } + + private static NotificationManager manager(Context ctx) { + return (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE); + } + + private static PendingIntent openApp(Context ctx, int requestCode) { + Intent intent = new Intent(ctx, MainActivity.class) + .setAction(Intent.ACTION_MAIN) + .addCategory(Intent.CATEGORY_LAUNCHER) + .setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); + return activity(ctx, requestCode, intent); + } + + private static Intent logIntent(Context ctx) { + return new Intent(ctx, LogActivity.class) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + } + + private static PendingIntent activity(Context ctx, int requestCode, Intent intent) { + return PendingIntent.getActivity(ctx, requestCode, intent, + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); + } + +} diff --git a/app/src/main/java/dev/ukanth/ufirewall/util/PackageComparator.java b/app/src/main/java/dev/ukanth/ufirewall/util/PackageComparator.java index ff2202b04..f664fa726 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/util/PackageComparator.java +++ b/app/src/main/java/dev/ukanth/ufirewall/util/PackageComparator.java @@ -22,15 +22,37 @@ public int compare(Api.PackageInfoData o1, Api.PackageInfoData o2) { if (o1_selected == o2_selected) { switch (G.sortBy()) { case "s0": - return String.CASE_INSENSITIVE_ORDER.compare(o1.names.get(0), o2.names.get(0)); + return compareIdentity(o1, o2); case "s1": - return (o1.installTime > o2.installTime) ? -1: (o1.installTime < o2.installTime) ? 1 : 0; + int installCompare = (o1.installTime > o2.installTime) ? -1: (o1.installTime < o2.installTime) ? 1 : 0; + if (installCompare != 0) { + return installCompare; + } + // Dual/profile apps can share sort values; keep their order deterministic. + return compareIdentity(o1, o2); case "s2": - return (o2.uid > o1.uid) ? -1: (o2.uid < o1.uid) ? 0 : 1; + int uidCompare = Integer.compare(o2.uid, o1.uid); + if (uidCompare != 0) { + return uidCompare; + } + // Dual/profile apps can share UID values; keep their order deterministic. + return compareIdentity(o1, o2); } } if (o1_selected) return -1; return 1; } + + private int compareIdentity(Api.PackageInfoData o1, Api.PackageInfoData o2) { + String firstName = (o1.names != null && !o1.names.isEmpty()) ? o1.names.get(0) : ""; + String secondName = (o2.names != null && !o2.names.isEmpty()) ? o2.names.get(0) : ""; + int nameCompare = String.CASE_INSENSITIVE_ORDER.compare(firstName, secondName); + if (nameCompare != 0) { + return nameCompare; + } + String firstPackage = o1.pkgName != null ? o1.pkgName : ""; + String secondPackage = o2.pkgName != null ? o2.pkgName : ""; + return String.CASE_INSENSITIVE_ORDER.compare(firstPackage, secondPackage); + } } diff --git a/app/src/main/java/dev/ukanth/ufirewall/util/RootFiles.java b/app/src/main/java/dev/ukanth/ufirewall/util/RootFiles.java new file mode 100644 index 000000000..7348002c6 --- /dev/null +++ b/app/src/main/java/dev/ukanth/ufirewall/util/RootFiles.java @@ -0,0 +1,105 @@ +package dev.ukanth.ufirewall.util; + +import com.topjohnwu.superuser.Shell; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import dev.ukanth.ufirewall.log.Log; + +/** + * File operations as root, on libsu's main shell (replaces the RootTools helpers). Blocking: call + * them off the main thread. + */ +public final class RootFiles { + + private static final String TAG = "AFWall"; + + // mount points remounted read-write by remount(..., true); only these are made read-only again + private static final Set remountedRw = new HashSet<>(); + + private RootFiles() { + } + + public static boolean exists(String path) { + return path != null && Shell.cmd("[ -e " + quote(path) + " ]").exec().isSuccess(); + } + + /** + * Copy a file; {@code dest} may be a directory. + */ + public static boolean copy(String src, String dest) { + return Shell.cmd("cp -f " + quote(src) + " " + quote(dest)).exec().isSuccess(); + } + + /** + * Make the file system holding {@code path} writable (e.g. /system for an init.d script), or + * restore it afterwards. A mount point that is already writable (like /data, where current + * root solutions keep their boot scripts) is left alone, and only a mount point this class + * made writable is made read-only again. + * + * @param busybox busybox binary to fall back to when the system mount can't remount; may be empty + * @return true if {@code path} is (still) usable in the requested mode + */ + public static synchronized boolean remount(String path, boolean readWrite, String busybox) { + String[] mount = findMount(path); + if (mount == null) { + Log.w(TAG, "No mount point found for " + path); + return false; + } + String point = mount[0]; + boolean isRw = mount[1].equals("rw") || mount[1].startsWith("rw,"); + if (readWrite) { + if (isRw) { + return true; + } + if (remountCmd(point, "rw", busybox)) { + remountedRw.add(point); + return true; + } + return false; + } + if (!remountedRw.remove(point)) { + return true; // we didn't change it + } + return remountCmd(point, "ro", busybox); + } + + private static boolean remountCmd(String point, String mode, String busybox) { + if (Shell.cmd("mount -o remount," + mode + " " + quote(point)).exec().isSuccess()) { + return true; + } + return busybox != null && !busybox.trim().isEmpty() + && Shell.cmd(quote(busybox) + " mount -o remount," + mode + " " + quote(point)).exec().isSuccess(); + } + + /** + * @return {mount point, options} of the longest mount point containing {@code path}, or null + */ + private static String[] findMount(String path) { + List mounts = Shell.cmd("cat /proc/mounts").exec().getOut(); + String[] best = null; + for (String line : mounts) { + // device mountpoint type options dump pass + String[] f = line.split("\\s+"); + if (f.length < 4) { + continue; + } + String point = f[1]; + boolean contains = path.equals(point) || point.equals("/") + || path.startsWith(point.endsWith("/") ? point : point + "/"); + if (contains && (best == null || point.length() >= best[0].length())) { + best = new String[]{point, f[3]}; + } + } + return best; + } + + /** + * @return {@code s} as a single-quoted shell word + */ + static String quote(String s) { + return "'" + s.replace("'", "'\\''") + "'"; + } +} diff --git a/app/src/main/java/dev/ukanth/ufirewall/util/SecureCrypto.java b/app/src/main/java/dev/ukanth/ufirewall/util/SecureCrypto.java new file mode 100644 index 000000000..45ab209da --- /dev/null +++ b/app/src/main/java/dev/ukanth/ufirewall/util/SecureCrypto.java @@ -0,0 +1,248 @@ +package dev.ukanth.ufirewall.util; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Build; +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyProperties; +import android.util.Base64; +import android.util.Log; + +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.security.spec.KeySpec; + +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.DESKeySpec; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; + +/** + * Secure cryptographic utility with backward compatibility for DES-encrypted passwords. + * + * This class provides: + * - Modern AES-256-GCM encryption for new passwords + * - Backward compatibility for existing DES-encrypted passwords + * - Automatic migration from DES to AES when passwords are verified + * - Secure key derivation using PBKDF2 + */ +public class SecureCrypto { + private static final String TAG = "SecureCrypto"; + + // Modern encryption constants + private static final String AES_ALGORITHM = "AES/GCM/NoPadding"; + private static final String KEY_DERIVATION = "PBKDF2WithHmacSHA256"; + private static final int AES_KEY_LENGTH = 256; + private static final int GCM_IV_LENGTH = 12; + private static final int GCM_TAG_LENGTH = 16; + private static final int PBKDF2_ITERATIONS = 100000; + private static final int SALT_LENGTH = 32; + + // Legacy DES constants (for backward compatibility) + private static final String DES_ALGORITHM = "DES"; + private static final String CHARSET_NAME = "UTF-8"; + private static final int BASE64_MODE = Base64.DEFAULT; + + // Version identifiers for encrypted data + private static final String AES_PREFIX = "AES:"; + private static final String DES_PREFIX = "DES:"; + + private static final String PREF_ENCRYPTION_VERSION = "encryption_version"; + private static final String PREF_PASSWORD_SALT = "password_salt"; + private static final int ENCRYPTION_VERSION_AES = 2; + private static final int ENCRYPTION_VERSION_DES = 1; + + /** + * Encrypt data using modern AES-256-GCM encryption + */ + public static String encryptSecure(Context context, String masterKey, String data) { + if (masterKey == null || data == null) { + return null; + } + + try { + SharedPreferences prefs = context.getSharedPreferences("secure_crypto", Context.MODE_PRIVATE); + + // Generate or retrieve salt + String saltBase64 = prefs.getString(PREF_PASSWORD_SALT, null); + byte[] salt; + if (saltBase64 == null) { + salt = new byte[SALT_LENGTH]; + new SecureRandom().nextBytes(salt); + saltBase64 = Base64.encodeToString(salt, Base64.NO_WRAP); + prefs.edit().putString(PREF_PASSWORD_SALT, saltBase64).apply(); + } else { + salt = Base64.decode(saltBase64, Base64.NO_WRAP); + } + + // Derive key using PBKDF2 + SecretKey key = deriveKey(masterKey, salt); + + // Generate random IV + byte[] iv = new byte[GCM_IV_LENGTH]; + new SecureRandom().nextBytes(iv); + + // Encrypt data + Cipher cipher = Cipher.getInstance(AES_ALGORITHM); + GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv); + cipher.init(Cipher.ENCRYPT_MODE, key, gcmSpec); + + byte[] encrypted = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8)); + + // Combine IV and encrypted data + byte[] combined = new byte[iv.length + encrypted.length]; + System.arraycopy(iv, 0, combined, 0, iv.length); + System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length); + + // Mark encryption version and store + prefs.edit().putInt(PREF_ENCRYPTION_VERSION, ENCRYPTION_VERSION_AES).apply(); + + return AES_PREFIX + Base64.encodeToString(combined, Base64.NO_WRAP); + + } catch (Exception e) { + Log.e(TAG, "AES encryption failed", e); + return null; + } + } + + /** + * Decrypt data - handles both AES and legacy DES formats + */ + public static String decryptSecure(Context context, String masterKey, String encryptedData) { + if (masterKey == null || encryptedData == null) { + return null; + } + + try { + if (encryptedData.startsWith(AES_PREFIX)) { + // Modern AES decryption + return decryptAES(context, masterKey, encryptedData.substring(AES_PREFIX.length())); + } else if (encryptedData.startsWith(DES_PREFIX)) { + // Legacy DES decryption (marked format) + return decryptDES(masterKey, encryptedData.substring(DES_PREFIX.length())); + } else { + // Assume legacy DES format (no prefix) + return decryptDES(masterKey, encryptedData); + } + } catch (Exception e) { + Log.e(TAG, "Decryption failed", e); + return null; + } + } + + /** + * Decrypt using modern AES-256-GCM + */ + private static String decryptAES(Context context, String masterKey, String encryptedData) throws Exception { + SharedPreferences prefs = context.getSharedPreferences("secure_crypto", Context.MODE_PRIVATE); + + // Get salt + String saltBase64 = prefs.getString(PREF_PASSWORD_SALT, null); + if (saltBase64 == null) { + throw new IllegalStateException("Salt not found for AES decryption"); + } + byte[] salt = Base64.decode(saltBase64, Base64.NO_WRAP); + + // Derive key + SecretKey key = deriveKey(masterKey, salt); + + // Decode encrypted data + byte[] combined = Base64.decode(encryptedData, Base64.NO_WRAP); + + // Extract IV and encrypted data + byte[] iv = new byte[GCM_IV_LENGTH]; + byte[] encrypted = new byte[combined.length - GCM_IV_LENGTH]; + System.arraycopy(combined, 0, iv, 0, GCM_IV_LENGTH); + System.arraycopy(combined, GCM_IV_LENGTH, encrypted, 0, encrypted.length); + + // Decrypt + Cipher cipher = Cipher.getInstance(AES_ALGORITHM); + GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv); + cipher.init(Cipher.DECRYPT_MODE, key, gcmSpec); + + byte[] decrypted = cipher.doFinal(encrypted); + return new String(decrypted, StandardCharsets.UTF_8); + } + + /** + * Legacy DES decryption for backward compatibility + */ + private static String decryptDES(String masterKey, String encryptedData) throws Exception { + byte[] dataBytes = Base64.decode(encryptedData, BASE64_MODE); + DESKeySpec desKeySpec = new DESKeySpec(masterKey.getBytes(CHARSET_NAME)); + SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance(DES_ALGORITHM); + SecretKey secretKey = secretKeyFactory.generateSecret(desKeySpec); + Cipher cipher = Cipher.getInstance(DES_ALGORITHM); + cipher.init(Cipher.DECRYPT_MODE, secretKey); + byte[] dataBytesDecrypted = cipher.doFinal(dataBytes); + return new String(dataBytesDecrypted, CHARSET_NAME); + } + + /** + * Derive AES key using PBKDF2 + */ + private static SecretKey deriveKey(String password, byte[] salt) throws Exception { + KeySpec spec = new PBEKeySpec(password.toCharArray(), salt, PBKDF2_ITERATIONS, AES_KEY_LENGTH); + SecretKeyFactory factory = SecretKeyFactory.getInstance(KEY_DERIVATION); + byte[] keyBytes = factory.generateSecret(spec).getEncoded(); + return new SecretKeySpec(keyBytes, "AES"); + } + + /** + * Check if data is encrypted with legacy DES + */ + public static boolean isLegacyEncryption(String encryptedData) { + return encryptedData != null && + !encryptedData.startsWith(AES_PREFIX) && + !encryptedData.startsWith(DES_PREFIX); + } + + /** + * Migrate password from DES to AES encryption + * This should be called when a legacy password is successfully verified + */ + public static String migrateToAES(Context context, String masterKey, String plaintext) { + Log.i(TAG, "Migrating password from DES to AES encryption"); + return encryptSecure(context, masterKey, plaintext); + } + + /** + * Get current encryption version + */ + public static int getCurrentEncryptionVersion(Context context) { + SharedPreferences prefs = context.getSharedPreferences("secure_crypto", Context.MODE_PRIVATE); + return prefs.getInt(PREF_ENCRYPTION_VERSION, ENCRYPTION_VERSION_DES); + } + + /** + * Validate that the crypto system is working correctly + */ + public static boolean validateCrypto(Context context) { + try { + String testData = "AFWall+ Security Test"; + String testKey = "TestKey123"; + + String encrypted = encryptSecure(context, testKey, testData); + if (encrypted == null) return false; + + String decrypted = decryptSecure(context, testKey, encrypted); + return testData.equals(decrypted); + + } catch (Exception e) { + Log.e(TAG, "Crypto validation failed", e); + return false; + } + } + + /** + * Clear all crypto preferences (for testing or reset purposes) + */ + public static void clearCryptoPreferences(Context context) { + SharedPreferences prefs = context.getSharedPreferences("secure_crypto", Context.MODE_PRIVATE); + prefs.edit().clear().apply(); + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/ukanth/ufirewall/util/SecurePasswordManager.java b/app/src/main/java/dev/ukanth/ufirewall/util/SecurePasswordManager.java new file mode 100644 index 000000000..436776a12 --- /dev/null +++ b/app/src/main/java/dev/ukanth/ufirewall/util/SecurePasswordManager.java @@ -0,0 +1,160 @@ +package dev.ukanth.ufirewall.util; + +import android.content.Context; +import android.util.Log; + +/** + * Secure password manager that handles encryption, storage, and migration + * with backward compatibility for existing DES-encrypted passwords + */ +public class SecurePasswordManager { + private static final String TAG = "SecurePasswordManager"; + private static final String MASTER_KEY = "AFW@LL_P@SSWORD_PR0T3CTI0N"; + + /** + * Store a password securely using modern AES encryption + * + * @param context Application context + * @param password Plain text password to store + * @return true if password was stored successfully + */ + public static boolean storePasswordSecure(Context context, String password) { + if (password == null || password.isEmpty()) { + return false; + } + + try { + String encrypted = SecureCrypto.encryptSecure(context, MASTER_KEY, password); + if (encrypted != null) { + // Use G's helper method to store password + G.profile_pwd(encrypted); + G.gPrefs.edit() + .putBoolean("enc", true) + .putBoolean("secure_enc", true) // Flag for new encryption + .apply(); + Log.i(TAG, "Password stored with secure encryption"); + return true; + } + } catch (Exception e) { + Log.e(TAG, "Failed to store password securely", e); + } + return false; + } + + /** + * Verify a password against stored encrypted password with automatic migration + * + * @param context Application context + * @param enteredPassword Password entered by user + * @return true if password matches + */ + public static boolean verifyPassword(Context context, String enteredPassword) { + if (enteredPassword == null) { + return false; + } + + String storedPassword = G.profile_pwd(); + if (storedPassword == null || storedPassword.isEmpty()) { + return false; + } + + try { + if (G.isEnc()) { + // Check if using new secure encryption + if (G.gPrefs.getBoolean("secure_enc", false)) { + String decrypted = SecureCrypto.decryptSecure(context, MASTER_KEY, storedPassword); + return enteredPassword.equals(decrypted); + } else { + // Try new secure decryption first (for migrated passwords) + String decrypted = SecureCrypto.decryptSecure(context, MASTER_KEY, storedPassword); + if (decrypted != null && enteredPassword.equals(decrypted)) { + return true; + } + + // Fallback to legacy DES decryption + decrypted = dev.ukanth.ufirewall.Api.unhideCrypt(MASTER_KEY, storedPassword); + if (decrypted != null && enteredPassword.equals(decrypted)) { + // Auto-migrate to secure encryption + if (migrateToSecureEncryption(context, enteredPassword)) { + Log.i(TAG, "Successfully migrated password from DES to AES"); + } + return true; + } + } + } else { + // Plain text password - migrate to secure encryption + if (enteredPassword.equals(storedPassword)) { + if (migrateToSecureEncryption(context, enteredPassword)) { + Log.i(TAG, "Successfully migrated plaintext password to AES"); + } + return true; + } + } + } catch (Exception e) { + Log.e(TAG, "Error during password verification", e); + } + + return false; + } + + /** + * Migrate existing password to secure AES encryption + */ + private static boolean migrateToSecureEncryption(Context context, String plainPassword) { + try { + return storePasswordSecure(context, plainPassword); + } catch (Exception e) { + Log.e(TAG, "Failed to migrate password to secure encryption", e); + return false; + } + } + + /** + * Check if password is encrypted with legacy DES + */ + public static boolean isLegacyEncryption() { + return G.isEnc() && !G.gPrefs.getBoolean("secure_enc", false); + } + + /** + * Get encryption status information for debugging + */ + public static String getEncryptionStatus(Context context) { + StringBuilder status = new StringBuilder(); + status.append("Encrypted: ").append(G.isEnc()).append("\n"); + status.append("Secure encryption: ").append(G.gPrefs.getBoolean("secure_enc", false)).append("\n"); + status.append("Legacy encryption: ").append(isLegacyEncryption()).append("\n"); + + if (G.isEnc()) { + String stored = G.profile_pwd(); + status.append("Uses AES prefix: ").append(stored != null && stored.startsWith("AES:")).append("\n"); + status.append("Crypto validation: ").append(SecureCrypto.validateCrypto(context)).append("\n"); + } + + return status.toString(); + } + + /** + * Force password re-encryption (for testing or security updates) + */ + public static boolean reencryptPassword(Context context, String currentPassword) { + if (!verifyPassword(context, currentPassword)) { + Log.w(TAG, "Cannot re-encrypt: current password verification failed"); + return false; + } + + return storePasswordSecure(context, currentPassword); + } + + /** + * Clear all password data (for reset/logout) + */ + public static void clearPassword() { + G.profile_pwd(""); // Clear password using G's method + G.gPrefs.edit() + .remove("enc") + .remove("secure_enc") + .apply(); + Log.i(TAG, "Password data cleared"); + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/ukanth/ufirewall/util/SecurityUtil.java b/app/src/main/java/dev/ukanth/ufirewall/util/SecurityUtil.java index 92301ff0c..077b85240 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/util/SecurityUtil.java +++ b/app/src/main/java/dev/ukanth/ufirewall/util/SecurityUtil.java @@ -11,6 +11,7 @@ import android.content.Intent; import android.os.Build; import android.text.InputType; +import android.util.Log; import android.widget.Toast; import androidx.annotation.RequiresApi; @@ -48,15 +49,98 @@ private void deviceCheck() { if (createConfirmDeviceCredentialIntent != null) { try { activity.startActivityForResult(createConfirmDeviceCredentialIntent, LOCK_VERIFICATION); + return; } catch (ActivityNotFoundException e) { } } } else { Toast.makeText(activity, context.getText(R.string.android_version), Toast.LENGTH_SHORT).show(); } - } else { + } else if (callback == null) { Api.donateDialog(activity, true); } + // no device lock to check: the app's screens let the user in then, so do the same + report(true); + } + } + + /** + * Result of {@link #passCheck(Callback)}. + */ + public interface Callback { + void onResult(boolean allowed); + } + + // set in callback mode: the caller continues after the check, and cancelling only reports it + private Callback callback; + + /** + * Check the app lock, then report the result instead of just letting the user in (as + * {@link #passCheck()} does for the app's screens). For actions started outside the app + * (widgets, tile). Cancelling calls back with false; it doesn't close the app. + *

+ * The activity must pass its onActivityResult() calls to {@link #handleActivityResult}. + */ + public void passCheck(Callback callback) { + if (!isPasswordProtected()) { + callback.onResult(true); + return; + } + this.callback = callback; + if (!startPrompt()) { + // nothing to ask for the configured lock (e.g. fingerprint no longer available) + report(true); + } + } + + /** + * @return true if a prompt was started; it reports its result through the callback + */ + private boolean startPrompt() { + if (G.enableDeviceCheck()) { + deviceCheck(); + return true; + } + switch (G.protectionLevel()) { + case "p1": + if (G.profile_pwd().length() > 0) { + requestPassword(); + return true; + } + return false; + case "p2": + if (G.sPrefs.getString("LockPassword", "").length() > 0) { + requestPassword(); + return true; + } + return false; + case "p3": + if (FingerprintUtil.isAndroidSupport() && G.isFingerprintEnabled()) { + requestFingerprint(); + return true; + } + return false; + default: + return false; + } + } + + /** + * @return true if the result belonged to the check started by {@link #passCheck(Callback)} + */ + public boolean handleActivityResult(int requestCode, int resultCode) { + if (callback == null || (requestCode != LOCK_VERIFICATION && requestCode != REQ_ENTER_PATTERN)) { + return false; + } + report(resultCode == Activity.RESULT_OK); + return true; + } + + private void report(boolean allowed) { + Callback cb = callback; + callback = null; + if (cb != null) { + cb.onResult(allowed); } } @@ -115,9 +199,15 @@ public boolean isPasswordProtected() { private void requestFingerprint() { FingerprintUtil.FingerprintDialog dialog = new FingerprintUtil.FingerprintDialog(activity); dialog.setOnFingerprintFailureListener(() -> { - activity.finish(); - android.os.Process.killProcess(android.os.Process.myPid()); + if (callback != null) { + report(false); + } else { + gracefulShutdown(); + } }); + if (callback != null) { + dialog.setOnFingerprintSuccess(() -> report(true)); + } dialog.show(); } @@ -125,8 +215,7 @@ private void requestFingerprint() { private void requestFingerprintQ() { BiometricUtil.FingerprintDialog dialog = new BiometricUtil.FingerprintDialog(activity); dialog.setOnFingerprintFailureListener(() -> { - activity.finish(); - android.os.Process.killProcess(android.os.Process.myPid()); + gracefulShutdown(); }); dialog.show(); } @@ -141,26 +230,26 @@ private void requestPassword() { .positiveText(R.string.submit) .negativeText(R.string.Cancel) .onNegative((dialog, which) -> { - activity.finish(); - android.os.Process.killProcess(android.os.Process.myPid()); + if (callback != null) { + dialog.dismiss(); + report(false); + } else { + gracefulShutdown(); + } }) .input(R.string.enterpass, R.string.password_empty, (dialog, input) -> { - String pass = input.toString(); - boolean isAllowed = false; - if (G.isEnc()) { - String decrypt = Api.unhideCrypt("AFW@LL_P@SSWORD_PR0T3CTI0N", G.profile_pwd()); - if (decrypt != null) { - if (decrypt.equals(pass)) { - isAllowed = true; - } - } - } else { - if (pass.equals(G.profile_pwd())) { - isAllowed = true; - } + String pass = InputValidator.sanitizeString(input.toString(), 256); + if (pass == null) { + Api.toast(activity, context.getString(R.string.wrong_password)); + return; } + + // Use secure password manager for verification and auto-migration + boolean isAllowed = SecurePasswordManager.verifyPassword(context, pass); + if (isAllowed) { dialog.dismiss(); + report(true); } else { Api.toast(activity, context.getString(R.string.wrong_password)); } @@ -177,4 +266,33 @@ private void requestPassword() { } } + + /** + * Perform graceful shutdown instead of abrupt process termination + * This ensures proper cleanup and prevents firewall rules from being left in inconsistent state + */ + private void gracefulShutdown() { + try { + // Give some time for any pending operations to complete + new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> { + try { + // Attempt to save any pending state or cleanup + activity.moveTaskToBack(true); + activity.finishAndRemoveTask(); + } catch (Exception e) { + // If graceful methods fail, fall back to finish() + activity.finish(); + } finally { + // Only use process kill as absolute last resort after cleanup attempt + new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> { + android.os.Process.killProcess(android.os.Process.myPid()); + }, 500); // 500ms delay to allow cleanup + } + }); + } catch (Exception e) { + Log.e("SecurityUtil", "Error during graceful shutdown", e); + // Emergency fallback + activity.finish(); + } + } } diff --git a/app/src/main/java/dev/ukanth/ufirewall/util/SystemUids.java b/app/src/main/java/dev/ukanth/ufirewall/util/SystemUids.java new file mode 100644 index 000000000..4a76ccda6 --- /dev/null +++ b/app/src/main/java/dev/ukanth/ufirewall/util/SystemUids.java @@ -0,0 +1,186 @@ +package dev.ukanth.ufirewall.util; + +import android.content.Context; +import android.os.SystemClock; + +import com.topjohnwu.superuser.Shell; + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +import dev.ukanth.ufirewall.log.Log; + +/** + * System UIDs (below 10000) that use the network but have no package and no fixed special entry + * in the app list: native daemons such as dns (1051) or wifi (1010), and manufacturer UIDs + * (2900-2999, 5000-5999) that differ from device to device. Without an entry they could not be + * allowed (allow-list mode) or blocked (block-list mode). + *

+ * They are found when they own a socket (read as root from /proc/net) or show up in the block + * log, and are remembered, so their entry stays when the daemon isn't running. + */ +public final class SystemUids { + + private static final String TAG = "AFWall"; + private static final String PREF = "discoveredSystemUids"; // "1051:dns|5012:vendor_x" + private static final long DISCOVERY_INTERVAL_MS = 60_000; + + private static Map known; + private static long lastDiscovery; + // UIDs already checked from the log in this process, to keep the per-line cost low + private static final Set seen = Collections.synchronizedSet(new HashSet<>()); + + private SystemUids() { + } + + /** + * @return remembered system UIDs and their Android names, sorted by UID + */ + public static synchronized Map known() { + if (known == null) { + known = new TreeMap<>(); + String saved = G.gPrefs != null ? G.gPrefs.getString(PREF, "") : ""; + for (String entry : saved.split("\\|")) { + int sep = entry.indexOf(':'); + if (sep <= 0) { + continue; + } + try { + known.put(Integer.parseInt(entry.substring(0, sep)), entry.substring(sep + 1)); + } catch (NumberFormatException ignored) { + } + } + } + return new TreeMap<>(known); + } + + /** + * A system UID showed up in the block log. + */ + public static void seenInLog(Context ctx, int uid, String name) { + if (!isCandidate(uid) || !seen.add(uid)) { + return; + } + if (!isCovered(ctx, uid)) { + // prefer the Android user name of the running daemon ("statsd") over a generic one + String processName = null; + try { + processName = processUserNames().get(uid); + } catch (Exception ignored) { + } + remember(uid, processName != null ? processName : name); + } + } + + /** + * Look for system UIDs that own sockets (root). Rate limited; call it off the main thread. + */ + public static void discover(Context ctx) { + synchronized (SystemUids.class) { + long now = SystemClock.elapsedRealtime(); + if (lastDiscovery != 0 && now - lastDiscovery < DISCOVERY_INTERVAL_MS) { + return; + } + lastDiscovery = now; + } + try { + if (!Boolean.TRUE.equals(Shell.isAppGrantedRoot())) { + return; + } + Set uids = new HashSet<>(); + List lines = Shell.cmd("cat /proc/net/tcp /proc/net/tcp6 /proc/net/udp /proc/net/udp6 2>/dev/null") + .exec().getOut(); + for (String line : lines) { + String[] f = line.trim().split("\\s+"); + // sl local_address rem_address st tx:rx tr:tm retrnsmt uid ... + if (f.length > 7) { + try { + int uid = Integer.parseInt(f[7]); + if (isCandidate(uid)) { + uids.add(uid); + } + } catch (NumberFormatException ignored) { + } + } + } + if (uids.isEmpty()) { + return; + } + Map names = processUserNames(); + for (int uid : uids) { + if (!isCovered(ctx, uid)) { + remember(uid, names.containsKey(uid) ? names.get(uid) : UidResolver.resolveUid(ctx, uid)); + } + } + } catch (Exception e) { + Log.w(TAG, "System UID discovery failed: " + e.getMessage()); + } + } + + /** + * @return uid -> Android user name ("audioserver", "vendor_rfs", ...) of running processes; + * manufacturer UIDs get the names their system defines + */ + private static Map processUserNames() { + Map names = new HashMap<>(); + for (String line : Shell.cmd("ps -A -o UID,USER").exec().getOut()) { + String[] f = line.trim().split("\\s+"); + if (f.length >= 2) { + try { + names.put(Integer.parseInt(f[0]), f[1]); + } catch (NumberFormatException ignored) { + } + } + } + return names; + } + + /** + * System UIDs of the main user; root (0) and app UIDs have their own entries. + */ + static boolean isCandidate(int uid) { + return uid > 0 && uid < android.os.Process.FIRST_APPLICATION_UID; + } + + /** + * @return true if the app list already has an entry for {@code uid}: a package or a fixed + * special entry + */ + private static boolean isCovered(Context ctx, int uid) { + String[] pkgs = ctx.getPackageManager().getPackagesForUid(uid); + if (pkgs != null && pkgs.length > 0) { + return true; + } + for (dev.ukanth.ufirewall.Api.PackageInfoData data : dev.ukanth.ufirewall.Api.getFixedSpecialData()) { + if (data.uid == uid) { + return true; + } + } + return false; + } + + private static synchronized void remember(int uid, String name) { + Map current = known(); + if (current.containsKey(uid)) { + return; + } + String clean = name == null || name.trim().isEmpty() ? String.valueOf(uid) + : name.trim().replace("|", "").replace(":", ""); + known.put(uid, clean); + StringBuilder sb = new StringBuilder(); + for (Map.Entry e : known.entrySet()) { + if (sb.length() > 0) { + sb.append('|'); + } + sb.append(e.getKey()).append(':').append(e.getValue()); + } + G.gPrefs.edit().putString(PREF, sb.toString()).apply(); + dev.ukanth.ufirewall.Api.applications = null; // the app list gets the new entry + Log.i(TAG, "New system UID with network use: " + uid + " (" + clean + ")"); + } +} diff --git a/app/src/main/java/dev/ukanth/ufirewall/util/ThemeHelper.java b/app/src/main/java/dev/ukanth/ufirewall/util/ThemeHelper.java new file mode 100644 index 000000000..13a6ff822 --- /dev/null +++ b/app/src/main/java/dev/ukanth/ufirewall/util/ThemeHelper.java @@ -0,0 +1,126 @@ +package dev.ukanth.ufirewall.util; + +import android.app.Activity; +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; +import android.widget.CompoundButton; +import android.widget.ListView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.appcompat.widget.Toolbar; +import androidx.cardview.widget.CardView; +import androidx.core.content.ContextCompat; +import androidx.core.graphics.drawable.DrawableCompat; +import androidx.core.widget.CompoundButtonCompat; + +import dev.ukanth.ufirewall.Api; +import dev.ukanth.ufirewall.R; +import dev.ukanth.ufirewall.log.Log; + +public final class ThemeHelper { + + private ThemeHelper() {} + + public static void applyTheme(Activity activity) { + apply(activity); + } + + public static void apply(Activity activity) { + if (activity == null) return; + activity.setTheme(G.getSelectedThemeStyle(activity)); + try { + applyCustomColors(activity); + } catch (Exception e) { + Log.e(G.TAG, "Unable to apply custom theme colors", e); + Api.toast(activity, activity.getText(R.string.theme_apply_error), Toast.LENGTH_LONG); + } + } + + public static Drawable defaultAndroidIcon(Context context) { + if (context == null) return null; + Drawable drawable = ContextCompat.getDrawable(context, R.drawable.ic_unknown); + if (drawable == null) return null; + drawable = DrawableCompat.wrap(drawable.mutate()); + DrawableCompat.setTint(drawable, G.defaultIconColor(context)); + return drawable; + } + + private static void applyCustomColors(Activity activity) { + Window window = activity.getWindow(); + if (window == null) return; + + applySystemBars(activity, window); + + if (!G.isCustomThemeActive(activity)) return; + + View content = window.getDecorView().findViewById(android.R.id.content); + if (content instanceof ViewGroup) { + ViewGroup root = (ViewGroup) content; + if (root.getChildCount() > 0) { + root.getChildAt(0).setBackgroundColor(G.backgroundColor(activity)); + } else { + root.setBackgroundColor(G.backgroundColor(activity)); + } + applyToChildren(root, activity); + } + Log.i(G.TAG, "Applied custom theme colors"); + } + + private static void applySystemBars(Context context, Window window) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); + window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); + int systemBarColor = G.primaryDarkColor(context); + window.setStatusBarColor(systemBarColor); + window.setNavigationBarColor(systemBarColor); + } + } + + private static void applyToChildren(ViewGroup parent, Context context) { + for (int i = 0; i < parent.getChildCount(); i++) { + View child = parent.getChildAt(i); + applyToView(child, context); + if (child instanceof ViewGroup) { + applyToChildren((ViewGroup) child, context); + } + } + } + + private static void applyToView(View view, Context context) { + if (view instanceof Toolbar) { + Toolbar toolbar = (Toolbar) view; + toolbar.setBackgroundColor(G.primaryColor(context)); + toolbar.setTitleTextColor(G.textPrimaryColor(context)); + toolbar.setSubtitleTextColor(G.textSecondaryColor(context)); + } else if (view instanceof CardView) { + ((CardView) view).setCardBackgroundColor(G.backgroundColor(context)); + } else if (view instanceof ListView) { + view.setBackgroundColor(G.backgroundColor(context)); + } else if (view instanceof TextView) { + ((TextView) view).setTextColor(G.textPrimaryColor(context)); + } + + if (view instanceof CompoundButton) { + CompoundButtonCompat.setButtonTintList((CompoundButton) view, controlTint(context)); + } + } + + static ColorStateList controlTint(Context context) { + return new ColorStateList( + new int[][]{ + new int[]{android.R.attr.state_checked}, + new int[]{} + }, + new int[]{ + G.accentColor(context), + G.textSecondaryColor(context) + }); + } +} diff --git a/app/src/main/java/dev/ukanth/ufirewall/util/UidCorrelator.java b/app/src/main/java/dev/ukanth/ufirewall/util/UidCorrelator.java new file mode 100644 index 000000000..7222fe421 --- /dev/null +++ b/app/src/main/java/dev/ukanth/ufirewall/util/UidCorrelator.java @@ -0,0 +1,245 @@ +/** + * Enhanced UID correlation system for AFWall+ + * Attempts to resolve Unknown UID (-100) entries by correlating + * netfilter logs with active network connections + * + * Copyright (C) 2024 AFWall+ Contributors + */ +package dev.ukanth.ufirewall.util; + +import android.util.Log; +import com.topjohnwu.superuser.Shell; + +import java.io.BufferedReader; +import java.io.StringReader; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class UidCorrelator { + private static final String TAG = "UidCorrelator"; + + // Cache active connections for correlation + private static final Map activeConnections = new ConcurrentHashMap<>(); + private static long lastRefresh = 0; + private static final long REFRESH_INTERVAL = 5000; // 5 seconds + private static final long CORRELATION_WINDOW = 10000; // 10 seconds + + public static class ConnectionInfo { + public final int uid; + public final String localAddress; + public final String remoteAddress; + public final int localPort; + public final int remotePort; + public final String protocol; + public final long timestamp; + + public ConnectionInfo(int uid, String localAddr, String remoteAddr, + int localPort, int remotePort, String protocol) { + this.uid = uid; + this.localAddress = localAddr; + this.remoteAddress = remoteAddr; + this.localPort = localPort; + this.remotePort = remotePort; + this.protocol = protocol; + this.timestamp = System.currentTimeMillis(); + } + + public String getConnectionKey() { + return protocol + ":" + remoteAddress + ":" + remotePort; + } + } + + /** + * Attempt to correlate unknown UID with active/recent connections + * + * @param srcIp Source IP from netfilter log + * @param dstIp Destination IP from netfilter log + * @param dstPort Destination port from netfilter log + * @param srcPort Source port from netfilter log + * @param protocol Protocol (TCP/UDP) + * @param logTimestamp Timestamp of the log entry + * @return UID if found, -100 if still unknown + */ + public static int correlateUid(String srcIp, String dstIp, int dstPort, + int srcPort, String protocol, long logTimestamp) { + if (protocol == null || srcPort <= 0) { + return -100; + } + refreshConnectionCache(); + + // Only an exact match: the log's source port is the socket's local port, which identifies + // it. Matching on the remote address alone attributed blocks to whichever app talked to + // the same server. No match (typically a closing packet of a socket that is already + // gone) stays unknown: shown as the kernel entry, not as a guessed app. + String proto = protocol.toUpperCase(java.util.Locale.US); + ConnectionInfo conn = activeConnections.get(tupleKey(proto, srcPort, dstIp, dstPort)); + if (conn == null && "UDP".equals(proto)) { + // unconnected UDP socket: only its local port is known + conn = activeConnections.get(localKey(proto, srcPort)); + } + if (conn != null && isWithinTimeWindow(conn.timestamp, logTimestamp)) { + Log.d(TAG, "Found exact match for " + proto + ":" + srcPort + "->" + dstIp + ":" + dstPort + " -> UID " + conn.uid); + return conn.uid; + } + return -100; // Still unknown + } + + private static String tupleKey(String proto, int localPort, String remoteIp, int remotePort) { + return proto + ":" + localPort + ":" + remoteIp + ":" + remotePort; + } + + private static String localKey(String proto, int localPort) { + return proto + ":" + localPort; + } + + /** + * Refresh the connection cache by parsing /proc/net files + */ + private static void refreshConnectionCache() { + long now = System.currentTimeMillis(); + if (now - lastRefresh < REFRESH_INTERVAL) { + return; // Cache still fresh + } + + try { + // Clear old data + activeConnections.clear(); + + // Parse TCP connections + parseNetworkConnections("/proc/net/tcp", "TCP"); + parseNetworkConnections("/proc/net/tcp6", "TCP"); + + // Parse UDP connections + parseNetworkConnections("/proc/net/udp", "UDP"); + parseNetworkConnections("/proc/net/udp6", "UDP"); + + lastRefresh = now; + if (activeConnections.isEmpty()) { + Log.w(TAG, "Connection cache refresh completed but no connections found - check root access"); + } else { + Log.d(TAG, "Refreshed connection cache: " + activeConnections.size() + " active connections"); + } + + } catch (Exception e) { + Log.e(TAG, "Error refreshing connection cache", e); + } + } + + /** + * Parse network connection files from /proc/net + * Uses root shell to ensure access on modern Android versions + */ + private static void parseNetworkConnections(String filePath, String protocol) { + try { + // Use getSU() to ensure root shell is used for /proc/net access + Shell.Result result = Shell.cmd("cat " + filePath).exec(); + if (!result.isSuccess()) { + Log.w(TAG, "Failed to read " + filePath + " - exit code: " + result.getCode()); + return; + } + + String output = String.join("\n", result.getOut()); + BufferedReader reader = new BufferedReader(new StringReader(output)); + String line; + boolean firstLine = true; + + while ((line = reader.readLine()) != null) { + if (firstLine) { + firstLine = false; + continue; // Skip header + } + + ConnectionInfo conn = parseConnectionLine(line, protocol); + if (conn != null && conn.uid > 0) { + activeConnections.put(tupleKey(protocol, conn.localPort, conn.remoteAddress, conn.remotePort), conn); + if ("UDP".equals(protocol) && conn.remotePort == 0) { + activeConnections.put(localKey(protocol, conn.localPort), conn); + } + } + } + + } catch (Exception e) { + Log.w(TAG, "Failed to parse " + filePath, e); + } + } + + /** + * Parse a single line from /proc/net/tcp or /proc/net/udp + * Format: sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode + */ + private static ConnectionInfo parseConnectionLine(String line, String protocol) { + try { + String[] parts = line.trim().split("\\s+"); + if (parts.length < 8) { + return null; + } + + // Parse local address (IP:PORT in hex) + String[] localAddr = parts[1].split(":"); + String localIp = hexToIp(localAddr[0]); + int localPort = Integer.parseInt(localAddr[1], 16); + + // Parse remote address + String[] remoteAddr = parts[2].split(":"); + String remoteIp = hexToIp(remoteAddr[0]); + int remotePort = Integer.parseInt(remoteAddr[1], 16); + + // Get UID (column 7) - handle potential parsing errors + int uid; + try { + uid = Integer.parseInt(parts[7]); + } catch (NumberFormatException e) { + Log.w(TAG, "Failed to parse UID from: " + parts[7]); + return null; + } + + // Only interested in established connections or UDP sockets + // TCP state 01 = ESTABLISHED, for UDP we take all + if (protocol.equals("TCP")) { + String state = parts[3]; + if (!"01".equals(state)) { + return null; // Not established + } + } + + return new ConnectionInfo(uid, localIp, remoteIp, localPort, remotePort, protocol); + + } catch (Exception e) { + Log.w(TAG, "Failed to parse connection line: " + line, e); + return null; + } + } + + /** + * Convert hex IP address to dotted decimal + * /proc/net format uses little-endian hex representation + */ + private static String hexToIp(String hexIp) { + if (hexIp.length() == 8) { + // IPv4 - /proc/net uses little-endian format + long ip = Long.parseLong(hexIp, 16); + // Convert from little-endian: reverse byte order + return (ip & 0xFF) + "." + ((ip >> 8) & 0xFF) + "." + + ((ip >> 16) & 0xFF) + "." + ((ip >> 24) & 0xFF); + } else if (hexIp.length() == 32) { + // IPv6 - check if it's an IPv4-mapped IPv6 address + // Format: 0000000000000000FFFF0000XXXXXXXX where XXXXXXXX is the IPv4 in hex + if (hexIp.startsWith("0000000000000000FFFF0000")) { + // Extract the IPv4 part (last 8 characters) + String ipv4Hex = hexIp.substring(24); + long ip = Long.parseLong(ipv4Hex, 16); + // Convert from big-endian for IPv6 mapped addresses + return ((ip >> 24) & 0xFF) + "." + ((ip >> 16) & 0xFF) + "." + + ((ip >> 8) & 0xFF) + "." + (ip & 0xFF); + } + } + // IPv6 or unknown format - return as is for now + return hexIp; + } + + private static boolean isWithinTimeWindow(long connTime, long logTime) { + return Math.abs(connTime - logTime) <= CORRELATION_WINDOW; + } + +} \ No newline at end of file diff --git a/app/src/main/java/dev/ukanth/ufirewall/util/UidListParser.java b/app/src/main/java/dev/ukanth/ufirewall/util/UidListParser.java new file mode 100644 index 000000000..ea484ed9e --- /dev/null +++ b/app/src/main/java/dev/ukanth/ufirewall/util/UidListParser.java @@ -0,0 +1,67 @@ +package dev.ukanth.ufirewall.util; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.StringTokenizer; + +/** + * Parses the "|"-separated UID lists stored in the rule preferences. + *

+ * Kept free of Android dependencies so it can be unit tested. + */ +public final class UidListParser { + + private UidListParser() { + } + + /** + * @param saved "|"-separated UIDs, e.g. "10123|-10|1000"; may be null + * @return the valid UIDs, sorted ascending. Malformed entries (for example left behind by an + * old import) are skipped so a single bad token can't make every rule apply fail. + */ + public static List parse(String saved) { + List uids = new ArrayList<>(); + if (saved == null) { + return uids; + } + StringTokenizer tok = new StringTokenizer(saved, "|"); + while (tok.hasMoreTokens()) { + String uid = tok.nextToken().trim(); + if (uid.isEmpty()) { + continue; + } + try { + uids.add(Integer.parseInt(uid)); + } catch (NumberFormatException e) { + // skip malformed entry + } + } + Collections.sort(uids); + return uids; + } + + /** + * Update a saved list with the state of the apps shown in the app list, keeping every UID + * that isn't shown (apps of other profiles when dual apps is off, apps without INTERNET when + * "show all apps" is off, ...): saving must not delete rules the user can't see. + * + * @param saved "|"-separated UIDs as stored + * @param shown UIDs of the apps in the app list + * @param selected those of {@code shown} that are checked + * @return the updated "|"-separated list, sorted + */ + public static String merge(String saved, java.util.Collection shown, java.util.Collection selected) { + java.util.TreeSet result = new java.util.TreeSet<>(parse(saved)); + result.removeAll(shown); + result.addAll(selected); + StringBuilder sb = new StringBuilder(); + for (Integer uid : result) { + if (sb.length() > 0) { + sb.append('|'); + } + sb.append(uid); + } + return sb.toString(); + } +} diff --git a/app/src/main/java/dev/ukanth/ufirewall/util/UidResolver.java b/app/src/main/java/dev/ukanth/ufirewall/util/UidResolver.java new file mode 100644 index 000000000..d686d8994 --- /dev/null +++ b/app/src/main/java/dev/ukanth/ufirewall/util/UidResolver.java @@ -0,0 +1,681 @@ +/** + * UID Resolution Helper - Provides fallback mechanisms for identifying UIDs + * + * Copyright (C) 2024 AFWall+ Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package dev.ukanth.ufirewall.util; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Build; +import android.util.Log; +import android.util.LruCache; +import android.util.SparseArray; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import dev.ukanth.ufirewall.R; + +public class UidResolver { + + private static final String TAG = "AFWall"; + private static final int PER_USER_RANGE = 100000; + + // System UID database - well-known Android system UIDs + private static final SparseArray SYSTEM_UIDS = new SparseArray<>(); + + static { + // Core system UIDs + SYSTEM_UIDS.put(0, "root"); + SYSTEM_UIDS.put(1000, "system"); + SYSTEM_UIDS.put(1001, "radio"); + SYSTEM_UIDS.put(1002, "bluetooth"); + SYSTEM_UIDS.put(1003, "graphics"); + SYSTEM_UIDS.put(1004, "input"); + SYSTEM_UIDS.put(1005, "audio"); + SYSTEM_UIDS.put(1006, "camera"); + SYSTEM_UIDS.put(1007, "log"); + SYSTEM_UIDS.put(1008, "compass"); + SYSTEM_UIDS.put(1009, "mount"); + SYSTEM_UIDS.put(1010, "wifi"); + SYSTEM_UIDS.put(1011, "adb"); + SYSTEM_UIDS.put(1012, "install"); + SYSTEM_UIDS.put(1013, "media"); + SYSTEM_UIDS.put(1014, "dhcp"); + SYSTEM_UIDS.put(1015, "sdcard_rw"); + SYSTEM_UIDS.put(1016, "vpn"); + SYSTEM_UIDS.put(1017, "keystore"); + SYSTEM_UIDS.put(1018, "usb"); + SYSTEM_UIDS.put(1019, "drm"); + SYSTEM_UIDS.put(1020, "mdnsr"); + SYSTEM_UIDS.put(1021, "gps"); + SYSTEM_UIDS.put(1023, "media_rw"); + SYSTEM_UIDS.put(1024, "mtp"); + SYSTEM_UIDS.put(1025, "unused1"); + SYSTEM_UIDS.put(1026, "unused2"); + SYSTEM_UIDS.put(1027, "unused3"); + SYSTEM_UIDS.put(1028, "unused4"); + SYSTEM_UIDS.put(1029, "clat"); + SYSTEM_UIDS.put(1030, "hsm"); + SYSTEM_UIDS.put(1031, "reserved"); + + // Shell and nobody + SYSTEM_UIDS.put(2000, "shell"); + SYSTEM_UIDS.put(9999, "nobody"); + + // Android framework UIDs (1000-1999 range commonly used) + for (int uid = 1032; uid <= 1099; uid++) { + SYSTEM_UIDS.put(uid, "system_" + uid); + } + } + + // Cache for resolved UIDs with timestamp + private static final ConcurrentHashMap UID_CACHE = new ConcurrentHashMap<>(); + private static final long CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes TTL + private static final int MAX_CACHE_SIZE = 500; // Maximum cache entries + + // LRU cache for frequently accessed UIDs + private static final LruCache FREQUENT_UID_CACHE = new LruCache(100) { + @Override + protected void entryRemoved(boolean evicted, Integer key, String oldValue, String newValue) { + if (evicted) { + Log.d(TAG, "LRU cache evicted UID " + key + " -> " + oldValue); + } + } + }; + + // Cache entry with TTL + private static class CacheEntry { + final String name; + final long timestamp; + final ResolutionMethod method; + + CacheEntry(String name, ResolutionMethod method) { + this.name = name; + this.timestamp = System.currentTimeMillis(); + this.method = method; + } + + boolean isExpired() { + return System.currentTimeMillis() - timestamp > CACHE_TTL_MS; + } + } + + // Track which resolution method was used + private enum ResolutionMethod { + SYSTEM_DB, PACKAGE_MANAGER, PROC_FS, PACKAGES_LIST, UNKNOWN + } + + // Process information from /proc/[pid]/status + private static class ProcessInfo { + String name; + int uid; + int pid; + String state; + + ProcessInfo(String name, int uid, int pid, String state) { + this.name = name; + this.uid = uid; + this.pid = pid; + this.state = state; + } + } + + /** + * Resolve UID to app name using fallback chain with caching + * + * @param ctx Context for accessing resources + * @param uid UID to resolve + * @return Resolved name or "Unknown" if all methods fail + */ + public static String resolveUid(Context ctx, int uid) { + // Check LRU cache first (most frequently accessed) + String frequent = FREQUENT_UID_CACHE.get(uid); + if (frequent != null) { + Log.d(TAG, "UID " + uid + " resolved from LRU cache: " + frequent); + return frequent; + } + + // Check main cache + CacheEntry cached = UID_CACHE.get(uid); + if (cached != null && !cached.isExpired()) { + // Move to frequent cache if accessed multiple times + FREQUENT_UID_CACHE.put(uid, cached.name); + Log.d(TAG, "UID " + uid + " resolved from cache (" + cached.method + "): " + cached.name); + return cached.name; + } + + // Clean expired entries periodically + if (UID_CACHE.size() > MAX_CACHE_SIZE) { + cleanExpiredEntries(); + } + + String result; + ResolutionMethod method = ResolutionMethod.UNKNOWN; + + // Method 1: Check system UID database + result = resolveSystemUid(uid); + if (result != null) { + method = ResolutionMethod.SYSTEM_DB; + cacheResult(uid, result, method); + Log.d(TAG, "UID " + uid + " resolved via system database: " + result); + return result; + } + + // Method 2: Use PackageManager + result = resolveViaPackageManager(ctx, uid); + if (result != null) { + method = ResolutionMethod.PACKAGE_MANAGER; + cacheResult(uid, result, method); + Log.d(TAG, "UID " + uid + " resolved via PackageManager: " + result); + return result; + } + + // Method 3: Check running processes + result = resolveViaProc(uid); + if (result != null) { + method = ResolutionMethod.PROC_FS; + cacheResult(uid, result, method); + Log.d(TAG, "UID " + uid + " resolved via /proc: " + result); + return result; + } + + // Method 4: Parse packages.list (requires root) + result = resolveViaPackagesList(uid); + if (result != null) { + method = ResolutionMethod.PACKAGES_LIST; + cacheResult(uid, result, method); + Log.d(TAG, "UID " + uid + " resolved via packages.list: " + result); + return result; + } + + String unknown = ctx.getString(R.string.unknown_item); + + // Method 5: app of another user (work profile, Private Space, clone): the same app id + // as in the main user, which the methods above can't see + if (uid >= PER_USER_RANGE) { + String base = resolveUid(ctx, uid % PER_USER_RANGE); + if (!unknown.equals(base)) { + result = base + " (user " + (uid / PER_USER_RANGE) + ")"; + cacheResult(uid, result, ResolutionMethod.PACKAGE_MANAGER); + Log.d(TAG, "UID " + uid + " resolved via app id: " + result); + return result; + } + } + + // Cache unknown result with shorter TTL + UID_CACHE.put(uid, new CacheEntry(unknown, ResolutionMethod.UNKNOWN)); + Log.w(TAG, "UID " + uid + " could not be resolved by any method"); + return unknown; + } + + /** + * Resolve UID using system UID database + */ + private static String resolveSystemUid(int uid) { + return SYSTEM_UIDS.get(uid); + } + + /** + * Resolve UID using PackageManager with multi-user support + */ + private static String resolveViaPackageManager(Context ctx, int uid) { + try { + PackageManager pm = ctx.getPackageManager(); + + // Try direct lookup first + String[] packages = pm.getPackagesForUid(uid); + if (packages != null && packages.length > 0) { + String packageName = packages[0]; + + // For multi-user UIDs, add user context + if (isMultiUserUid(uid)) { + int userId = getUserId(uid); + int appId = getAppId(uid); + return packageName + " (User " + userId + ")"; + } + return packageName; + } + + // Try getNameForUid as fallback + String name = pm.getNameForUid(uid); + if (name != null && !name.isEmpty()) { + if (isMultiUserUid(uid)) { + int userId = getUserId(uid); + return name + " (User " + userId + ")"; + } + return name; + } + + // For multi-user UIDs, try looking up the base app UID + if (isMultiUserUid(uid)) { + int appId = getAppId(uid); + int userId = getUserId(uid); + + Log.d(TAG, "Trying base UID lookup for multi-user UID " + uid + " (user:" + userId + ", app:" + appId + ")"); + + // Try to resolve the base app UID + String[] basePackages = pm.getPackagesForUid(appId); + if (basePackages != null && basePackages.length > 0) { + return basePackages[0] + " (User " + userId + ")"; + } + + String baseName = pm.getNameForUid(appId); + if (baseName != null && !baseName.isEmpty()) { + return baseName + " (User " + userId + ")"; + } + } + + } catch (Exception e) { + Log.w(TAG, "PackageManager resolution failed for UID " + uid, e); + } + return null; + } + + /** + * Resolve UID by checking running processes in /proc (enhanced version) + */ + private static String resolveViaProc(int uid) { + try { + File procDir = new File("/proc"); + if (!procDir.exists() || !procDir.canRead()) { + Log.d(TAG, "/proc directory not accessible"); + return null; + } + + File[] pidDirs = procDir.listFiles(file -> { + try { + Integer.parseInt(file.getName()); + return file.isDirectory(); + } catch (NumberFormatException e) { + return false; + } + }); + + if (pidDirs == null) return null; + + // Track best match found + String bestMatch = null; + int bestScore = 0; + + for (File pidDir : pidDirs) { + try { + File statusFile = new File(pidDir, "status"); + if (!statusFile.exists() || !statusFile.canRead()) continue; + + // Check if this process belongs to our UID + ProcessInfo procInfo = parseProcessStatus(statusFile); + if (procInfo != null && procInfo.uid == uid) { + + // Try multiple sources for process name + String processName = null; + int score = 0; + + // Method 1: Get from cmdline (highest priority) + File cmdlineFile = new File(pidDir, "cmdline"); + if (cmdlineFile.exists() && cmdlineFile.canRead()) { + String cmdline = readFirstLine(cmdlineFile); + if (cmdline != null && !cmdline.isEmpty()) { + processName = cleanProcessName(cmdline); + score = 3; // Highest score for cmdline + } + } + + // Method 2: Get from comm file (medium priority) + if (processName == null || processName.isEmpty()) { + File commFile = new File(pidDir, "comm"); + if (commFile.exists() && commFile.canRead()) { + String comm = readFirstLine(commFile); + if (comm != null && !comm.isEmpty()) { + processName = comm.trim(); + score = 2; + } + } + } + + // Method 3: Get from status Name field (lowest priority) + if (processName == null || processName.isEmpty()) { + if (procInfo.name != null && !procInfo.name.isEmpty()) { + processName = procInfo.name; + score = 1; + } + } + + // Track the best match found + if (processName != null && score > bestScore) { + bestMatch = processName; + bestScore = score; + } + } + } catch (Exception e) { + // Skip this process and continue + continue; + } + } + + if (bestMatch != null) { + Log.d(TAG, "Found process for UID " + uid + ": " + bestMatch + " (score: " + bestScore + ")"); + } + return bestMatch; + + } catch (Exception e) { + Log.w(TAG, "Failed to resolve UID via /proc", e); + } + return null; + } + + /** + * Parse comprehensive process information from /proc/[pid]/status file + */ + private static ProcessInfo parseProcessStatus(File statusFile) { + try (BufferedReader reader = new BufferedReader(new FileReader(statusFile))) { + String line; + String name = null; + int uid = -1; + int pid = -1; + String state = null; + + while ((line = reader.readLine()) != null) { + if (line.startsWith("Name:")) { + String[] parts = line.split("\\s+", 2); + if (parts.length >= 2) { + name = parts[1].trim(); + } + } else if (line.startsWith("Pid:")) { + String[] parts = line.split("\\s+"); + if (parts.length >= 2) { + try { + pid = Integer.parseInt(parts[1]); + } catch (NumberFormatException e) { + // Ignore + } + } + } else if (line.startsWith("State:")) { + String[] parts = line.split("\\s+", 2); + if (parts.length >= 2) { + state = parts[1].trim(); + } + } else if (line.startsWith("Uid:")) { + String[] parts = line.split("\\s+"); + if (parts.length >= 2) { + try { + uid = Integer.parseInt(parts[1]); // Real UID + } catch (NumberFormatException e) { + // Ignore + } + } + } + } + + if (uid != -1) { + return new ProcessInfo(name, uid, pid, state); + } + + } catch (IOException e) { + // Ignore, process might have died + } + return null; + } + + /** + * Read first line from file + */ + private static String readFirstLine(File file) { + try (BufferedReader reader = new BufferedReader(new FileReader(file))) { + String line = reader.readLine(); + return line != null ? line.trim() : null; + } catch (IOException e) { + return null; + } + } + + /** + * Clean process name from cmdline + */ + private static String cleanProcessName(String cmdline) { + if (cmdline == null || cmdline.isEmpty()) return null; + + // Replace null bytes with spaces + cmdline = cmdline.replace('\0', ' ').trim(); + + // Extract just the command name + String[] parts = cmdline.split("\\s+"); + if (parts.length > 0) { + String cmd = parts[0]; + // Remove path if present + int lastSlash = cmd.lastIndexOf('/'); + if (lastSlash >= 0) { + cmd = cmd.substring(lastSlash + 1); + } + return cmd; + } + return cmdline; + } + + /** + * Resolve UID by parsing /data/system/packages.list (requires root) - enhanced version + */ + private static String resolveViaPackagesList(int uid) { + // Try multiple possible locations for packages list + String[] possiblePaths = { + "/data/system/packages.list", + "/system/etc/packages.list", // Some ROMs + "/data/data/packages.list" // Alternative location + }; + + for (String path : possiblePaths) { + String result = tryReadPackagesList(path, uid); + if (result != null) { + Log.d(TAG, "Found UID " + uid + " in " + path + ": " + result); + return result; + } + } + + return null; + } + + /** + * Try to read packages list from specific path + */ + private static String tryReadPackagesList(String path, int uid) { + try { + File packagesFile = new File(path); + if (!packagesFile.exists() || !packagesFile.canRead()) { + return null; + } + + try (BufferedReader reader = new BufferedReader(new FileReader(packagesFile))) { + String line; + while ((line = reader.readLine()) != null) { + // Skip comments and empty lines + if (line.trim().isEmpty() || line.startsWith("#")) { + continue; + } + + String[] parts = line.split("\\s+"); + // Format: package_name uid debuggable data_dir selinux_label + if (parts.length >= 2) { + try { + int packageUid = Integer.parseInt(parts[1]); + + // Handle multi-user UIDs (user ID is in higher bits) + int baseUid = packageUid % 100000; // Remove user ID part + + if (packageUid == uid || baseUid == (uid % 100000)) { + String packageName = parts[0]; + + // Validate package name format + if (isValidPackageName(packageName)) { + return packageName; + } + } + } catch (NumberFormatException e) { + // Skip malformed line + Log.d(TAG, "Malformed line in " + path + ": " + line); + continue; + } + } + } + } + } catch (IOException e) { + Log.d(TAG, "Failed to read " + path + ": " + e.getMessage()); + } catch (SecurityException e) { + Log.d(TAG, "No permission to read " + path + " (expected on non-root)"); + } + return null; + } + + /** + * Validate package name format + */ + private static boolean isValidPackageName(String packageName) { + if (packageName == null || packageName.trim().isEmpty()) { + return false; + } + + // Basic package name validation (at least one dot, reasonable length) + return packageName.contains(".") && + packageName.length() > 3 && + packageName.length() < 256 && + packageName.matches("^[a-zA-Z0-9._]+$"); + } + + /** + * Check if UID is in system range + */ + public static boolean isSystemUid(int uid) { + return uid >= 0 && uid < 10000; + } + + /** + * Check if UID is in app range + */ + public static boolean isAppUid(int uid) { + return uid >= 10000; + } + + /** + * Check if UID is in multi-user range + */ + public static boolean isMultiUserUid(int uid) { + return uid >= 100000; // User 1 and above + } + + /** + * Extract user ID from multi-user UID + * @param uid the multi-user UID + * @return user ID (0 for primary user, 1+ for secondary users) + */ + public static int getUserId(int uid) { + return uid / 100000; + } + + /** + * Extract app ID from multi-user UID + * @param uid the multi-user UID + * @return app ID (the base UID without user component) + */ + public static int getAppId(int uid) { + return uid % 100000; + } + + /** + * Create multi-user UID from user ID and app ID + * @param userId user ID (0, 1, 2, etc.) + * @param appId app ID (10000+) + * @return multi-user UID + */ + public static int createMultiUserUid(int userId, int appId) { + return userId * 100000 + appId; + } + + /** + * Get user-friendly description of UID type + */ + public static String getUidTypeDescription(int uid) { + if (uid < 0) { + return "invalid"; + } else if (uid == 0) { + return "root"; + } else if (uid < 1000) { + return "system_low"; + } else if (uid < 10000) { + return "system"; + } else if (uid < 100000) { + return "app_primary"; + } else { + int userId = getUserId(uid); + int appId = getAppId(uid); + return String.format("app_user%d(app:%d)", userId, appId); + } + } + + /** + * Cache a resolved UID result + */ + private static void cacheResult(int uid, String name, ResolutionMethod method) { + UID_CACHE.put(uid, new CacheEntry(name, method)); + + // Add system UIDs to frequent cache immediately (they're stable) + if (method == ResolutionMethod.SYSTEM_DB) { + FREQUENT_UID_CACHE.put(uid, name); + } + } + + /** + * Clean expired entries from cache + */ + public static void cleanExpiredEntries() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + UID_CACHE.entrySet().removeIf(entry -> entry.getValue().isExpired()); + } else { + // Fallback for older APIs + Iterator> iterator = UID_CACHE.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + if (entry.getValue().isExpired()) { + iterator.remove(); + } + } + } + } + /** + * Clear all caches (useful for testing or when packages change) + */ + public static void clearCache() { + UID_CACHE.clear(); + FREQUENT_UID_CACHE.evictAll(); + Log.i(TAG, "UID resolution caches cleared"); + } + + /** + * Invalidate cache entry for specific UID + */ + public static void invalidateUid(int uid) { + UID_CACHE.remove(uid); + FREQUENT_UID_CACHE.remove(uid); + Log.d(TAG, "Cache invalidated for UID: " + uid); + } + + /** + * Get cache statistics for debugging + */ + public static String getCacheStats() { + return String.format("Cache: %d entries, LRU: %d entries", + UID_CACHE.size(), FREQUENT_UID_CACHE.size()); + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/ukanth/ufirewall/widget/StatusWidget.java b/app/src/main/java/dev/ukanth/ufirewall/widget/StatusWidget.java index 167a6cd29..b55ac10fb 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/widget/StatusWidget.java +++ b/app/src/main/java/dev/ukanth/ufirewall/widget/StatusWidget.java @@ -29,93 +29,74 @@ import android.content.ComponentName; import android.content.Context; import android.content.Intent; -import android.content.SharedPreferences; -import android.os.Bundle; -import android.util.Log; +import android.os.Handler; +import android.os.Looper; import android.widget.RemoteViews; import dev.ukanth.ufirewall.Api; import dev.ukanth.ufirewall.R; -import dev.ukanth.ufirewall.service.RootCommand; -import dev.ukanth.ufirewall.util.G; /** - * ON/OFF Widget implementation + * ON/OFF Widget implementation. A tap opens {@link WidgetActionActivity}, which does the + * confirmation / app lock checks and the toggle. */ public class StatusWidget extends AppWidgetProvider { @Override public void onReceive(final Context context, final Intent intent) { super.onReceive(context, intent); - if (Api.STATUS_CHANGED_MSG.equals(intent.getAction())) { - // Broadcast sent when the DroidWall status has changed - final Bundle extras = intent.getExtras(); - if (extras != null && extras.containsKey(Api.STATUS_EXTRA)) { - final boolean firewallEnabled = extras.getBoolean(Api.STATUS_EXTRA); - final AppWidgetManager manager = AppWidgetManager.getInstance(context); - final int[] widgetIds = manager.getAppWidgetIds(new ComponentName(context, StatusWidget.class)); - showWidget(context, manager, widgetIds, firewallEnabled); - } - } else if (Api.TOGGLE_REQUEST_MSG.equals(intent.getAction())) { - // Broadcast sent to request toggling DroidWall's status - - /*final String oldPwd = G.profile_pwd(); - final String newPwd = context.getSharedPreferences(Api.PREF_FIREWALL_STATUS, 0).getString("LockPassword", ""); - */ - final SharedPreferences prefs = context.getSharedPreferences(Api.PREF_FIREWALL_STATUS, 0); - final boolean enabled = !prefs.getBoolean(Api.PREF_ENABLED, true); - - Log.d(Api.TAG, "Protection Level: " + G.protectionLevel()); - if (!G.protectionLevel().equals("p0") || G.enableDeviceCheck()) { - - //Toast.makeText(context, R.string.widget_disable_fail, Toast.LENGTH_SHORT).show(); - //return; - } else { - if (enabled) { - Api.applySavedIptablesRules(context, true, new RootCommand() - .setSuccessToast(R.string.toast_enabled) - .setFailureToast(R.string.toast_error_enabling) - .setReopenShell(true) - .setCallback(new RootCommand.Callback() { - public void cbFunc(RootCommand state) { - boolean status = (state.exitCode == 0); - // setEnabled always sends us a STATUS_CHANGED_MSG intent to update the icon - Api.setEnabled(context, status, true); - } - })); - } else { - Api.purgeIptables(context, true, new RootCommand() - .setSuccessToast(R.string.toast_disabled) - .setFailureToast(R.string.toast_error_disabling) - .setCallback(new RootCommand.Callback() { - public void cbFunc(RootCommand state) { - boolean status = (state.exitCode != 0); - Api.setEnabled(context, status, true); - } - })); - } - } + // STATUS_CHANGED: sent by Api.setEnabled(). TOGGLE_REQUEST: the tap of a widget placed by + // an older version; this receiver is exported, so a toggle is never done from here (any + // app could send it). Both just redraw from the real state, which also replaces an old + // tap action with the new one. + if (Api.STATUS_CHANGED_MSG.equals(intent.getAction()) || Api.TOGGLE_REQUEST_MSG.equals(intent.getAction())) { + refresh(context); } } @Override - public void onUpdate(Context context, AppWidgetManager appWidgetManager, - int[] ints) { + public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] ints) { super.onUpdate(context, appWidgetManager, ints); - final SharedPreferences prefs = context.getSharedPreferences(Api.PREF_FIREWALL_STATUS, 0); - boolean enabled = prefs.getBoolean(Api.PREF_ENABLED, true); - showWidget(context, appWidgetManager, ints, enabled); + show(context, appWidgetManager, ints, Api.isEnabled(context) ? R.drawable.widget_on : R.drawable.widget_off); + } + + /** + * Redraw every status widget from the current state. + */ + public static void refresh(Context context) { + show(context, Api.isEnabled(context) ? R.drawable.widget_on : R.drawable.widget_off); + } + + /** + * Show "enabling..." / "disabling..." while a toggle runs. + */ + static void showPending(Context context, boolean willEnable) { + show(context, willEnable ? R.drawable.widget_enabling : R.drawable.widget_disabling); + } + + /** + * Flash success / error, then show the real state. May be called from any thread. + */ + static void showResult(Context context, boolean success) { + new Handler(Looper.getMainLooper()).post(() -> { + show(context, success ? R.drawable.widget_success : R.drawable.widget_error); + new Handler(Looper.getMainLooper()).postDelayed(() -> refresh(context), success ? 1000 : 2000); + }); } - private void showWidget(Context context, AppWidgetManager manager, - int[] widgetIds, boolean enabled) { + private static void show(Context context, int iconId) { + AppWidgetManager manager = AppWidgetManager.getInstance(context); + int[] widgetIds = manager.getAppWidgetIds(new ComponentName(context, StatusWidget.class)); + if (widgetIds != null && widgetIds.length > 0) { + show(context, manager, widgetIds, iconId); + } + } + + private static void show(Context context, AppWidgetManager manager, int[] widgetIds, int iconId) { final RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.onoff_widget); - final int iconId = enabled ? R.drawable.widget_on : R.drawable.widget_off; views.setInt(R.id.widgetCanvas, "setBackgroundResource", iconId); - final Intent msg = new Intent(context, StatusWidget.class); - msg.setAction(Api.TOGGLE_REQUEST_MSG); - final PendingIntent intent = PendingIntent.getBroadcast(context, -1, msg, PendingIntent.FLAG_IMMUTABLE); + final PendingIntent intent = PendingIntent.getActivity(context, 0, WidgetActionActivity.toggleIntent(context), + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); views.setOnClickPendingIntent(R.id.widgetCanvas, intent); manager.updateAppWidget(widgetIds, views); } - } diff --git a/app/src/main/java/dev/ukanth/ufirewall/widget/ToggleWidget.java b/app/src/main/java/dev/ukanth/ufirewall/widget/ToggleWidget.java index eab397298..a52623183 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/widget/ToggleWidget.java +++ b/app/src/main/java/dev/ukanth/ufirewall/widget/ToggleWidget.java @@ -49,7 +49,7 @@ public void onUpdate(Context context, AppWidgetManager appWidgetManager, RemoteViews remoteViews = new RemoteViews(context.getPackageName(),R.layout.toggle_widget_layout); Intent configIntent = new Intent(context, ToggleWidgetActivity.class); - PendingIntent configPendingIntent = PendingIntent.getActivity(context,0, configIntent, PendingIntent.FLAG_MUTABLE); + PendingIntent configPendingIntent = PendingIntent.getActivity(context,0, configIntent, PendingIntent.FLAG_IMMUTABLE); remoteViews.setOnClickPendingIntent(R.id.toggle_widget_icon,configPendingIntent); appWidgetManager.updateAppWidget(appWidgetIds, remoteViews); } diff --git a/app/src/main/java/dev/ukanth/ufirewall/widget/ToggleWidgetActivity.java b/app/src/main/java/dev/ukanth/ufirewall/widget/ToggleWidgetActivity.java index 995f5341d..f53076a92 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/widget/ToggleWidgetActivity.java +++ b/app/src/main/java/dev/ukanth/ufirewall/widget/ToggleWidgetActivity.java @@ -1,29 +1,23 @@ package dev.ukanth.ufirewall.widget; -import static dev.ukanth.ufirewall.util.SecurityUtil.LOCK_VERIFICATION; -import static dev.ukanth.ufirewall.util.SecurityUtil.REQ_ENTER_PATTERN; -import static haibison.android.lockpattern.LockPatternActivity.RESULT_FAILED; -import static haibison.android.lockpattern.LockPatternActivity.RESULT_FORGOT_PATTERN; - import android.app.Activity; import android.content.Context; import android.content.Intent; import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.os.Message; import android.view.ViewGroup; import android.widget.RelativeLayout; -import android.widget.Toast; import java.util.ArrayList; import java.util.List; +import com.afollestad.materialdialogs.MaterialDialog; + import dev.ukanth.ufirewall.Api; import dev.ukanth.ufirewall.R; +import dev.ukanth.ufirewall.log.Log; import dev.ukanth.ufirewall.profiles.ProfileData; import dev.ukanth.ufirewall.profiles.ProfileHelper; -import dev.ukanth.ufirewall.service.RootCommand; +import dev.ukanth.ufirewall.util.FirewallActions; import dev.ukanth.ufirewall.util.G; import dev.ukanth.ufirewall.util.SecurityUtil; import dev.ukanth.ufirewall.widget.RadialMenuWidget.RadialMenuEntry; @@ -33,12 +27,20 @@ public class ToggleWidgetActivity extends Activity { private RadialMenuWidget pieMenu; private RelativeLayout relativeLayout; + private static final int ACTION_ENABLE = 1; + private static final int ACTION_DISABLE = 2; + private static final int ACTION_PROFILE = 4; + private int actionType = 0; + // identifier of the profile to switch to (ACTION_PROFILE) + private String pendingProfileId; + private SecurityUtil security; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.toggle_widget_view); + security = new SecurityUtil(this); relativeLayout = this.findViewById(R.id.widgetCircle); pieMenu = new RadialMenuWidget(getBaseContext()); @@ -111,48 +113,29 @@ public List getChildren() { } public void menuActiviated() { - actionType = 1; - startAction(1); + startAction(ACTION_ENABLE); } } public class Status implements RadialMenuEntry { public String getName() { - if (G.enableMultiProfile()) { - switch (G.storedProfile()) { - case Api.DEFAULT_PREFS_NAME: - return G.gPrefs.getString("default", getApplicationContext().getString(R.string.defaultProfile)); - case "AFWallProfile1": - return G.gPrefs.getString("profile1", getApplicationContext().getString(R.string.profile1)); - case "AFWallProfile2": - return G.gPrefs.getString("profile2", getApplicationContext().getString(R.string.profile2)); - case "AFWallProfile3": - return G.gPrefs.getString("profile3", getApplicationContext().getString(R.string.profile3)); - default: - return G.storedProfile(); - } - } else { - return ""; - } + return activeProfileName(); } public String getLabel() { - if (G.enableMultiProfile()) { - switch (G.storedProfile()) { - case Api.DEFAULT_PREFS_NAME: - return G.gPrefs.getString("default", getApplicationContext().getString(R.string.defaultProfile)); - case "AFWallProfile1": - return G.gPrefs.getString("profile1", getApplicationContext().getString(R.string.profile1)); - case "AFWallProfile2": - return G.gPrefs.getString("profile2", getApplicationContext().getString(R.string.profile2)); - case "AFWallProfile3": - return G.gPrefs.getString("profile3", getApplicationContext().getString(R.string.profile3)); - default: - return G.storedProfile(); - } - } else { + return activeProfileName(); + } + + private String activeProfileName() { + if (!G.enableMultiProfile()) { return ""; } + String identifier = G.storedProfile(); + if (Api.DEFAULT_PREFS_NAME.equals(identifier)) { + return G.gPrefs.getString("default", getApplicationContext().getString(R.string.defaultProfile)); + } + ProfileData data = ProfileHelper.getProfileByIdentifier(identifier); + return data != null ? data.getName() : identifier; } public int getIcon() { @@ -186,8 +169,7 @@ public List getChildren() { } public void menuActiviated() { - actionType = 2; - startAction(2); + startAction(ACTION_DISABLE); } } @@ -212,21 +194,9 @@ public List getChildren() { } public Profiles() { - if (!G.isProfileMigrated()) { - children.add(new DefaultProfile()); - children.add(new Profile1()); - children.add(new Profile2()); - children.add(new Profile3()); - for (String profileName : G.getAdditionalProfiles()) { - RadialMenuEntry entry = new GenericProfile(profileName); - children.add(entry); - } - } else { - children.add(new DefaultProfile()); - for (ProfileData data : ProfileHelper.getProfiles()) { - RadialMenuEntry entry = new GenericProfile(data.getName()); - children.add(entry); - } + children.add(new DefaultProfile()); + for (ProfileData data : ProfileHelper.getProfiles()) { + children.add(new GenericProfile(data.getName())); } } @@ -258,42 +228,12 @@ public List getChildren() { } public void menuActiviated() { - final Handler toaster = new Handler() { - public void handleMessage(Message msg) { - if (msg.arg1 != 0) - Toast.makeText(getApplicationContext(), msg.arg1, Toast.LENGTH_SHORT).show(); - } - }; - final Context context = getApplicationContext(); - new Thread() { - @Override - public void run() { - if (G.isProfileMigrated()) { - ProfileData data = ProfileHelper.getProfileByName(profileName); - G.setProfile(true, data.getIdentifier()); - } else { - G.setProfile(true, profileName); - } - Api.applySavedIptablesRules(context, true, new RootCommand() - .setSuccessToast(R.string.rules_applied) - .setFailureToast(R.string.error_apply) - .setCallback(new RootCommand.Callback() { - @Override - public void cbFunc(RootCommand state) { - Message msg = new Message(); - if (state.exitCode == 0) { - msg.arg1 = R.string.rules_applied; - } else { - // error details are already in logcat - msg.arg1 = R.string.error_apply; - } - toaster.sendMessage(msg); - } - })); - //Api.showNotification(Api.isEnabled(getApplicationContext()), getApplicationContext()); - Api.updateNotification(Api.isEnabled(getApplicationContext()), getApplicationContext()); - } - }.start(); + ProfileData data = ProfileHelper.getProfileByName(profileName); + if (data != null) { + // same checks (app lock) as the other actions + pendingProfileId = data.getIdentifier(); + startAction(ACTION_PROFILE); + } } } @@ -315,254 +255,74 @@ public List getChildren() { } public void menuActiviated() { - startAction(3); - } - } - - public class Profile1 implements RadialMenuEntry { - public String getName() { - if (!G.isProfileMigrated()) { - return G.gPrefs.getString("profile1", getString(R.string.profile1)); - } else { - return "AFWallProfile1"; - } - } - - public String getLabel() { - if (!G.isProfileMigrated()) { - return G.gPrefs.getString("profile1", getString(R.string.profile1)); - } else { - return "AFWallProfile1"; - } - } - - public int getIcon() { - return 0; - } - - public List getChildren() { - return null; - } - - public void menuActiviated() { - startAction(4); - } - } - - public class Profile2 implements RadialMenuEntry { - public String getName() { - if (!G.isProfileMigrated()) { - return G.gPrefs.getString("profile2", getString(R.string.profile2)); - } else { - return "AFWallProfile2"; - } - } - - public String getLabel() { - if (!G.isProfileMigrated()) { - return G.gPrefs.getString("profile2", getString(R.string.profile2)); - } else { - return "AFWallProfile2"; - } - } - - public int getIcon() { - return 0; - } - - public List getChildren() { - return null; - } - - public void menuActiviated() { - startAction(5); - } - } - - public class Profile3 implements RadialMenuEntry { - public String getName() { - if (!G.isProfileMigrated()) { - return G.gPrefs.getString("profile3", getString(R.string.profile3)); - } else { - return "AFWallProfile3"; - } - } - - public String getLabel() { - if (!G.isProfileMigrated()) { - return G.gPrefs.getString("profile3", getString(R.string.profile3)); - } else { - return "AFWallProfile3"; - } - } - - public int getIcon() { - return 0; - } - - public List getChildren() { - return null; - } - - public void menuActiviated() { - startAction(6); + pendingProfileId = Api.DEFAULT_PREFS_NAME; + startAction(ACTION_PROFILE); } } @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); - switch (requestCode) { - case LOCK_VERIFICATION: { - switch (resultCode) { - case RESULT_OK: - invokeAction(); - break; - default: - ToggleWidgetActivity.this.finish(); - android.os.Process.killProcess(android.os.Process.myPid()); - break; - } - } - break; - case REQ_ENTER_PATTERN: { - switch (resultCode) { - case RESULT_OK: - invokeAction(); - break; - case RESULT_CANCELED: - case RESULT_FAILED: - case RESULT_FORGOT_PATTERN: - default: - ToggleWidgetActivity.this.finish(); - break; - } - } - break; - } + // device lock / pattern results of the app lock check + security.handleActivityResult(requestCode, resultCode); } private void startAction(final int i) { actionType = i; - SecurityUtil util = new SecurityUtil(ToggleWidgetActivity.this); - boolean isProtected = util.isPasswordProtected(); - if (!isProtected) { - invokeAction(); - } else { - util.passCheck(); + if (i == ACTION_DISABLE && G.enableConfirm()) { + confirmDisableFromWidget(); + return; } + continueActionAfterConfirmation(); } - private void invokeAction() { - final Handler toaster = new Handler(getMainLooper()) { - @Override - public void handleMessage(Message msg) { - if (msg.arg1 != 0) { - runOnUiThread(() -> Toast.makeText(getApplicationContext(),msg.arg1,Toast.LENGTH_SHORT).show()); - } + private void confirmDisableFromWidget() { + new MaterialDialog.Builder(this) + .title(R.string.confirmMsg) + .cancelable(false) + .positiveText(R.string.Yes) + .negativeText(R.string.No) + .onPositive((dialog, which) -> { + Log.i(Api.TAG, "Widget firewall disable confirmed"); + dialog.dismiss(); + continueActionAfterConfirmation(); + }) + .onNegative((dialog, which) -> { + Log.i(Api.TAG, "Widget firewall disable canceled"); + dialog.dismiss(); + finish(); + }) + .show(); + } + + private void continueActionAfterConfirmation() { + security.passCheck(allowed -> { + if (allowed) { + invokeAction(); + } else { + finish(); } - }; + }); + } + + private void invokeAction() { final Context context = getApplicationContext(); - new Thread() { - @Override - public void run() { - Looper.prepare(); - if (actionType < 7) { - switch (actionType) { - case 1: - Api.applySavedIptablesRules(context, true, new RootCommand() - .setSuccessToast(R.string.rules_applied) - .setFailureToast(R.string.error_apply) - .setReopenShell(true) - .setCallback(new RootCommand.Callback() { - @Override - public void cbFunc(RootCommand state) { - final Message msg = new Message(); - if (state.exitCode == 0) { - msg.arg1 = R.string.rules_applied; - Api.setEnabled(context, true, false); - } else { - // error details are already in logcat - msg.arg1 = R.string.error_apply; - } - toaster.sendMessage(msg); - } - })); - break; - case 2: - //validation, check for password - Api.purgeIptables(context, true, new RootCommand() - .setSuccessToast(R.string.toast_disabled) - .setFailureToast(R.string.toast_error_disabling) - .setReopenShell(true) - .setCallback(new RootCommand.Callback() { - public void cbFunc(RootCommand state) { - final Message msg = new Message(); - if (state.exitCode == 0) { - msg.arg1 = R.string.toast_disabled; - Api.setEnabled(context, false, false); - } else { - // error details are already in logcat - msg.arg1 = R.string.toast_error_disabling; - } - toaster.sendMessage(msg); - } - })); - break; - case 3: - G.setProfile(G.enableMultiProfile(), "AFWallPrefs"); - break; - case 4: - G.setProfile(true, "AFWallProfile1"); - break; - case 5: - G.setProfile(true, "AFWallProfile2"); - break; - case 6: - G.setProfile(true, "AFWallProfile3"); - break; - } - if (actionType > 2) { - final Message msg = new Message(); - Api.applySavedIptablesRules(context, true, new RootCommand() - .setSuccessToast(R.string.rules_applied) - .setFailureToast(R.string.error_apply) - .setCallback(new RootCommand.Callback() { - @Override - public void cbFunc(RootCommand state) { - if (state.exitCode == 0) { - msg.arg1 = R.string.rules_applied; - } else { - // error details are already in logcat - msg.arg1 = R.string.error_apply; - } - toaster.sendMessage(msg); - } - })); - G.reloadPrefs(); - } + FirewallActions.Done updateNotification = ok -> Api.updateNotification(Api.isEnabled(context), context); + switch (actionType) { + case ACTION_ENABLE: + FirewallActions.setEnabled(context, true, true, updateNotification); + break; + case ACTION_DISABLE: + FirewallActions.setEnabled(context, false, true, updateNotification); + break; + case ACTION_PROFILE: + if (pendingProfileId != null) { + // applies the rules only while the firewall is enabled + FirewallActions.switchProfile(context, pendingProfileId, true, updateNotification); } - //Api.showNotification(Api.isEnabled(getApplicationContext()), getApplicationContext()); - Api.updateNotification(Api.isEnabled(getApplicationContext()), getApplicationContext()); - } - }.start(); + break; + } } - /*private boolean applyProfileRules(final Context context, final Message msg, final Handler toaster) { - boolean ret = Api.applySavedIptablesRules(context, false, new RootCommand() - .setFailureToast(R.string.error_apply) - .setCallback(new RootCommand.Callback() { - @Override - public void cbFunc(RootCommand state) { - if (state.exitCode == 0) { - msg.arg1 = R.string.rules_applied; - } else { - // error details are already in logcat - msg.arg1 = R.string.error_apply; - } - } - })); - return ret; - }*/ } \ No newline at end of file diff --git a/app/src/main/java/dev/ukanth/ufirewall/widget/ToggleWidgetOld.java b/app/src/main/java/dev/ukanth/ufirewall/widget/ToggleWidgetOld.java index 633477a63..fe54852f2 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/widget/ToggleWidgetOld.java +++ b/app/src/main/java/dev/ukanth/ufirewall/widget/ToggleWidgetOld.java @@ -50,7 +50,7 @@ public void onUpdate(Context context, AppWidgetManager appWidgetManager, Intent configIntent = new Intent(context, ToggleWidgetOldActivity.class); - PendingIntent configPendingIntent = PendingIntent.getActivity(context, 0, configIntent, PendingIntent.FLAG_MUTABLE); + PendingIntent configPendingIntent = PendingIntent.getActivity(context, 0, configIntent, PendingIntent.FLAG_IMMUTABLE); remoteViews.setOnClickPendingIntent(R.id.toggle_widget_icon_old, configPendingIntent); appWidgetManager.updateAppWidget(appWidgetIds, remoteViews); diff --git a/app/src/main/java/dev/ukanth/ufirewall/widget/ToggleWidgetOldActivity.java b/app/src/main/java/dev/ukanth/ufirewall/widget/ToggleWidgetOldActivity.java index 91d7bc8b0..65129a5ce 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/widget/ToggleWidgetOldActivity.java +++ b/app/src/main/java/dev/ukanth/ufirewall/widget/ToggleWidgetOldActivity.java @@ -1,49 +1,45 @@ package dev.ukanth.ufirewall.widget; -import static dev.ukanth.ufirewall.util.SecurityUtil.LOCK_VERIFICATION; -import static dev.ukanth.ufirewall.util.SecurityUtil.REQ_ENTER_PATTERN; -import static haibison.android.lockpattern.LockPatternActivity.RESULT_FAILED; -import static haibison.android.lockpattern.LockPatternActivity.RESULT_FORGOT_PATTERN; - import android.app.Activity; import android.content.Context; import android.content.Intent; import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.os.Message; import android.view.View; import android.view.View.OnClickListener; import android.widget.Button; -import android.widget.Toast; import java.util.List; +import com.afollestad.materialdialogs.MaterialDialog; + import dev.ukanth.ufirewall.Api; import dev.ukanth.ufirewall.R; +import dev.ukanth.ufirewall.log.Log; import dev.ukanth.ufirewall.profiles.ProfileData; import dev.ukanth.ufirewall.profiles.ProfileHelper; -import dev.ukanth.ufirewall.service.RootCommand; +import dev.ukanth.ufirewall.util.FirewallActions; import dev.ukanth.ufirewall.util.G; import dev.ukanth.ufirewall.util.SecurityUtil; public class ToggleWidgetOldActivity extends Activity implements OnClickListener { - private static Button enableButton; - private static Button disableButton; - private static Button defaultButton; - private static Button profButton1; - private static Button profButton2; - private static Button profButton3; + private Button enableButton; + private Button disableButton; + private Button defaultButton; + private Button profButton1; + private Button profButton2; + private Button profButton3; private String profileName; private int buttonId; + private SecurityUtil security; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.toggle_widget_old_view); + security = new SecurityUtil(this); enableButton = this.findViewById(R.id.toggle_enable_firewall); disableButton = this @@ -64,55 +60,15 @@ protected void onCreate(Bundle savedInstanceState) { disableOthers(); } - if (!G.isProfileMigrated()) { - profButton1.setText(G.gPrefs.getString("profile1", getApplicationContext().getString(R.string.profile1))); - profButton2.setText(G.gPrefs.getString("profile2", getApplicationContext().getString(R.string.profile2))); - profButton3.setText(G.gPrefs.getString("profile3", getApplicationContext().getString(R.string.profile3))); - } else { - //hide by default - profButton1.setVisibility(View.INVISIBLE); - profButton2.setVisibility(View.INVISIBLE); - profButton3.setVisibility(View.INVISIBLE); - - if (ProfileHelper.getProfileByIdentifier("AFWallProfile1") != null) { - profButton1.setVisibility(View.VISIBLE); - } - if (ProfileHelper.getProfileByIdentifier("AFWallProfile2") != null) { - profButton2.setVisibility(View.VISIBLE); - } - if (ProfileHelper.getProfileByIdentifier("AFWallProfile3") != null) { - profButton3.setVisibility(View.VISIBLE); - } - List listData = ProfileHelper.getProfiles(); - //worst case 10 ! - if (listData.size() <= 20) { - switch (listData.size()) { - case 1: - profButton1.setText(listData.get(0).getName()); - profButton1.setVisibility(View.VISIBLE); - break; - case 2: - profButton1.setText(listData.get(0).getName()); - profButton1.setVisibility(View.VISIBLE); - profButton2.setText(listData.get(1).getName()); - profButton2.setVisibility(View.VISIBLE); - case 3: - profButton1.setText(listData.get(0).getName()); - profButton1.setVisibility(View.VISIBLE); - profButton2.setText(listData.get(1).getName()); - profButton2.setVisibility(View.VISIBLE); - profButton3.setText(listData.get(2).getName()); - profButton3.setVisibility(View.VISIBLE); - default: - //enable first 3 - profButton1.setText(listData.get(0).getName()); - profButton1.setVisibility(View.VISIBLE); - profButton2.setText(listData.get(1).getName()); - profButton2.setVisibility(View.VISIBLE); - profButton3.setText(listData.get(2).getName()); - profButton3.setVisibility(View.VISIBLE); - - } + // this widget has room for the first three profiles only + Button[] profButtons = {profButton1, profButton2, profButton3}; + List listData = ProfileHelper.getProfiles(); + for (int i = 0; i < profButtons.length; i++) { + if (i < listData.size()) { + profButtons[i].setText(listData.get(i).getName()); + profButtons[i].setVisibility(View.VISIBLE); + } else { + profButtons[i].setVisibility(View.INVISIBLE); } } @@ -137,64 +93,52 @@ protected void onCreate(Bundle savedInstanceState) { } private void switchAction() { - if(buttonId == R.id.toggle_enable_firewall) { - startAction(1); - } else if(buttonId == R.id.toggle_disable_firewall) { - startAction(2); - } else if(buttonId == R.id.toggle_default_profile) { - startAction(3); - } else if(buttonId == R.id.toggle_profile1) { - if (!G.isProfileMigrated()) { - startAction(4); - } else { - runProfile(profileName); - } - } else if(buttonId == R.id.toggle_profile2) { - if (!G.isProfileMigrated()) { - startAction(5); - } else { - runProfile(profileName); - } - } else if(buttonId == R.id.toggle_profile3) { - if (!G.isProfileMigrated()) { - startAction(6); - } else { - runProfile(profileName); + final Context context = getApplicationContext(); + if (buttonId == R.id.toggle_enable_firewall) { + FirewallActions.setEnabled(context, true, true, ok -> { + if (ok) { + enableOthers(); + } + Api.updateNotification(Api.isEnabled(context), context); + }); + } else if (buttonId == R.id.toggle_disable_firewall) { + FirewallActions.setEnabled(context, false, true, ok -> { + if (ok) { + disableOthers(); + } + Api.updateNotification(Api.isEnabled(context), context); + }); + } else if (buttonId == R.id.toggle_default_profile) { + switchProfile(Api.DEFAULT_PREFS_NAME); + } else if (buttonId == R.id.toggle_profile1 || buttonId == R.id.toggle_profile2 + || buttonId == R.id.toggle_profile3) { + ProfileData data = ProfileHelper.getProfileByName(profileName); + if (data != null) { + switchProfile(data.getIdentifier()); } } } + private void switchProfile(final String identifier) { + final Context context = getApplicationContext(); + // applies the rules only while the firewall is enabled + FirewallActions.switchProfile(context, identifier, true, ok -> { + if (ok) { + if (Api.DEFAULT_PREFS_NAME.equals(identifier)) { + disableDefault(); + } else { + disableCustom(identifier); + } + } + Api.updateNotification(Api.isEnabled(context), context); + }); + } + @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); - switch (requestCode) { - case LOCK_VERIFICATION: { - switch (resultCode) { - case RESULT_OK: - switchAction(); - break; - default: - ToggleWidgetOldActivity.this.finish(); - android.os.Process.killProcess(android.os.Process.myPid()); - break; - } - } - break; - case REQ_ENTER_PATTERN: { - switch (resultCode) { - case RESULT_OK: - switchAction(); - break; - case RESULT_CANCELED: - case RESULT_FAILED: - case RESULT_FORGOT_PATTERN: - default: - ToggleWidgetOldActivity.this.finish(); - break; - } - } - break; - } + // device lock / pattern results of the app lock check + security.handleActivityResult(requestCode, resultCode); } @Override @@ -202,217 +146,40 @@ public void onClick(View button) { profileName = ((Button) button).getText().toString(); buttonId = button.getId(); - SecurityUtil util = new SecurityUtil(ToggleWidgetOldActivity.this); - boolean passCheck = util.isPasswordProtected(); - if (!passCheck) { - switchAction(); - } else { - util.passCheck(); + if (buttonId == R.id.toggle_disable_firewall && G.enableConfirm()) { + confirmDisableFromWidget(); + return; } + continueClickAfterConfirmation(); } - private void runProfile(final String profileName) { - final Handler toaster = new Handler() { - public void handleMessage(Message msg) { - if (msg.arg1 != 0) - Toast.makeText(getApplicationContext(), msg.arg1, Toast.LENGTH_SHORT).show(); - } - }; - - final Context context = getApplicationContext(); - new Thread() { - @Override - public void run() { - Looper.prepare(); - ProfileData data = ProfileHelper.getProfileByName(profileName); - G.setProfile(true, data.getIdentifier()); - Api.applySavedIptablesRules(context, false, new RootCommand() - .setCallback(new RootCommand.Callback() { - @Override - public void cbFunc(RootCommand state) { - Message msg = new Message(); - if (state.exitCode == 0) { - msg.arg1 = R.string.rules_applied; - toaster.sendMessage(msg); - enableOthers(); - } else { - // error details are already in logcat - msg.arg1 = R.string.error_apply; - toaster.sendMessage(msg); - } - } - })); - //Api.showNotification(Api.isEnabled(getApplicationContext()), getApplicationContext()); - Api.updateNotification(Api.isEnabled(getApplicationContext()), getApplicationContext()); - } - }.start(); - defaultButton.setEnabled(true); - if (profButton1.getText().equals(profileName)) { - profButton1.setEnabled(false); - profButton2.setEnabled(true); - profButton3.setEnabled(true); - } else if (profButton2.getText().equals(profileName)) { - profButton1.setEnabled(true); - profButton2.setEnabled(false); - profButton3.setEnabled(true); - } else if (profButton3.getText().equals(profileName)) { - profButton1.setEnabled(true); - profButton2.setEnabled(true); - profButton3.setEnabled(false); - } + private void confirmDisableFromWidget() { + new MaterialDialog.Builder(this) + .title(R.string.confirmMsg) + .cancelable(false) + .positiveText(R.string.Yes) + .negativeText(R.string.No) + .onPositive((dialog, which) -> { + Log.i(Api.TAG, "Legacy widget firewall disable confirmed"); + dialog.dismiss(); + continueClickAfterConfirmation(); + }) + .onNegative((dialog, which) -> { + Log.i(Api.TAG, "Legacy widget firewall disable canceled"); + dialog.dismiss(); + finish(); + }) + .show(); } - private void startAction(final int i) { - - final Handler toaster = new Handler() { - public void handleMessage(Message msg) { - if (msg.arg1 != 0) - Toast.makeText(getApplicationContext(), msg.arg1, - Toast.LENGTH_SHORT).show(); - } - }; - final Context context = getApplicationContext(); - new Thread() { - @Override - public void run() { - Looper.prepare(); - switch (i) { - case 1: - Api.applySavedIptablesRules(context, false, new RootCommand() - .setCallback(new RootCommand.Callback() { - @Override - public void cbFunc(RootCommand state) { - Message msg = new Message(); - if (state.exitCode == 0) { - msg.arg1 = R.string.rules_applied; - toaster.sendMessage(msg); - enableOthers(); - Api.setEnabled(context, true, false); - } else { - // error details are already in logcat - msg.arg1 = R.string.error_apply; - toaster.sendMessage(msg); - } - } - })); - break; - case 2: - // validation, check for password - Api.purgeIptables(context, true, new RootCommand() - .setSuccessToast(R.string.toast_disabled) - .setFailureToast(R.string.toast_error_disabling) - .setReopenShell(true) - .setCallback(new RootCommand.Callback() { - public void cbFunc(RootCommand state) { - final Message msg = new Message(); - if (state.exitCode == 0) { - msg.arg1 = R.string.toast_disabled; - Api.setEnabled(context, false, false); - } else { - // error details are already in logcat - msg.arg1 = R.string.toast_error_disabling; - } - toaster.sendMessage(msg); - } - })); - break; - case 3: - G.setProfile(G.enableMultiProfile(), "AFWallPrefs"); - Api.applySavedIptablesRules(context, false, new RootCommand() - .setCallback(new RootCommand.Callback() { - @Override - public void cbFunc(RootCommand state) { - Message msg = new Message(); - if (state.exitCode == 0) { - msg.arg1 = R.string.rules_applied; - toaster.sendMessage(msg); - enableOthers(); - disableDefault(); - } else { - // error details are already in logcat - msg.arg1 = R.string.error_apply; - toaster.sendMessage(msg); - } - } - })); - /* if (applyProfileRules(context, msg, toaster)) { - disableDefault(); - }*/ - break; - case 4: - G.setProfile(true, "AFWallProfile1"); - Api.applySavedIptablesRules(context, false, new RootCommand() - .setCallback(new RootCommand.Callback() { - @Override - public void cbFunc(RootCommand state) { - Message msg = new Message(); - if (state.exitCode == 0) { - msg.arg1 = R.string.rules_applied; - toaster.sendMessage(msg); - enableOthers(); - disableCustom("AFWallProfile1"); - } else { - // error details are already in logcat - msg.arg1 = R.string.error_apply; - toaster.sendMessage(msg); - } - } - })); - /*if (applyProfileRules(context, msg, toaster)) { - disableCustom("AFWallProfile1"); - }*/ - break; - case 5: - G.setProfile(true, "AFWallProfile2"); - Api.applySavedIptablesRules(context, false, new RootCommand() - .setCallback(new RootCommand.Callback() { - @Override - public void cbFunc(RootCommand state) { - Message msg = new Message(); - if (state.exitCode == 0) { - msg.arg1 = R.string.rules_applied; - toaster.sendMessage(msg); - enableOthers(); - disableCustom("AFWallProfile2"); - } else { - // error details are already in logcat - msg.arg1 = R.string.error_apply; - toaster.sendMessage(msg); - } - } - })); - /*if (applyProfileRules(context, msg, toaster)) { - disableCustom("AFWallProfile2"); - }*/ - break; - case 6: - G.setProfile(true, "AFWallProfile3"); - Api.applySavedIptablesRules(context, false, new RootCommand() - .setCallback(new RootCommand.Callback() { - @Override - public void cbFunc(RootCommand state) { - Message msg = new Message(); - if (state.exitCode == 0) { - msg.arg1 = R.string.rules_applied; - toaster.sendMessage(msg); - enableOthers(); - disableCustom("AFWallProfile3"); - } else { - // error details are already in logcat - msg.arg1 = R.string.error_apply; - toaster.sendMessage(msg); - } - } - })); - /* if (applyProfileRules(context, msg, toaster)) { - disableCustom("AFWallProfile3"); - }*/ - break; - } - //Api.showNotification(Api.isEnabled(getApplicationContext()), getApplicationContext()); - Api.updateNotification(Api.isEnabled(getApplicationContext()), getApplicationContext()); + private void continueClickAfterConfirmation() { + security.passCheck(allowed -> { + if (allowed) { + switchAction(); + } else { + finish(); } - }.start(); + }); } private void enableOthers() { @@ -457,28 +224,18 @@ public void run() { }); } - private void disableCustom(final String code) { + /** + * @param identifier identifier of the active profile; its button is disabled + */ + private void disableCustom(final String identifier) { + ProfileData data = ProfileHelper.getProfileByIdentifier(identifier); + final String name = data != null ? data.getName() : null; runOnUiThread(new Runnable() { public void run() { - switch (code) { - case "AFWallProfile1": - defaultButton.setEnabled(true); - profButton1.setEnabled(false); - profButton2.setEnabled(true); - profButton3.setEnabled(true); - break; - case "AFWallProfile2": - defaultButton.setEnabled(true); - profButton1.setEnabled(true); - profButton2.setEnabled(false); - profButton3.setEnabled(true); - break; - case "AFWallProfile3": - defaultButton.setEnabled(true); - profButton1.setEnabled(true); - profButton2.setEnabled(true); - profButton3.setEnabled(false); - } + defaultButton.setEnabled(true); + profButton1.setEnabled(!profButton1.getText().toString().equals(name)); + profButton2.setEnabled(!profButton2.getText().toString().equals(name)); + profButton3.setEnabled(!profButton3.getText().toString().equals(name)); } }); } diff --git a/app/src/main/java/dev/ukanth/ufirewall/widget/WidgetActionActivity.java b/app/src/main/java/dev/ukanth/ufirewall/widget/WidgetActionActivity.java new file mode 100644 index 000000000..cdc35cdbf --- /dev/null +++ b/app/src/main/java/dev/ukanth/ufirewall/widget/WidgetActionActivity.java @@ -0,0 +1,188 @@ +package dev.ukanth.ufirewall.widget; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +import com.afollestad.materialdialogs.MaterialDialog; + +import java.util.ArrayList; +import java.util.List; + +import dev.ukanth.ufirewall.Api; +import dev.ukanth.ufirewall.R; +import dev.ukanth.ufirewall.profiles.ProfileData; +import dev.ukanth.ufirewall.profiles.ProfileHelper; +import dev.ukanth.ufirewall.util.FirewallActions; +import dev.ukanth.ufirewall.util.G; +import dev.ukanth.ufirewall.util.Notifications; +import dev.ukanth.ufirewall.util.SecurityUtil; + +/** + * Firewall actions started outside the app (status widget, quick settings tile, notification + * actions), after the same checks as in the app: the "confirm before disabling" setting and the + * app lock. Shows nothing unless there is something to ask. + *

    + *
  • toggle: enable / disable the firewall
  • + *
  • profile: pick a profile to switch to
  • + *
  • app: allow or block a (newly installed) app
  • + *
+ * Not exported: only our own PendingIntents can start it (the toggle used to be an exported + * broadcast that any app could send). + */ +public class WidgetActionActivity extends Activity { + + public static final String EXTRA_ACTION = "dev.ukanth.ufirewall.widget.ACTION"; + public static final String ACTION_TOGGLE = "toggle"; + public static final String ACTION_PROFILE = "profile"; + public static final String ACTION_APP = "app"; + private static final String EXTRA_UID = "uid"; + private static final String EXTRA_PACKAGE = "package"; + private static final String EXTRA_LABEL = "label"; + private static final String EXTRA_ALLOW = "allow"; + + private SecurityUtil security; + + public static Intent toggleIntent(Context ctx) { + return base(ctx, ACTION_TOGGLE); + } + + public static Intent profileIntent(Context ctx) { + return base(ctx, ACTION_PROFILE); + } + + public static Intent appRuleIntent(Context ctx, int uid, String pkg, String label, boolean allow) { + return base(ctx, ACTION_APP) + .putExtra(EXTRA_UID, uid) + .putExtra(EXTRA_PACKAGE, pkg) + .putExtra(EXTRA_LABEL, label) + .putExtra(EXTRA_ALLOW, allow); + } + + private static Intent base(Context ctx, String action) { + return new Intent(ctx, WidgetActionActivity.class) + .putExtra(EXTRA_ACTION, action) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_NO_ANIMATION + | Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (savedInstanceState != null) { + // recreated (e.g. rotated) while asking: the prompt is gone, let the user tap again + finish(); + return; + } + security = new SecurityUtil(this); + String action = getIntent().getStringExtra(EXTRA_ACTION); + if (ACTION_PROFILE.equals(action)) { + pickProfile(); + } else if (ACTION_APP.equals(action)) { + final Intent in = getIntent(); + checkLockThenRun(() -> setAppAccess(in.getIntExtra(EXTRA_UID, -1), in.getStringExtra(EXTRA_PACKAGE), + in.getStringExtra(EXTRA_LABEL), in.getBooleanExtra(EXTRA_ALLOW, false))); + } else { + toggle(); + } + } + + // ---- enable / disable ---- + + private void toggle() { + final boolean enable = !Api.isEnabled(this); + if (!enable && G.enableConfirm()) { + new MaterialDialog.Builder(this) + .title(R.string.confirmMsg) + .cancelable(false) + .positiveText(R.string.Yes) + .negativeText(R.string.No) + .onPositive((dialog, which) -> { + dialog.dismiss(); + checkLockThenRun(() -> setEnabled(false)); + }) + .onNegative((dialog, which) -> { + dialog.dismiss(); + finish(); + }) + .show(); + } else { + checkLockThenRun(() -> setEnabled(enable)); + } + } + + private void setEnabled(boolean enable) { + final Context app = getApplicationContext(); + StatusWidget.showPending(app, enable); + FirewallActions.setEnabled(app, enable, true, ok -> StatusWidget.showResult(app, ok)); + } + + // ---- profile switch ---- + + private void pickProfile() { + final List names = new ArrayList<>(); + final List ids = new ArrayList<>(); + names.add(ProfileHelper.displayName(this, Api.DEFAULT_PREFS_NAME)); + ids.add(Api.DEFAULT_PREFS_NAME); + for (ProfileData data : ProfileHelper.getProfiles()) { + names.add(data.getName()); + ids.add(data.getIdentifier()); + } + // mark the active profile + int current = ids.indexOf(G.storedProfile()); + if (current >= 0) { + names.set(current, names.get(current) + " ✓"); + } + new MaterialDialog.Builder(this) + .title(R.string.notif_action_profile) + .items(names) + .itemsCallback((dialog, view, which, text) -> { + final String identifier = ids.get(which); + checkLockThenRun(() -> { + FirewallActions.switchProfile(getApplicationContext(), identifier, true, null); + Notifications.refreshStatus(getApplicationContext()); + }); + }) + .negativeText(R.string.Cancel) + .onNegative((dialog, which) -> finish()) + .cancelListener(dialog -> finish()) + .show(); + } + + // ---- allow / block an app ---- + + private void setAppAccess(int uid, String pkg, String label, boolean allow) { + if (uid < 0) { + return; + } + Context app = getApplicationContext(); + FirewallActions.setAppAccess(app, uid, allow); + Notifications.cancelNewApp(app, uid, pkg); + Api.toast(app, getString(allow ? R.string.notif_app_allowed : R.string.notif_app_blocked, + label != null ? label : pkg)); + } + + private void checkLockThenRun(Runnable action) { + security.passCheck(allowed -> { + if (allowed) { + action.run(); + } + finish(); + }); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (security != null) { + security.handleActivityResult(requestCode, resultCode); + } + } + + @Override + public void finish() { + super.finish(); + overridePendingTransition(0, 0); + } +} diff --git a/app/src/main/res/anim/widget_pulse.xml b/app/src/main/res/anim/widget_pulse.xml new file mode 100644 index 000000000..9a3d2654e --- /dev/null +++ b/app/src/main/res/anim/widget_pulse.xml @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/widget_scale_in.xml b/app/src/main/res/anim/widget_scale_in.xml new file mode 100644 index 000000000..4a1a6df8b --- /dev/null +++ b/app/src/main/res/anim/widget_scale_in.xml @@ -0,0 +1,19 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/card_border.xml b/app/src/main/res/drawable/card_border.xml new file mode 100644 index 000000000..7581dae0b --- /dev/null +++ b/app/src/main/res/drawable/card_border.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circle_background.xml b/app/src/main/res/drawable/circle_background.xml new file mode 100644 index 000000000..3953e54ba --- /dev/null +++ b/app/src/main/res/drawable/circle_background.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_block_black_24dp.xml b/app/src/main/res/drawable/ic_block_black_24dp.xml index 6119526db..2cc82ad43 100644 --- a/app/src/main/res/drawable/ic_block_black_24dp.xml +++ b/app/src/main/res/drawable/ic_block_black_24dp.xml @@ -1,9 +1,9 @@ - + + android:pathData="M10,1a9,9 0,1 0,9 9a9,9 0,0 0,-9 -9m5,10H5V9h10z" + android:fillColor="#FFFFFF"/> diff --git a/app/src/main/res/drawable/ic_notifications_off_black_24dp.xml b/app/src/main/res/drawable/ic_notifications_off_black_24dp.xml new file mode 100644 index 000000000..a7916a849 --- /dev/null +++ b/app/src/main/res/drawable/ic_notifications_off_black_24dp.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_notifications_on_black_24dp.xml b/app/src/main/res/drawable/ic_notifications_on_black_24dp.xml new file mode 100644 index 000000000..838c288fc --- /dev/null +++ b/app/src/main/res/drawable/ic_notifications_on_black_24dp.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_open_in_new_black_24dp.xml b/app/src/main/res/drawable/ic_open_in_new_black_24dp.xml new file mode 100644 index 000000000..ecadb900e --- /dev/null +++ b/app/src/main/res/drawable/ic_open_in_new_black_24dp.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_special_any.xml b/app/src/main/res/drawable/ic_special_any.xml new file mode 100644 index 000000000..8796a7986 --- /dev/null +++ b/app/src/main/res/drawable/ic_special_any.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_special_code.xml b/app/src/main/res/drawable/ic_special_code.xml new file mode 100644 index 000000000..281d02f97 --- /dev/null +++ b/app/src/main/res/drawable/ic_special_code.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_special_kernel.xml b/app/src/main/res/drawable/ic_special_kernel.xml new file mode 100644 index 000000000..7922b10d9 --- /dev/null +++ b/app/src/main/res/drawable/ic_special_kernel.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_special_location.xml b/app/src/main/res/drawable/ic_special_location.xml new file mode 100644 index 000000000..8da8f2966 --- /dev/null +++ b/app/src/main/res/drawable/ic_special_location.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_special_lock.xml b/app/src/main/res/drawable/ic_special_lock.xml new file mode 100644 index 000000000..9ed120533 --- /dev/null +++ b/app/src/main/res/drawable/ic_special_lock.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_special_media.xml b/app/src/main/res/drawable/ic_special_media.xml new file mode 100644 index 000000000..b391aaf55 --- /dev/null +++ b/app/src/main/res/drawable/ic_special_media.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_special_service.xml b/app/src/main/res/drawable/ic_special_service.xml new file mode 100644 index 000000000..ac0c7999c --- /dev/null +++ b/app/src/main/res/drawable/ic_special_service.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_special_time.xml b/app/src/main/res/drawable/ic_special_time.xml new file mode 100644 index 000000000..f05bb9eda --- /dev/null +++ b/app/src/main/res/drawable/ic_special_time.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/text_background_rounded.xml b/app/src/main/res/drawable/text_background_rounded.xml new file mode 100644 index 000000000..7581dae0b --- /dev/null +++ b/app/src/main/res/drawable/text_background_rounded.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/widget_disabling.xml b/app/src/main/res/drawable/widget_disabling.xml new file mode 100644 index 000000000..667d464de --- /dev/null +++ b/app/src/main/res/drawable/widget_disabling.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/widget_enabling.xml b/app/src/main/res/drawable/widget_enabling.xml new file mode 100644 index 000000000..ed4d41c40 --- /dev/null +++ b/app/src/main/res/drawable/widget_enabling.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/widget_error.xml b/app/src/main/res/drawable/widget_error.xml new file mode 100644 index 000000000..40f28f319 --- /dev/null +++ b/app/src/main/res/drawable/widget_error.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/widget_success.xml b/app/src/main/res/drawable/widget_success.xml new file mode 100644 index 000000000..b7a91d222 --- /dev/null +++ b/app/src/main/res/drawable/widget_success.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/app_detail.xml b/app/src/main/res/layout/app_detail.xml index 383e93c11..fea21d328 100644 --- a/app/src/main/res/layout/app_detail.xml +++ b/app/src/main/res/layout/app_detail.xml @@ -3,7 +3,8 @@ android:layout_width="fill_parent" android:layout_height="fill_parent" android:background="?attr/itemBackground" - android:orientation="vertical"> + android:orientation="vertical" + android:fitsSystemWindows="true"> + android:layout_gravity="center_vertical" + android:scaleType="fitCenter" + android:background="?android:attr/selectableItemBackground" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + android:layout_marginEnd="8dp" + android:text="@android:string/cancel" + android:background="?android:attr/selectableItemBackground" + android:textColor="?android:attr/textColorSecondary" + style="?android:attr/buttonBarButtonStyle" /> + android:text="@android:string/ok" + android:backgroundTint="?attr/colorPrimary" + android:textColor="@android:color/white" + style="?android:attr/buttonBarButtonStyle" /> + diff --git a/app/src/main/res/layout/help_about.xml b/app/src/main/res/layout/help_about.xml index 51e8f9bb0..5b88b4ca6 100644 --- a/app/src/main/res/layout/help_about.xml +++ b/app/src/main/res/layout/help_about.xml @@ -1,9 +1,8 @@ - @@ -14,16 +13,552 @@ android:background="?attr/colorPrimary" android:minHeight="?attr/actionBarSize" /> - + android:layout_height="match_parent" + android:fillViewport="true"> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/help_about_content.xml b/app/src/main/res/layout/help_about_content.xml deleted file mode 100644 index f8503310e..000000000 --- a/app/src/main/res/layout/help_about_content.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/help_faq_content.xml b/app/src/main/res/layout/help_faq_content.xml deleted file mode 100644 index fa1b10da2..000000000 --- a/app/src/main/res/layout/help_faq_content.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/help_legend_section.xml b/app/src/main/res/layout/help_legend_section.xml new file mode 100644 index 000000000..38835d283 --- /dev/null +++ b/app/src/main/res/layout/help_legend_section.xml @@ -0,0 +1,1209 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/legend.xml b/app/src/main/res/layout/legend.xml deleted file mode 100644 index 26e237a81..000000000 --- a/app/src/main/res/layout/legend.xml +++ /dev/null @@ -1,298 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/log_detail_summary.xml b/app/src/main/res/layout/log_detail_summary.xml new file mode 100644 index 000000000..c072cad2d --- /dev/null +++ b/app/src/main/res/layout/log_detail_summary.xml @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/logdetail_recycle_item.xml b/app/src/main/res/layout/logdetail_recycle_item.xml index 27cef7836..6b1ae1dd2 100644 --- a/app/src/main/res/layout/logdetail_recycle_item.xml +++ b/app/src/main/res/layout/logdetail_recycle_item.xml @@ -1,75 +1,199 @@ + android:layout_marginBottom="2dp" + android:layout_marginLeft="8dp" + android:layout_marginRight="8dp" + android:layout_marginTop="4dp" + card_view:cardCornerRadius="8dp" + card_view:cardElevation="3dp" + card_view:cardUseCompatPadding="true"> - - - - + + + + + + + + + - - + + android:orientation="horizontal" + android:gravity="center_vertical"> - + - + + + - + + android:orientation="vertical" + android:layout_marginTop="8dp"> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/logdetail_view.xml b/app/src/main/res/layout/logdetail_view.xml index dd1f2b7da..6da00f76a 100644 --- a/app/src/main/res/layout/logdetail_view.xml +++ b/app/src/main/res/layout/logdetail_view.xml @@ -6,7 +6,8 @@ android:background="?attr/itemBackground" android:duplicateParentState="false" - android:orientation="vertical"> + android:orientation="vertical" + android:fitsSystemWindows="true"> - + + + + + android:layout_height="0dp" + android:layout_weight="1"> + + + \ No newline at end of file diff --git a/app/src/main/res/layout/main.xml b/app/src/main/res/layout/main.xml index a682eb13d..d3b361116 100644 --- a/app/src/main/res/layout/main.xml +++ b/app/src/main/res/layout/main.xml @@ -4,7 +4,7 @@ android:id="@+id/mainlayout" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="@color/primary_dark" + android:background="?attr/colorPrimaryDark" android:fitsSystemWindows="true"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/main_list_old.xml b/app/src/main/res/layout/main_list_old.xml index 05ed5f373..e92b0365e 100644 --- a/app/src/main/res/layout/main_list_old.xml +++ b/app/src/main/res/layout/main_list_old.xml @@ -2,79 +2,194 @@ - - - - - - - - - - - - - - - - - - + android:layout_height="wrap_content" + android:orientation="vertical"> + - - + + - - + + - + + + + + + + + + + + + + + + + + + + + + + + - + android:orientation="vertical" + android:padding="12dp" + android:background="?android:attr/selectableItemBackground" + android:visibility="gone"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/main_old.xml b/app/src/main/res/layout/main_old.xml index 4ca62289a..ac55a232b 100644 --- a/app/src/main/res/layout/main_old.xml +++ b/app/src/main/res/layout/main_old.xml @@ -4,7 +4,7 @@ android:id="@+id/mainlayout" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="@color/primary_dark" + android:background="?attr/colorPrimaryDark" android:fitsSystemWindows="true"> + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/profile_main.xml b/app/src/main/res/layout/profile_main.xml index 484e4819d..dded2a5ac 100644 --- a/app/src/main/res/layout/profile_main.xml +++ b/app/src/main/res/layout/profile_main.xml @@ -4,7 +4,8 @@ android:layout_height="match_parent" android:duplicateParentState="false" android:background="?attr/itemBackground" - android:orientation="vertical"> + android:orientation="vertical" + android:fitsSystemWindows="true"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_bar.xml b/app/src/main/res/menu/menu_bar.xml index 48962abd9..50fbff518 100644 --- a/app/src/main/res/menu/menu_bar.xml +++ b/app/src/main/res/menu/menu_bar.xml @@ -108,11 +108,6 @@ android:icon="@drawable/ic_import" android:title="@string/imports" app:showAsAction="always" /> - - Q: Why does my entire network seem to be blocked after I enabled the experimental 'Fix Startup Data Leak' option? + Q: Why does my entire network seem to be blocked after I enabled the 'Fix Startup Data Leak' option?
- A: Please uncheck that option (this will remove the init.d file automatically).
- It is under Preferences - Experimental preferences - 'Fix startup data leak'.
+ A: Please uncheck that option (this will remove the boot script automatically).
+ It is under Preferences - Rules/Connectivity - Boot - 'Fix startup data leak'.
In case you uninstalled AFWall+ and copied the script manually, ensure it is removed: the file is called 'afwallstart'.
diff --git a/app/src/main/res/raw/ip6tables_arm b/app/src/main/res/raw/ip6tables_arm old mode 100644 new mode 100755 index 0b1bfe25c..d2022bfe8 Binary files a/app/src/main/res/raw/ip6tables_arm and b/app/src/main/res/raw/ip6tables_arm differ diff --git a/app/src/main/res/raw/ip6tables_arm64 b/app/src/main/res/raw/ip6tables_arm64 new file mode 100755 index 000000000..54176cb02 Binary files /dev/null and b/app/src/main/res/raw/ip6tables_arm64 differ diff --git a/app/src/main/res/raw/ip6tables_mips b/app/src/main/res/raw/ip6tables_mips deleted file mode 100644 index 04b778823..000000000 Binary files a/app/src/main/res/raw/ip6tables_mips and /dev/null differ diff --git a/app/src/main/res/raw/ip6tables_x86 b/app/src/main/res/raw/ip6tables_x86 old mode 100644 new mode 100755 index e834857dc..40bf77bdf Binary files a/app/src/main/res/raw/ip6tables_x86 and b/app/src/main/res/raw/ip6tables_x86 differ diff --git a/app/src/main/res/raw/iptables_arm b/app/src/main/res/raw/iptables_arm old mode 100644 new mode 100755 index 0b1bfe25c..d2022bfe8 Binary files a/app/src/main/res/raw/iptables_arm and b/app/src/main/res/raw/iptables_arm differ diff --git a/app/src/main/res/raw/iptables_arm64 b/app/src/main/res/raw/iptables_arm64 new file mode 100755 index 000000000..54176cb02 Binary files /dev/null and b/app/src/main/res/raw/iptables_arm64 differ diff --git a/app/src/main/res/raw/iptables_mips b/app/src/main/res/raw/iptables_mips deleted file mode 100644 index 04b778823..000000000 Binary files a/app/src/main/res/raw/iptables_mips and /dev/null differ diff --git a/app/src/main/res/raw/iptables_x86 b/app/src/main/res/raw/iptables_x86 old mode 100644 new mode 100755 index e834857dc..40bf77bdf Binary files a/app/src/main/res/raw/iptables_x86 and b/app/src/main/res/raw/iptables_x86 differ diff --git a/app/src/main/res/raw/nflog_arm b/app/src/main/res/raw/nflog_arm old mode 100644 new mode 100755 index 6e11b6ef8..5a13bacc0 Binary files a/app/src/main/res/raw/nflog_arm and b/app/src/main/res/raw/nflog_arm differ diff --git a/app/src/main/res/raw/nflog_arm64 b/app/src/main/res/raw/nflog_arm64 index 28be7dec7..3524bc8db 100755 Binary files a/app/src/main/res/raw/nflog_arm64 and b/app/src/main/res/raw/nflog_arm64 differ diff --git a/app/src/main/res/raw/nflog_mips b/app/src/main/res/raw/nflog_mips deleted file mode 100644 index b0eaae197..000000000 Binary files a/app/src/main/res/raw/nflog_mips and /dev/null differ diff --git a/app/src/main/res/raw/nflog_x86 b/app/src/main/res/raw/nflog_x86 old mode 100644 new mode 100755 index ffef8212b..63ac3915f Binary files a/app/src/main/res/raw/nflog_x86 and b/app/src/main/res/raw/nflog_x86 differ diff --git a/app/src/main/res/values-af/strings.xml b/app/src/main/res/values-af/strings.xml index dc2103fdb..6302d02b0 100644 --- a/app/src/main/res/values-af/strings.xml +++ b/app/src/main/res/values-af/strings.xml @@ -123,9 +123,6 @@ Refresh Multiple Profile Preferences Custom name for Default Profile - Custom name for Profile1 - Custom name for Profile2 - Custom name for Profile3 Enable Firewall Disable Firewall Activate Default Profile diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 7ec006447..f7518d07b 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -167,9 +167,6 @@ الأوضاع اسم مخصص للوضع الإفتراضي - اسم مخصص للوضع 1 - اسم مخصص للوضع 2 - اسم مخصص للوضع 3 تفعيل جدار الحماية تعطيل جدار الحماية تنشيط الوضع الإفتراضي @@ -182,7 +179,6 @@ ملف التعريف 2 ملف التعريف 3 اسم الملف الشخصي - الرجاء ترحيل بيانات الملف الشخصي لاستخدام هذا الخيار الملف الشخصي موجود بالفعل! إعادة تطبيق قواعد الملف الشخصي @@ -386,8 +382,6 @@ إدارة الأوضاع إضافة أو إزالة ملفات تعريف ملف التعريف - ترحيل الأوضاع - ترحيل كافة البيانات الشخصية اسم الملف الشخصي إضافة تمكين اظهار الاشعار على المانع @@ -428,8 +422,6 @@ (mdns) DNS متعدد تمكين DNS متعدد غير قادر على بدء خدمة السجل. LogTarget فارغ أو لم يتم تمكين خدمة السجل. - غير قادر على حذف الملف الشخصي - ترحيل الملفات الشخصية عنوان الوجهة عنوان المصدر @@ -477,8 +469,6 @@ تكبير خارج غير قادر على بدء النشاط بسبب تفضيلات معطوبة، يرجى مسح بيانات التطبيق AFWall+ تم اكتشاف اتصال IPv6. الرجاء التمكين من تفضيلات-القواعد/الاتصال وإعادة التطبيق - تأخير الثواني المخصصة - انتظار AFWall+ لثوان معينة بعد بدء التشغيل قبل تطبيق القواعد تعيين التأخير بالثواني مهلة Ping أولوية الإشعار diff --git a/app/src/main/res/values-ast-rES/strings.xml b/app/src/main/res/values-ast-rES/strings.xml index 1e1fc1557..7297e1dc0 100644 --- a/app/src/main/res/values-ast-rES/strings.xml +++ b/app/src/main/res/values-ast-rES/strings.xml @@ -167,9 +167,6 @@ Perfiles Nome personalizáu pal perfil por defeutu - Nome personalizáu pa perfil1 - Nome personalizáu pa perfil2 - Nome personalizáu pa perfil3 HabilItar tornafuéu DeshabilItar tornafuéu Activar perfil predetermináu @@ -182,7 +179,6 @@ Perfil 2 Perfil 3 ProfileName - Please migrate profile data to use this option ¡El perfil yá esiste! Reaplicar regles de perfiles @@ -386,8 +382,6 @@ Xestionar perfiles Amiesta o desanicia perfiles Perfil - Migrar perfiles - Migrate all profile data Nome de perfil Amestar Amuesa avisos burbuya al bloquiar @@ -428,8 +422,6 @@ (mdns) Multicast DNS Enable Multicast DNS Nun ye posible aniciar el serviciu de rexistru. LogTarget ta baleru o LogService nun ta activáu. - Nun ye posible desaniciar el perfil - Profiles migrated Direición de destín Direición d\'orixe @@ -477,8 +469,6 @@ Zoom Out Nun ye posible aniciar l\'actividá pola mor d\'una preferencia toyida. Llimpia los datos de l\'aplicación AFWall+ detected IPv6 connection. Please enable from Preference-Rules/Connectivity and Reapply - Custom Seconds Delay - AFWall+ waits for given seconds after startup before applying rules Set delay in seconds Ping timeout Notification Priority diff --git a/app/src/main/res/values-az/strings.xml b/app/src/main/res/values-az/strings.xml index b53801b11..1497b9948 100644 --- a/app/src/main/res/values-az/strings.xml +++ b/app/src/main/res/values-az/strings.xml @@ -167,9 +167,6 @@ Profil İlkin Profil üçün xüsusi ad - Profil 1 üçün xüsusi ad - Profil 2 üçün xüsusi ad - Profil 3 üçün xüsusi ad Enable firewall Disable firewall İlkin profil aktivdir @@ -182,7 +179,6 @@ Profil 2 Profil 3 Profil adı - Please migrate profile data to use this option Profil artıq mövcuddur! Profil qaydalarını yenidən tətbiq et @@ -386,8 +382,6 @@ Profilləri idarə et Profilləri əlavə et və ya çıxart Profil - Profilləri köçür - Bütün profil verilənlərini köçür Profil adı Əlavə et Show toast notification on block @@ -428,8 +422,6 @@ (mdns) Multicast DNS Enable Multicast DNS Unable to start log service. LogTarget is empty or LogService is not enabled. - Unable to delete profile - Profiles migrated Destination Address Source Address @@ -477,8 +469,6 @@ Uzaqlaşdır Unable to start the activity due to corrupted preference, Please clear the application data AFWall+ detected IPv6 connection. Please enable from Preference-Rules/Connectivity and Reapply - Custom Seconds Delay - AFWall+ waits for given seconds after startup before applying rules Set delay in seconds Ping timeout Bildiriş Üstünlüyü diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index ddba86d6c..e580b493a 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -167,9 +167,6 @@ Профили Потребителско име за профил по подразбиране - Потребителско име за профил 1 - Потребителско име за профила 2 - Потребителско име за профила 3 Разреши Защитната стена Забрани Защитната стена Активиране на профила по подразбиране @@ -182,7 +179,6 @@ Профил 2 Профил 3 Име на профил - Моля мигрирайте данни за профила, за да използвате тази опция Профилът вече съществува! Приложи наново профилни правила @@ -386,8 +382,6 @@ Управление на профили Добавяне или премахване на профили Профил - Мигриране на профили - Мигриране на всички данни за профила Име на профил Добави Покажи тост уведомление при блокиране @@ -428,8 +422,6 @@ (mdns) Multicast DNS Активиране на Multicast DNS Не може да се стартира журналния сървис. LogTarget е празен или LogService не е разрешен. - Не може да изтрие профил - Профилите са пренесени Адрес на дестинация Изходен адрес @@ -477,8 +469,6 @@ Отдалечаване Не може да започне дейността поради повредени предпочитания, моля изчистете данни на приложението AFWall + откри IPv6 връзка. Моля, активирайте от предпочитания->правила/свързаност и потвърдете наново - Персонално закъснение в секунди - След стартиране AFWall+ изчаква зададените секунди преди да приложи правилата Задайте забавяне в секунди Време за изчакване на Ping Приоритет на известие diff --git a/app/src/main/res/values-bi/strings.xml b/app/src/main/res/values-bi/strings.xml index cd871ef48..c4d008769 100644 --- a/app/src/main/res/values-bi/strings.xml +++ b/app/src/main/res/values-bi/strings.xml @@ -167,9 +167,6 @@ Profiles Custom name for Default Profile - Custom name for Profile 1 - Custom name for Profile 2 - Custom name for Profile 3 Enable firewall Disable firewall Activate default profile @@ -182,7 +179,6 @@ Profile 2 Profile 3 ProfileName - Please migrate profile data to use this option Profile exists already! Reapply profile rules @@ -386,8 +382,6 @@ Manage profiles Add or remove profiles Profile - Migrate profiles - Migrate all profile data Profile name Add Show toast notification on block @@ -428,8 +422,6 @@ (mdns) Multicast DNS Enable Multicast DNS Unable to start log service. LogTarget is empty or LogService is not enabled. - Unable to delete profile - Profiles migrated Destination Address Source Address @@ -477,8 +469,6 @@ Zoom Out Unable to start the activity due to corrupted preference, Please clear the application data AFWall+ detected IPv6 connection. Please enable from Preference-Rules/Connectivity and Reapply - Custom Seconds Delay - AFWall+ waits for given seconds after startup before applying rules Set delay in seconds Ping timeout Notification Priority diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index bdddf8660..24902d9e0 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -167,9 +167,6 @@ প্রোফাইল কাস্টম নাম ডিফল্ট প্রোফাইলের জন্য - কাস্টম নাম প্রোফাইলের ১ জন্য - প্রোফাইলের ২ এর জন্য কাস্টম নাম - প্রোফাইলের ৩ এর জন্য কাস্টম নাম Firewall চালু করুন Firewall বন্ধ করুন ডিফল্ট প্রোফাইল সক্রিয় করুন @@ -182,7 +179,6 @@ প্রোফাইল ২ প্রোফাইল ৩ প্রোফাইল এর নাম - প্রোফাইল তথ্য আপনি এই অপশন ব্যবহার করতে অনুগ্রহ করে স্থানান্তর Profile exists already! প্রোফাইল নিয়ম পুনরায় সক্রিয় করুন @@ -386,8 +382,6 @@ প্রোফাইল ব্যবস্থাপনা গোষ্ঠীগুলি যোগ অথবা অপসারণ করুন প্রোফাইল - প্রোফাইল মাইগ্রেট - সব প্রোফাইল ডেটা স্থানান্তর Profile নাম যুক্ত করুন Show toast notification on block @@ -428,8 +422,6 @@ (mdns) Multicast DNS Enable Multicast DNS লগ সেবা শুরু করতে অক্ষম। LogTarget খালি থাকে বা LogService সক্রিয় করা হয় না। - প্রোফাইল মুছে ফেলা যায়নি - Profiles migrated গন্তব্য ঠিকানা প্রদর্শন করা হবে উৎস ঠিকানা প্রদর্শন করা হবে @@ -477,8 +469,6 @@ জুম আউট ক্রিয়াকলাপের জন্য বিকৃত অগ্রাধিকার শুরু করতে অক্ষম, অনুগ্রহ করে পরিষ্কার প্রয়োগণ উপাত্ত AFWall + IPv6 সংযোগ শনাক্ত করা হয়েছে। দয়া করে অগ্রাধিকার-আইন/সংযোগ ও হয় আবার স্প্রে করুন - কাস্টম সেকেন্ড দেরি - এফওয়াল + নিয়ম প্রয়োগ করার আগে স্টার্টআপের পরে দেওয়া সেকেন্ডের জন্য অপেক্ষা করে সেকেন্ডের মধ্যে বিলম্ব সেট করুন পিং টাইমআউট বিজ্ঞপ্তি অগ্রাধিকার diff --git a/app/src/main/res/values-bs/strings.xml b/app/src/main/res/values-bs/strings.xml index c3e95369f..6adf63ede 100644 --- a/app/src/main/res/values-bs/strings.xml +++ b/app/src/main/res/values-bs/strings.xml @@ -167,9 +167,6 @@ Profili Lični naziv za Zadani Profil - Lični naziv za Profil 1 - Lični naziv za Profil 2 - Lični naziv za Profil 3 Uključi vatrozid Isključi vatrozid Uključi zadani profil @@ -182,7 +179,6 @@ Profil 2 Profil 3 NazivProfila - Molimo da premjestite podatke profila da bi koristili ovu opciju Profil već postoji! Ponovo primjeni pravila profila @@ -386,8 +382,6 @@ Upravljanje profilima Dodaj ili ukloni profile Profil - Premještanje profila - Premjesti sve podatke profila Naziv profila Dodaj Prikaži iskočnu obavijest prilikom blokiranja @@ -428,8 +422,6 @@ (mdns) Multicast DNS Uključi Multicast DNS Nije moguće pokrenuti servis dnevnika. LogTarget je prazan ili LogService nije uključen. - Nije moguće obrisati profil - Profili su preseljeni Adresa odredišta Adresa izvora @@ -477,8 +469,6 @@ Smanji Nije moguće pokrenuti aktivnost zbog oštećenih postavki. Molimo očistite podatke aplikacije AFWall+ je detektovao IPv6 konekciju. Molimo omogućite u Postavkama-Pravila/Veza i ponovo primjenite - Zadana odgoda u sekundama - AFWall+ će sačekati zadani broj sekundi nakon pokretanja za primjenu pravila Postavljanje odgode u sekundama Vremensko ograničenje za ping Prioritet obavijesti diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 97e5648a4..ab2d236a7 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -167,9 +167,6 @@ Perfils Nom personalitzat per al perfil Predeterminat - Custom name for Profile 1 - Custom name for Profile 2 - Custom name for Profile 3 Enable firewall Disable firewall Activate default profile @@ -182,7 +179,6 @@ Perfil 2 Perfil 3 ProfileName - Please migrate profile data to use this option Profile exists already! Reapply profile rules @@ -386,8 +382,6 @@ Manage profiles Add or remove profiles Profile - Migrate profiles - Migrate all profile data Profile name Afegeix Show toast notification on block @@ -428,8 +422,6 @@ (mdns) Multicast DNS Enable Multicast DNS Unable to start log service. LogTarget is empty or LogService is not enabled. - Unable to delete profile - Profiles migrated Destination Address Source Address @@ -477,8 +469,6 @@ Zoom Out Unable to start the activity due to corrupted preference, Please clear the application data AFWall+ detected IPv6 connection. Please enable from Preference-Rules/Connectivity and Reapply - Custom Seconds Delay - AFWall+ waits for given seconds after startup before applying rules Set delay in seconds Ping timeout Notification Priority diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index b4c244cb9..1eeb4df1f 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -168,9 +168,6 @@ Profily Vlastní název pro výchozí profil - Vlastní název pro Profil1 - Vlastní název pro Profil 2 - Vlastní název pro Profil 3 Zapnout firewall Vypnout firewall Aktivovat výchozí profil @@ -183,7 +180,6 @@ Profil 2 Profil 3 Název_profilu - Pro tuto možnost prosím migrujte profilové data Profil již existuje! Znova aplikovat pravidla profilu @@ -387,8 +383,6 @@ Správa profilů Přidat nebo odebrat profily Profil - Přenést profily - Migrovat všechna data profilu Název profilu Přidat Zobrazit vyskakovací oznámení v bloku @@ -429,8 +423,6 @@ (mdns) Multicast DNS Enable Multicast DNS Nelze spustit službu protokolů. LogTarget je prázdný nebo LogService není povolen. - Nelze odstranit profil - Profily migrovány Cílová adresa Zdrojová adresa @@ -478,8 +470,6 @@ Oddálit Není možné spustit aktivitu z důvodu poškozené preference. Prosím vymažte data aplikace AFWall+ zjistil připojení IPv6. Prosím zapněte ho pomocí Nastavení-Pravidla/Připojení a opětovně aplikujte - Vlastní zpoždění v sekundách - AFWall+ počká zadaný počet sekund po startu před aplikováním pravidel Nastavit dobu prodlení v sekundách Ping timeout Priorita oznámení diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 06e700262..867cb5d73 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -167,9 +167,6 @@ Profiler Brugerdefineret navn for standard profil - Brugerdefineret navn profil 1 - Brugerdefineret navn for profil 2 - Brugerdefineret navn for profil 3 Aktivere firewall Deaktiver firewall Aktivere standardprofil @@ -182,7 +179,6 @@ Profil 2 Profil 3 ProfileName - Skal du overflytte profildata for at bruge denne indstilling Profile exists already! Genanvende profil regler @@ -386,8 +382,6 @@ Administrere profiler Tilføje eller fjerne profiler Profil - Overflytte profiler - Overføre alle profildata Profilnavn Tilføje Vis toast anmeldelse på blok @@ -428,8 +422,6 @@ (mdns) Multicast DNS Enable Multicast DNS Stand til at starte Logtjenesten. LogTarget er tom eller LogService er ikke aktiveret. - Ude af stand til at opdatere profilen - Profiles migrated Destinationsadressen Kildeadresse @@ -477,8 +469,6 @@ Zoom ud Stand til at starte aktivitet på grund af beskadigede præference, venligst rydde application data AFWall + opdaget IPv6-forbindelse. Venligst aktiver fra præference-regler/Connectivity og genanvende - Brugerdefinerede sekunder forsinkelse - AFWall + venter givet sekunder efter start før du anvender regler Sæt forsinkelse i sekunder Ping timeout Anmeldelse prioritet diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index e632c1506..d192802fa 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -167,9 +167,6 @@ Profile Benutzerdefinierter Name für Standardprofil - Benutzerdefinierter Name für Profil 1 - Benutzerdefinierter Name für Profil 2 - Benutzerdefinierter Name für Profil 3 Firewall aktivieren Firewall deaktivieren Standardprofil aktivieren @@ -182,7 +179,6 @@ Profil 2 Profil 3 Profilname - Bitte Datenprofil migrieren, um diese Option verwenden zu können Profil existiert bereits! Profilregeln erneut anwenden @@ -386,8 +382,6 @@ Profile verwalten Profile hinzufügen oder entfernen Profile - Profile migrieren - Alle Profildaten übertragen Profilname Hinzufügen Bei Blockade eine Kurzmeldung einblenden @@ -428,8 +422,6 @@ (mdns) Multicast DNS Mutlicast DNS aktivieren Protokolldienst kann nicht gestartet werden. Entweder LogTarget enthält keine Werte oder LogService ist nicht aktiviert. - Profil kann nicht gelöscht werden - Profile übertragen Zieladresse Herkunftsadresse @@ -477,8 +469,6 @@ Verkleinern Aktivität kann aufgrund fehlerhafter Einstellungen nicht gestartet werden. Bitte Anwendungsdaten löschen. AFWall+ hat eine IPv6-Verbindung erkannt. Bitte unter Einstellungen-Regel/Verbindung IPv6-Unterstützung aktivieren und erneut anwenden - Startverzögerung (in Sekunden) - AFWall+ wartet für die angegebene Zeit nach dem Gerätestart, bevor die Regeln angewendet werden Startverzögerung (in Sekunden) festlegen Ping-Zeitüberschreitung Benachrichtigungspriorität diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 57049266b..1d851450f 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -167,9 +167,6 @@ Προφίλ Προσαρμοσμένο όνομα για το προεπιλεγμένο προφίλ - Προσαρμοσμένο όνομα για το Προφίλ 1 - Προσαρμοσμένο όνομα για το Προφίλ 2 - Προσαρμοσμένο όνομα για το Προφίλ 3 Ενεργοποίηση του τείχους Απενεργοποίηση του τείχους Ενεργοποίηση προεπιλεγμένου προφίλ @@ -182,7 +179,6 @@ Προφίλ 2 Προφίλ 3 Όνομα Προφίλ - Παρακαλώ μετεγκαταστήσετε τα δεδομένα του προφίλ για να χρησιμοποιήσετε αυτήν την επιλογή Το προφίλ υπάρχει ήδη! Επανάληψη εφαρμογής κανόνων προφίλ @@ -386,8 +382,6 @@ Διαχείριση προφίλ Προσθήκη ή κατάργηση προφίλ Προφίλ - Μετεγκατάσταση προφίλ - Μετεγκατάσταση όλων των δεδομένων προφίλ Όνομα προφίλ Προσθήκη Εμφάνιση αναδυόμενων ειδοποιήσεων κατά τον αποκλεισμό @@ -428,8 +422,6 @@ (mdns) Ενεργοποίηση Mutlicast DNS Ενεργοποίηση Mutlicast DNS Δεν είναι δυνατή η εκκίνηση της υπηρεσίας καταγραφής. Η θέση καταγραφής είναι κενή ή δεν είναι ενεργοποιημένη η υπηρεσία καταγραφής. - Δεν είναι δυνατή η διαγραφή του προφίλ - Τα προφίλ μετεγκαταστάθηκαν Διεύθυνση προορισμού Διεύθυνση προέλευσης @@ -480,8 +472,6 @@ Παρακαλούμε ενεργοποιήστε από προτίμηση-κανόνες / συνδεσιμότητα και εφαρμόστε ξανά
- Προσαρμοσμένη καθυστέρηση δευτερολέπτων - AFWall + περιμένει για Χ δευτερόλεπτα μετά την εκκίνηση πριν από την εφαρμογή των κανόνων Ορισμός καθυστέρησης σε δευτερόλεπτα Χρονικό όριο Ping Προτεραιότητα ειδοποιήσεων diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 26c627abe..ce255187c 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -167,9 +167,6 @@ Perfiles Nombre personalizado para el perfil predeterminado - Nombre personalizado para el perfil 1 - Nombre personalizado para el perfil 2 - Nombre personalizado para el perfil 3 Activar cortafuegos Desactivar cortafuegos Activar el perfil predeterminado @@ -182,7 +179,6 @@ Perfil 2 Perfil 3 Nombre del perfil - Por favor migra los datos de perfil para usar esta opción ¡Éste perfil ya existe! Re-Aplicar reglas del perfil @@ -386,8 +382,6 @@ Administrar perfiles Agregar o eliminar perfiles Perfil - Migrar perfiles - Migrar todos los datos de perfil Nombre de perfil Agregar Mostrar notificaciones toast en bloque @@ -428,8 +422,6 @@ (mdns) DNS de Multidifusión Activar DNS de Multidifusión No se puede iniciar el servicio de registro. LogTarget está vacío o LogService no está activado. - No se pudo eliminar el perfil - Profiles migrados Dirección de destino Dirección de origen @@ -477,8 +469,6 @@ Alejar No se pudo iniciar debido a un dato corrupto en las preferencias, Por favor elimina los datos de la aplicación AFWall+ ha detectado una conexión IPv6. Por favor actívela desde Preferencias > Reglas/Conectividad y vuelva a aplicar - Retraso en segundos personalizado - Al iniciar, AFWall+ esperará los segundos especificados antes de aplicar las reglas Establecer retraso en segundos Tiempo de espera de ping Prioridad de notificación diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml index 90aedce3d..637d7939a 100644 --- a/app/src/main/res/values-eu/strings.xml +++ b/app/src/main/res/values-eu/strings.xml @@ -167,9 +167,6 @@ Profilak Lehenetsitako profilaren izen pertsonalizatua - 1 Profilaren izen pertsonalizatua - 2 Profilaren izen pertsonalizatua - 3 Profilaren izen pertsonalizatua Gaitu suebakia Ezgaitu suebakia Aktibatu lehenetsitako profila @@ -182,7 +179,6 @@ 2 profila 3 profila ProfilIzena - Migratu profileko datuak aukera hau erabiltzeko Profil hori dagoeneko existitzen da! Berraplikatu profilaren arauak @@ -386,8 +382,6 @@ Kudeatu profilak Gehitu edo kendu profilak Profila - Migratu profilak - Migratu profileko datu guztiak Profilaren izena Gehitu Erakutsi laster-leiho jakinarazpenak blokean @@ -428,8 +422,6 @@ (mdns) Multicast DNS Gaitu Mutlicast DNS Ezin izan da egunkari zerbitzua hasi. LogTarget hutsik dago edo LogService desgaituta dago. - Ezin izan da profila ezabatu - Profilak migratu dira Helburuaren helbidea Iturriaren helbidea @@ -477,8 +469,6 @@ Txikiagotu zooma Ezin izan da jarduera hasi hondatutako hobespenak direla eta, garbitu aplikazioaren datuak AFWall+ aplikazioak IPv6 konexio bat antzeman du. Gaitu hemen Hobespen-arauak/konektibitatea, eta berriro aplikatu - Atzerapen segundo kopuru aukeratua - AFWall+ aplikazioak emandako segundo kopurua itxarongo du arauak aplikatu aurretik Ezarri atzerapena segundotan Ping-aren denbora-muga Jakinarazpenen lehentasuna diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 2757a0f41..b6bbac84d 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -169,9 +169,6 @@ نمایه‌‌ها نام سفارشی برای نمایه پیش‌فرض - نام سفارشی برای نمایه ۱ - نام سفارشی برای نمایه ۲ - نام سفارشی برای نمایه ۳ فعال‌سازی فایروال غیرفعال‌سازی فایروال فعال‌سازی نمایه پیش‌فرض @@ -184,7 +181,6 @@ نمایه ۲ نمایه ۳ نام نمایه - برای استفاده از این گزینه لطفا پروفایل را منتقل کنید پروفایل از قبل وجود دارد! اجرای دوباره قوانین پروفایل @@ -393,8 +389,6 @@ S-OFF پیکربندی شوند.
مدیریت نمایه‌ها افزودن یا حذف نمایه‌ها نمایه - انتقال نمایه‌ها - انتقال تمام داده‌های نمایه نام نمایه اضافه کردن موقع مسدود کردن اعلان نشان بده @@ -435,8 +429,6 @@ S-OFF پیکربندی شوند.
(mdns) Multicast DNS فعال سازی Multicast DNS شروع سرویس لاگ ناموفق بود. LogTarget خالی است یا LogService فعال نیست. - امکان حذف نمایه وجود ندارد - نمایه‌ها انتقال‌یافتند آدرس مقصد آدرس منبع @@ -484,8 +476,6 @@ S-OFF پیکربندی شوند.
کوچک‌نمایی شروع فعالیت ها به دلیل سلیقه خراب ناموفق بود، لطفا دیتا های برنامه رو پاکسازی کنید AFWall+ detected IPv6 connection. Please enable from Preference-Rules/Connectivity and Reapply - زمان درنگ سفارشی - AFWall+ waits for given seconds after startup before applying rules تنظیم درنگ به ثانیه Ping timeout اولویت اعلان diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index cd871ef48..c4d008769 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -167,9 +167,6 @@ Profiles Custom name for Default Profile - Custom name for Profile 1 - Custom name for Profile 2 - Custom name for Profile 3 Enable firewall Disable firewall Activate default profile @@ -182,7 +179,6 @@ Profile 2 Profile 3 ProfileName - Please migrate profile data to use this option Profile exists already! Reapply profile rules @@ -386,8 +382,6 @@ Manage profiles Add or remove profiles Profile - Migrate profiles - Migrate all profile data Profile name Add Show toast notification on block @@ -428,8 +422,6 @@ (mdns) Multicast DNS Enable Multicast DNS Unable to start log service. LogTarget is empty or LogService is not enabled. - Unable to delete profile - Profiles migrated Destination Address Source Address @@ -477,8 +469,6 @@ Zoom Out Unable to start the activity due to corrupted preference, Please clear the application data AFWall+ detected IPv6 connection. Please enable from Preference-Rules/Connectivity and Reapply - Custom Seconds Delay - AFWall+ waits for given seconds after startup before applying rules Set delay in seconds Ping timeout Notification Priority diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 794327f2a..a68ea23dd 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -167,9 +167,6 @@ Profils Nom personnalisé pour Profil par défaut - Nom personnalisé pour profil1 - Nom personnalisé pour profil2 - Nom personnalisé pour profil3 Activer le pare-feu Désactiver le pare-feu Activer le profil par défaut @@ -182,7 +179,6 @@ Profil 2 Profil 3 Nom du profil - Veuillez migrer les données de profil pour utiliser cette option Le profil existe déjà ! Réappliquer les règles de profil @@ -386,8 +382,6 @@ Gérer les profils Ajouter ou supprimer des profils Profil - Migrer les profils - Migrer toutes les données de profil Nom du profil Ajouter Afficher une notification toast en cas de blocage @@ -428,8 +422,6 @@ Multidiffusion DNS (mdns) Activer la multidiffusion DNS Impossible de démarrer le service de journalisation. LogTarget est vide ou LogService n’est pas activé. - Impossible de supprimer le profil - Profils migrés Adresse de destination Adresse source @@ -477,8 +469,6 @@ Zoom arrière Impossible de lancer l’application à cause de préférences corrompues, veuillez effacer les données de l’application AFWall+ a détecté une connexion IPv6. Veuillez activer depuis Préférences-Règles/Connectivité et appliquer à nouveau - Délai en secondes personnalisé - AFWall+ attend pendant X secondes après le démarrage avant d\'appliquer les règles Définir une durée en secondes Durée du ping Priorité de la notification diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml index 77cb371aa..0bc8b6f7a 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -168,9 +168,6 @@ iptables.\nייתכן כי +AFWall לא יפעל כצפוי בשל מגבלה ז פרופילים שם מותאם אישית עבור פרופיל ברירת מחדל - שם מותאם אישית עבור פרופיל 1 - שם מותאם אישית עבור פרופיל 2 - שם מותאם אישית עבור פרופיל 3 הפעל חומת אש השבת חומת אש הפעל פרופיל ברירת מחדל @@ -183,7 +180,6 @@ iptables.\nייתכן כי +AFWall לא יפעל כצפוי בשל מגבלה ז פרופיל 2 פרופיל 3 שם-פרופיל - יש להעביר את נתוני הפרופילים כדי להשתמש באפשרות זו פרופיל קיים כבר! החל מחדש חוקי פרופיל @@ -387,8 +383,6 @@ iptables.\nייתכן כי +AFWall לא יפעל כצפוי בשל מגבלה ז ניהול פרופילים הוספה או הסרת פרופילים פרופיל - היגור פרופילים - היגור כל המידע בפרופילים שם פרופיל הוסף הצגת התראה בעת חסימה @@ -429,8 +423,6 @@ iptables.\nייתכן כי +AFWall לא יפעל כצפוי בשל מגבלה ז (mdns) Multicast DNS לאפשר Multicast DNS לא ניתן להפעיל את שירות היומן. LogTarget ריק או LogService אינו מופעל. - לא ניתן למחוק פרופיל - פרופילים הועברו כתובת יעד כתובת מקור @@ -478,8 +470,6 @@ iptables.\nייתכן כי +AFWall לא יפעל כצפוי בשל מגבלה ז Zoom Out Unable to start the activity due to corrupted preference, Please clear the application data AFWall+ detected IPv6 connection. Please enable from Preference-Rules/Connectivity and Reapply - Custom Seconds Delay - AFWall+ ממתין מספר שניות זה אחרי עליית המכשיר לפני החלת חוקים הגדר זמן המתנה בשניות Ping timeout עדיפות התראה diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 647e5d79e..0abdeea24 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -168,9 +168,6 @@ प्रोफाइल्स डिफ़ॉल्ट प्रोफ़ाइल के लिए कस्टम नाम - प्रोफ़ाइल 1 के लिए कस्टम नाम - प्रोफ़ाइल 2 के लिए कस्टम नाम - प्रोफ़ाइल 3 के लिए कस्टम नाम फ़ायरवॉल सक्षम करें फ़ायरवॉल अक्षम करें डिफ़ॉल्ट प्रोफ़ाइल सक्रिय करें @@ -183,7 +180,6 @@ प्रोफ़ाइल 2 प्रोफ़ाइल 3 प्रोफाइल नाम - कृपया इस विकल्प का उपयोग करने के लिए प्रोफ़ाइल डेटा माइग्रेट करें प्रोफ़ाइल पहले से मौजूद है! प्रोफ़ाइल नियम फिर से लागू करें @@ -387,8 +383,6 @@ Manage profiles Add or remove profiles Profile - Migrate profiles - Migrate all profile data Profile name Add Show toast notification on block @@ -429,8 +423,6 @@ (mdns) Multicast DNS Enable Multicast DNS Unable to start log service. LogTarget is empty or LogService is not enabled. - Unable to delete profile - Profiles migrated Destination Address Source Address @@ -478,8 +470,6 @@ Zoom Out Unable to start the activity due to corrupted preference, Please clear the application data AFWall+ detected IPv6 connection. Please enable from Preference-Rules/Connectivity and Reapply - Custom Seconds Delay - AFWall+ waits for given seconds after startup before applying rules Set delay in seconds Ping timeout Notification Priority diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 13ea1b6e4..d3aa1ea1b 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -167,9 +167,6 @@ Profili Prilagođeno ime za zadani profil - Prilagođeno ime za Profil 1 - Prilagođeno ime za Profil 2 - Prilagođeno ime za Profil 3 Omogući vatrozid Onemogući vatrozid Aktiviraj zadani profil @@ -182,7 +179,6 @@ Profil 2 Profil 3 NazivProfila - Molimo da premjestite podatke profila da bi koristili ovu opciju Profil već postoji! Ponovno primijenite pravila profila @@ -386,8 +382,6 @@ Upravljanje profilima Dodaj ili ukloni profile Profil - Migrirajte profile - Migrirajte sve podatke profila Naziv profila Dodaj Prikaži skočnu obavijest prilikom blokiranja @@ -428,8 +422,6 @@ (mdns) Multicast DNS Omogući Multicast DNS Nije moguće pokrenuti servis zapisnika. LogTarget je prazan ili LogService nije omogućen. - Nije moguće obrisati profil - Profili premješteni Odredišna adresa Izvorišna adresa @@ -477,8 +469,6 @@ Smanji Nije moguće pokrenuti aktivnost zbog pogreške u postavkama, izbrišite podatke aplikacije AFWall+ je otkrio IPv6 vezu. U odjeljku Postavke-Pravila/Povezivanje omogućite IPv6 podršku i ponovo primijenite izmjene - Prilagođena odgoda (u sekundama) - AFWall+ čeka određeno vrijeme nakon pokretanja uređaja prije primjene pravila Postavi odgodu u sekundama Ping timeout Prioritet obavijesti diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 46d91e306..ad13ab8fd 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -167,9 +167,6 @@ Profilok Egyéni név az alapértelmezett profilhoz - Profil 1 neve - Profil 2 neve - Profil 3 neve A tűzfal engedélyezése A tűzfal letiltása Alapértelmezett profil bekapcsolása @@ -182,7 +179,6 @@ Profil 2 Profil 3 Profil neve - Költöztesd át a profil adatokat ezen lehetőség használatához A profil már létezik! Profil szabályok újraalkalmazása @@ -386,8 +382,6 @@ Profilok kezelése Profilok hozzáadása vagy eltávolítása Profil - Profilok áttelepítése - Minden profil adat áttelepítése Profil név Hozzáadás Toast üzenet mutatása zároláskor @@ -428,8 +422,6 @@ (mdns) Multicast DNS Mutlicast DNS engedélyezése Nem lehet elindítani a naplószolgáltatást. A LogTarget üres vagy a LogService nincs engedélyezve. - Nem lehet törölni a profilt - Profilok átköltöztetve Cél cím Forrás cím @@ -477,8 +469,6 @@ Kicsinyítés A tevékenység hibás preferencia miatt nem indítható el, kérjük törölje az alkalmazás adatait Az AFWall+ IPv6 kapcsolatot észlelt. Kérjük engedélyezze a Preferenciák-Szabályok/Kapcsolat menüpontban, majd jelentkezzen be újra - Egyéni késleltetési idő - Az AFWall+ a megadott ideig vár induláskor, a szabályok alkalmazása előtt. A késleltetés hossza másodpercben Ping időtúllépés Értesítés priorítása diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 8faa37370..16fb48b11 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -167,9 +167,6 @@ Profil Atur nama profil default - Atur nama untuk profil 1 - Atur nama untuk profil 2 - Atur nama untuk profil 3 Aktifkan firewall Nonaktifkan firewall Aktifkan profil default @@ -182,7 +179,6 @@ Profil 2 Profil 3 NamaProfil - Harap pindahkan data profile untuk menggunakan pilihan ini Profil sudah ada! Terapkan kembali aturan profil @@ -386,8 +382,6 @@ Atur profil Tambah atau hapus profil Profil - Pindah profil - Migrasi semua data profil Nama profil Tambahkan Tampilkan pemberitahuan melayang pada blok @@ -428,8 +422,6 @@ (mdns) DNS Multicast Aktifkan DNS Multicast Tidak dapat memulai layanan log. LogTarget kosong atau LogService tidak diaktifkan. - Tidak dapat menghapus profil - Profil dipindahkan Alamat tujuan Alamat sumber @@ -477,8 +469,6 @@ Perkecil Tidak dapat memulai activity karena preferensi korup, Silakan hapus data aplikasi AFWall+ mendeteksi koneksi IPv6. Silakan aktifkan dari Preferensi-Aturan/Konektifitas dan terapkan kembali - Kustom detik jeda - AFWall+ menunggu selama sesuai waktu yang diatur setelah perangkat memulai sebelum menerapkan aturan Atur jeda dalam detik Timeout ping Prioritas Pemberitahuan diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 386c8f6a3..6313a7ec4 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -167,9 +167,6 @@ Profili Nome personalizzato per il Profilo Predefinito - Nome personalizzato del profilo 1 - Nome personalizzato del profilo 2 - Nome personalizzato del profilo 3 Abilita firewall Disabilita firewall Attiva profilo predefinito @@ -182,7 +179,6 @@ Profilo 2 Profilo 3 NomeProfilo - Migrare i dati del profilo per utilizzare questa opzione Il profilo esiste già! Applica nuovamente le regole @@ -386,8 +382,6 @@ Gestione profili Aggiungi o rimuovi profili Profilo - Eseguire la migrazione dei profili - Migrazione di tutti i dati del profilo Nome del profilo Aggiungi Mostra notifica toast quando si blocca @@ -428,8 +422,6 @@ (mdns) Multicast DNS Abilita Multicast DNS Impossibile avviare il servizio Registro. LogTarget è vuoto o LogService non è abilitato. - Impossibile eliminare il profilo - Profili migrati Indirizzo di destinazione Indirizzo di origine @@ -477,8 +469,6 @@ Riduci Impossibile avviare a causa di un errore nelle preferenze, elimina i dati dell\'applicazione AFWall+ ha rilevato una connessione IPv6. Abilitala da Preferenze-Regole/Connettività e riprova - Ritardo personalizzato (in sec.) - Dopo l\'avvio AFWall+ attende i secondi impostati prima di applicare le regole Imposta ritardo in secondi Ping timeout Priorità notifiche diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 3c9e15f86..1530cc488 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -170,9 +170,6 @@ プロファイル デフォルトプロファイルの名前を変更 - プロファイル1の名前を変更 - プロファイル2の名前を変更 - プロファイル3の名前を変更 Firewall を有効にする Firewall を無効にする デフォルトプロファイルを有効にする @@ -185,7 +182,6 @@ プロファイル 2 プロファイル 3 プロファイル名 - このオプションを使用して、プロファイルデータを移行してください プロファイルは既に存在します! プロファイルのルールを再適用 @@ -389,8 +385,6 @@ プロファイルの管理 プロファイルを追加または削除します プロファイル - プロファイルを移行 - すべてのプロファイル データを移行します プロファイル名 追加 ブロック時にトースト通知を表示 @@ -431,8 +425,6 @@ (mdns) マルチキャスト DNS マルチキャスト DNS を有効にする ログ サービスを開始できません。LogTarget が空か、LogService が有効ではありません。 - プロファイルを削除できません - プロファイルを移行しました 宛先アドレス 送信元アドレス @@ -480,8 +472,6 @@ 縮小 環境設定が破損しているため、活動を開始できません。アプリケーションのデータをクリアしてください AFWall+ が IPv6 接続を検出しました。環境設定-ルール/接続から有効にして再適用してください - カスタム遅延秒数 - AFWall+ が起動後、ルールを適用する前に指定された秒数待ちます 設定時間(秒数) Ping タイムアウト 通知の優先度 diff --git a/app/src/main/res/values-kn/strings.xml b/app/src/main/res/values-kn/strings.xml index cd871ef48..c4d008769 100644 --- a/app/src/main/res/values-kn/strings.xml +++ b/app/src/main/res/values-kn/strings.xml @@ -167,9 +167,6 @@ Profiles Custom name for Default Profile - Custom name for Profile 1 - Custom name for Profile 2 - Custom name for Profile 3 Enable firewall Disable firewall Activate default profile @@ -182,7 +179,6 @@ Profile 2 Profile 3 ProfileName - Please migrate profile data to use this option Profile exists already! Reapply profile rules @@ -386,8 +382,6 @@ Manage profiles Add or remove profiles Profile - Migrate profiles - Migrate all profile data Profile name Add Show toast notification on block @@ -428,8 +422,6 @@ (mdns) Multicast DNS Enable Multicast DNS Unable to start log service. LogTarget is empty or LogService is not enabled. - Unable to delete profile - Profiles migrated Destination Address Source Address @@ -477,8 +469,6 @@ Zoom Out Unable to start the activity due to corrupted preference, Please clear the application data AFWall+ detected IPv6 connection. Please enable from Preference-Rules/Connectivity and Reapply - Custom Seconds Delay - AFWall+ waits for given seconds after startup before applying rules Set delay in seconds Ping timeout Notification Priority diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 3d34b8e2f..9cffa3d2d 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -167,9 +167,6 @@ 프로필 기본프로필 이름 바꾸기 - Profile1 의 이름 - Profile2 의 이름 - Profile3 의 이름 방화벽 시작 방화벽 중단 기본프로필 적용 @@ -182,7 +179,6 @@ 프로필 2 프로필 3 프로필 이름 - 이 옵션을 사용하시려면 프로필 정보를 옮겨 주세요. 프로파일이 이미 존재합니다! 프로필 규칙 재적용 @@ -386,8 +382,6 @@ 프로필 관리 프로필을 추가/제거 프로필 - 프로필 이동 - 프로필 정보 옮기기 프로필 이름 추가 차단할 때 알림을 받도록 허용 @@ -428,8 +422,6 @@ 멀티캐스트 DNS (mdns) 멀티캐스트 DNS를 사용하도록 설정 로그 서비스를 시작할 수 없습니다. LogTarget이 비었거나 LogService가 활성화되지 않았습니다. - 프로필을 삭제하는데 실패하였습니다 - 프로파일들이 이전되었습니다. 목적지 주소 출발지 주소 @@ -477,8 +469,6 @@ 축소 손상된 설정때문에 액티비티를 실행할 수 없습니다. 애플리케이션 데이터를 지운 뒤 다시 시도하십시오 AFWall+ 이 IPv6 연결을 감지했습니다. IPv6 연결 설정을 활성화한 뒤 다시 적용하십시오 - 사용자 정의 시간 단위 지연 - AFWall+ 이 활성화된 뒤, 규칙을 적용하기 전에 주어진 시간만큼 기다립니다. 초 단위 대기 시간 설정 핑 타임 아웃 알림 우선 순위 diff --git a/app/src/main/res/values-ku/strings.xml b/app/src/main/res/values-ku/strings.xml index 2518d870c..ab6131789 100644 --- a/app/src/main/res/values-ku/strings.xml +++ b/app/src/main/res/values-ku/strings.xml @@ -167,9 +167,6 @@ Profiles Custom name for Default Profile - Custom name for Profile 1 - Custom name for Profile 2 - Custom name for Profile 3 Enable firewall Disable firewall Activate default profile @@ -182,7 +179,6 @@ Profile 2 Profile 3 ProfileName - Please migrate profile data to use this option Profile exists already! Reapply profile rules @@ -386,8 +382,6 @@ Manage profiles Add or remove profiles Profile - Migrate profiles - Migrate all profile data Profile name Add Show toast notification on block @@ -428,8 +422,6 @@ (mdns) Multicast DNS Enable Multicast DNS Unable to start log service. LogTarget is empty or LogService is not enabled. - Unable to delete profile - Profiles migrated Destination Address Source Address @@ -477,8 +469,6 @@ Zoom Out Unable to start the activity due to corrupted preference, Please clear the application data AFWall+ detected IPv6 connection. Please enable from Preference-Rules/Connectivity and Reapply - Custom Seconds Delay - AFWall+ waits for given seconds after startup before applying rules Set delay in seconds Ping timeout Notification Priority diff --git a/app/src/main/res/values-ky/strings.xml b/app/src/main/res/values-ky/strings.xml index 5e95b030c..eb594c4d8 100644 --- a/app/src/main/res/values-ky/strings.xml +++ b/app/src/main/res/values-ky/strings.xml @@ -167,9 +167,6 @@ Profiles Custom name for Default Profile - Custom name for Profile 1 - Custom name for Profile 2 - Custom name for Profile 3 Enable firewall Disable firewall Activate default profile @@ -182,7 +179,6 @@ Profile 2 Profile 3 ProfileName - Please migrate profile data to use this option Profile exists already! Reapply profile rules @@ -386,8 +382,6 @@ Manage profiles Add or remove profiles Profile - Migrate profiles - Migrate all profile data Profile name Add Show toast notification on block @@ -428,8 +422,6 @@ (mdns) Multicast DNS Enable Multicast DNS Unable to start log service. LogTarget is empty or LogService is not enabled. - Unable to delete profile - Profiles migrated Destination Address Source Address @@ -477,8 +469,6 @@ Zoom Out Unable to start the activity due to corrupted preference, Please clear the application data AFWall+ detected IPv6 connection. Please enable from Preference-Rules/Connectivity and Reapply - Custom Seconds Delay - AFWall+ waits for given seconds after startup before applying rules Set delay in seconds Ping timeout Notification Priority diff --git a/app/src/main/res/values-ml-rIN/strings.xml b/app/src/main/res/values-ml-rIN/strings.xml index cd871ef48..c4d008769 100644 --- a/app/src/main/res/values-ml-rIN/strings.xml +++ b/app/src/main/res/values-ml-rIN/strings.xml @@ -167,9 +167,6 @@ Profiles Custom name for Default Profile - Custom name for Profile 1 - Custom name for Profile 2 - Custom name for Profile 3 Enable firewall Disable firewall Activate default profile @@ -182,7 +179,6 @@ Profile 2 Profile 3 ProfileName - Please migrate profile data to use this option Profile exists already! Reapply profile rules @@ -386,8 +382,6 @@ Manage profiles Add or remove profiles Profile - Migrate profiles - Migrate all profile data Profile name Add Show toast notification on block @@ -428,8 +422,6 @@ (mdns) Multicast DNS Enable Multicast DNS Unable to start log service. LogTarget is empty or LogService is not enabled. - Unable to delete profile - Profiles migrated Destination Address Source Address @@ -477,8 +469,6 @@ Zoom Out Unable to start the activity due to corrupted preference, Please clear the application data AFWall+ detected IPv6 connection. Please enable from Preference-Rules/Connectivity and Reapply - Custom Seconds Delay - AFWall+ waits for given seconds after startup before applying rules Set delay in seconds Ping timeout Notification Priority diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 04366df84..2ba514845 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -167,9 +167,6 @@ Profil Nama kustom untuk Profil Asal - Nama kustom untuk Profil 1 - Nama kustom untuk Profil 2 - Nama kustom untuk Profil 3 Aktif Firewall Nyahaktif Firewall Aktifkan profil asal @@ -182,7 +179,6 @@ Profil 2 Profil 3 Nama Profil - Sila pindahkan data profil untuk menggunakan opsyen ini Profile exists already! Mengaktif semula arahan profil @@ -386,8 +382,6 @@ Uruskan profil Menambah atau mengalih keluar profil Profil - Memindahkan profil - Memindahkan semua data profil Nama profil Tambah Papar notifikasi semasa disekat @@ -428,8 +422,6 @@ (mdns) Multicast DNS Enable Multicast DNS Tidak dapat mulakan log servis. TargetLog adalah kosong atau ServisLog dinyahaktifkan. - Tidak dapat hapuskan profil - Profiles migrated Alamat destinasi Sumber Alamat @@ -477,8 +469,6 @@ Zum keluar Tidak dapat memulakan aktiviti ini kerana rujukan yang rosak, Sila kosongkan data aplikasi AFWall+ Sambungan IPv6 dikesan. Sila aktifkan daripada Preference-Rules/Connectivity dan Reapply - Kustom Tangguh Saat - AFWall+ menunggu untuk saat yang diterima selepas permulaan sebelum mengaktifkan arahan Tetapkan tangguhan kiraan saat Masa tamat Ping Keutamaan Notifikasi diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index e9a7ed03b..d65510223 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -167,9 +167,6 @@ Profiler Egendefinert navn for standardprofil - Egendefinert navn for profil 1 - Egendefinert navn for profil 2 - Egendefinert navn for profil 3 Aktiver brannmur Deaktiver brannmur Aktiver standardprofil @@ -182,7 +179,6 @@ Profil 2 Profil 3 ProfilNavn - Overfør profildata for å bruke dette alternativet Profilen eksisterer allerede! Bruk profilregler igjen @@ -386,8 +382,6 @@ Behandle profiler Legg til eller fjern profiler Profil - Overfør profiler - Overfør alle profildata Profilnavn Legg til Show toast notification on block @@ -428,8 +422,6 @@ (mdns) Multicast DNS Enable Multicast DNS Unable to start log service. LogTarget is empty or LogService is not enabled. - Kan ikke slette profil - Profiles migrated Måladresse Kildeadresse @@ -477,8 +469,6 @@ Forminsk Unable to start the activity due to corrupted preference, Please clear the application data AFWall+ oppdaget IPv6-forbindelse. Aktiver fra Innstillinger-Regler/Tilkobling og ta i bruk på nytt - Tilpasset sekundforsinkelse - AFWall+ waits for given seconds after startup before applying rules Angi forsinkelse i sekunder Ping-tidsavbrudd Varslingsprioritet diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 6dab1fc04..a8b64cf29 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -170,9 +170,6 @@ Profielen Aangepaste naam voor standaardprofiel - Aangepaste naam voor Profiel 1 - Aangepaste naam voor Profiel 2 - Aangepaste naam voor Profiel 3 Firewall inschakelen Firewall uitschakelen Standaard profiel activeren @@ -185,7 +182,6 @@ Profiel 2 Profiel 3 Profiel naam - Migreer profieldata om deze optie te gebruiken Profiel bestaat al! Profiel regels opnieuw toepassen @@ -389,8 +385,6 @@ Beheer profielen Profielen toevoegen of verwijderen Profiel - Profielen migreren - Alle profielgegevens migreren Profielnaam Toevoegen Toast-melding weergeven bij blokkeren @@ -431,8 +425,6 @@ (mdns) Multicast DNS Multicast DNS inschakelen Kan log-service niet starten. LogTarget is leeg of LogService is niet ingeschakeld. - Kan profiel niet verwijderen - Profielen gemigreerd Adres bestemming Bron adres @@ -480,8 +472,6 @@ Uitzoomen Kan de activiteit niet starten vanwege de corrupte voorkeur, verwijder de applicatie gegevens AFWall+ heeft IPv6 verbinding gedetecteerd. Schakel in van Voorkeursregels/Connectie en pas opnieuw toe - Aangepaste vertraging - AFWall+ wacht voor enkele seconden na het opstarten voor het toepassen van regels Vertraging in seconden Ping timeout Notificatie prioriteit diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 6b1130014..a5c1b795d 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -167,9 +167,6 @@ Profile Zmiana nazwy domyślnego profilu - Własna nazwa dla Profilu 1 - Własna nazwa dla Profilu 2 - Własna nazwa dla Profilu 3 Włącz zaporę Wyłącz zaporę Aktywuj profil domyślny @@ -182,7 +179,6 @@ Profil 2 Profil 3 NazwaProfilu - Proszę przenieść dane profilu, aby skorzystać z tej opcji Profil już istnieje! Zaaplikuj ponownie reguły profili @@ -386,8 +382,6 @@ Zarządzaj profilami Dodaj lub usuń profile Profil - Migruj profile - Migracja wszystkich danych profilu Nazwa profilu Dodaj Pokazuje dymki powiadomień przy blokowaniu @@ -428,8 +422,6 @@ (mdns) Multicast DNS Włącz Multicast DNS Nie można uruchomić usługi dziennika. LogTarget jest pusty lub Logservice nie jest włączona. - Nie można usunąć profilu - Profile zmigrowane Adres docelowy Adres źródłowy @@ -477,8 +469,6 @@ Pomniejsz Nie można uruchomić aktywności z powodu uszkodzonych ustawień. Proszę wyczyść dane aplikacji AFWall + wykrył połączenie IPv6. Proszę włączyć wsparcie w Ustawienia-Reguły/Łączność i ponownie zastosować - Własne opóźnienie w sekundach - AFWall + zaczeka przez podany czas po starcie przed zastosowaniem reguł Ustaw opóźnienie w sekundach Limit czasu Ping Priorytet powiadomień diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index d7452e951..481db22b1 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -167,9 +167,6 @@ Perfis Nome personalizado para o Perfil Padrão - Nome personalizado para o perfil 1 - Nome personalizado para o perfil 2 - Nome personalizado para o perfil 3 Ativar firewall Desativar firewall Ativar perfil padrão @@ -182,7 +179,6 @@ Perfil 2 Perfil 3 NomePerfil - Por favor, migre os dados de perfil para usar esta opção Perfil já existente! Reaplicar regras do perfil @@ -386,8 +382,6 @@ Gerenciar perfis Adicionar ou remover perfis Perfil - Migrar Perfis - Migrar todos os dados de perfil Nome do Perfil Adicionar Mostrar notificação ao bloquear @@ -428,8 +422,6 @@ DNS Multicast (mdns) Ativar DNS Multicast Não é possível iniciar o serviço do registro. LogTarget está vazio ou LogService não está habilitado. - Não foi possível excluir o perfil - Perfil migrado Endereço do destino Endereço de Origem @@ -477,8 +469,6 @@ Zoom - Não é possível iniciar a atividade devido a preferências corrompidas, Limpe os dados do aplicativo AFWall+ conexão IPv6 detectada. Por favor, habilite em Preferências-Regras/Conectividade e reaplique - Atraso personalizado em segundos - AFWall + aguarda segundos após a inicialização antes de aplicar as regras Definir o atraso em segundos Tempo de espera de ping Prioridade de notificação diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index aa4cc07bb..613093d83 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -167,9 +167,6 @@ Perfis Nome personalizado para o perfil padrão - Nome personalizado para o perfil 1 - Nome personalizado para o perfil 2 - Nome personalizado para o perfil 3 Ativar firewall Desativar firewall Ativar perfil padrão @@ -182,7 +179,6 @@ Perfil 2 Perfil 3 Nome do perfil - Efetue a migração dos dados para poder usar esta opção O Perfil já existe! Reaplicar regras do perfil @@ -386,8 +382,6 @@ Gerir perfis Adicionar/remover perfis Perfil - Migrar perfis - Migrar todos os dados do perfil Nome do perfil Adicionar Mostrar notificação ao bloquear @@ -428,8 +422,6 @@ (mdns) Multicast DNS Enable Multicast DNS Unable to start log service. LogTarget is empty or LogService is not enabled. - Incapaz de apagar o perfil - Profiles migrated Endereço do destino Endereço da origem @@ -477,8 +469,6 @@ Reduzir Unable to start the activity due to corrupted preference, Please clear the application data AFWall+ detected IPv6 connection. Please enable from Preference-Rules/Connectivity and Reapply - Custom Seconds Delay - AFWall+ waits for given seconds after startup before applying rules Definir atraso em segundos Ping timeout Prioridade da notificação diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 289beca22..e92843353 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -167,9 +167,6 @@ Profiluri Nume personalizat pentru profilul implicit - Nume personalizat pentru Profilul 1 - Nume personalizat pentru Profilul 2 - Nume personalizat pentru Profilul 3 Activează Firewall Dezactivează Firewall Activează profilul implicit @@ -182,7 +179,6 @@ Profilul 2 Profil 3 Nume Profil - Migrează datele profilului pentru a folosi această opțiune Profile exists already! Reaplică regulile profilului @@ -386,8 +382,6 @@ Administrează profiluri Adaugă sau elimină profiluri Profil - Migrează profiluri - Migrează toate datele profilului Nume profil Adaugă Afișează o notificare la blocare @@ -428,8 +422,6 @@ (mdns) Multicast DNS Enable Multicast DNS Serviciul jurnal nu poate fi pornit. LogTarget este gol sau LogService nu este activat. - Profilul nu a putut fi șters - Profile migrat Adresa destinației Adresa sursei @@ -477,8 +469,6 @@ Zoom Out Unable to start the activity due to corrupted preference, Please clear the application data AFWall+ detected IPv6 connection. Please enable from Preference-Rules/Connectivity and Reapply - Custom Seconds Delay - AFWall+ waits for given seconds after startup before applying rules Set delay in seconds Ping timeout Prioritatea notificăriilor diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index d49928947..a3e23c022 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -168,9 +168,6 @@ Профили Задать имя для профиля по умолчанию - Имя для профиля 1 - Имя для профиля 2 - Имя для профиля 3 Включить брандмауэр Отключить брандмауэр Активировать профиль по умолчанию @@ -183,7 +180,6 @@ Профиль 2 Профиль 3 Имя_профиля - Пожалуйста, перенесите данные профиля, чтобы использовать эту функцию Профиль уже существует! Повтор применения правил профиля @@ -387,8 +383,6 @@ Управление профилями Добавление или удаление профилей Профиль - Перенос профилей - Перенести все данные профиля Имя профиля Добавить Всплывающее уведомление при блокировке @@ -429,8 +423,6 @@ (mdns) Multicast DNS Включить Multicast DNS Не удалось запустить службу журнала. LogTarget пуст или LogService не включён. - Невозможно удалить профиль - Профили перемещены Адрес назначения Адрес источника @@ -478,8 +470,6 @@ Уменьшить Не удаётся выполнить действие из-за повреждённых настроек, пожалуйста, очистите данные приложения AFWall+ обнаружил соединение IPv6. Пожалуйста, включите их в \"Настройки\"-\"Правила/соединение\" и примените повторно - Настраиваемая задержка - AFWall+ будет ждать заданное количество секунд после запуска перед применением правил Укажите задержку в секундах Время ожидания пинга Приоритет уведомлений diff --git a/app/src/main/res/values-si/strings.xml b/app/src/main/res/values-si/strings.xml index 40a571aa9..206f4f614 100644 --- a/app/src/main/res/values-si/strings.xml +++ b/app/src/main/res/values-si/strings.xml @@ -167,9 +167,6 @@ පැතිකඩ පෙරනිමි පැතිකඩ සඳහා අභිරුචි නම - පැතිකඩ 1 සඳහා අභිරුචි නම - පැතිකඩ 2 සඳහා අභිරුචි නම - පැතිකඩ 3 සඳහා අභිරුචි නම ගිනි පවුර සක්රිය කරන්න ගිනි පවුර අක්රිය කරන්න පෙරනිමි පැතිකඩ සක්රිය කරන්න @@ -182,7 +179,6 @@ පැතිකඩ 2 පැතිකඩ 3 පැතිකඩ නම - මෙම විකල්පය භාවිතා කිරීමට කරුණාකර පැතිකඩ දත්ත සංක්‍රමණය කරන්න පැතිකඩ දැනටමත් පවතී! පැතිකඩ නීති නැවත යොදන්න @@ -386,8 +382,6 @@ පැතිකඩ කළමනාකරණය පැතිකඩ එකතු කිරීම හෝ ඉවත් කිරීම පැතිකඩ - පැතිකඩ සංක්‍රමණය කරන්න - සියලුම පැතිකඩ දත්ත සංක්‍රමණය කරන්න පැතිකඩ නම එකතු බ්ලොක් එකේ ටෝස්ට් දැනුම්දීම පෙන්වන්න @@ -428,8 +422,6 @@ (mdns) Multicast DNS Multicast DNS සබල කරන්න ලොග් සේවාව ආරම්භ කළ නොහැක. LogTarget හිස් හෝ LogService සබල කර නැත. - පැතිකඩ මැකීමට නොහැකිය - පැතිකඩ සංක්‍රමණය විය ගමනාන්තය ලිපිනය මූලාශ්ර ලිපිනය @@ -477,8 +469,6 @@ විශාලනය කරන්න දූෂිත මනාපය හේතුවෙන් ක්‍රියාකාරකම ආරම්භ කළ නොහැක, කරුණාකර යෙදුම් දත්ත හිස් කරන්න AFWall+ IPv6 සම්බන්ධතාවය හඳුනා ගන්නා ලදී. කරුණාකර මනාප-රීති/සම්බන්ධතාවයෙන් සබල කර නැවත අයදුම් කරන්න - අභිරුචි තත්පර ප්‍රමාදය - AFWall+ රීති යෙදීමට පෙර ආරම්භයෙන් පසු ලබා දී ඇති තත්පර සඳහා රැඳී සිටියි ප්‍රමාදය තත්පර වලින් සකසන්න පිං කල් ඉකුත්වීම දැනුම්දීමේ ප්‍රමුඛතාවය diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index e6ba80094..34a4e2b3c 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -167,9 +167,6 @@ Profily Vlastný názov pre Predvolený Profil - Vlastný názov pre Profil 1 - Vlastný názov pre Profil 2 - Vlastný názov pre Profil 3 Zapnúť Firewall Vypnúť Firewall Aktivovať predvolený profil @@ -182,7 +179,6 @@ Profil 2 Profil 3 Názov Profilu - Prosím, presuňte dáta profilu pre použitie tejto funkcie Profil už existuje! Opätovné aplikovanie pravidiel profilu @@ -386,8 +382,6 @@ Spravovať profily Pridať alebo odstrániť profily Profil - Migrovať profily - Migrácia všetkých profilových dát Názov profilu Pridať Zobraziť toast notifikácie pri blokovaní @@ -428,8 +422,6 @@ (mdns) Multicast DNS Zapnúť Multicast DNS Nie je možné spustiť službu pre denník. LogTarget je prázdny alebo LogService nie je zapnutý. - Profil sa nedá odstrániť - Profily migrované Cieľová adresa Zdrojová adresa @@ -477,8 +469,6 @@ Vzdialiť Nie je možné spustiť aktivitu z dôvodu poškodenej preferencie, prosím vymazať údaje aplikácie AFWall+ zistil pripojenie IPv6. Zapnite cez Nastavenia-Pravidlá/Pripojenie a znova aplikujte - Vlastné oneskorenie v sekundách - AFWall+ čaká daný počet sekúnd po štarte pred uplatňovaním pravidiel Nastaviť oneskorenie v sekundách Ping-maximum čakania Priorita notifikácie diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index 039c7d031..9eeb7c33e 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -167,9 +167,6 @@ Profili Ime po meri za Osnovni profil - Ime po meri za Profil 1 - Ime po meri za Profil 2 - Ime po meri za Profil 3 Vklopi požarni zid Izklopi požarni zid Aktiviraj osnovni profil @@ -182,7 +179,6 @@ Profil 2 Profil 3 ImeProfila - Za uporabo te opcije morate prej izvesti selitev profilnih podatkov Profile exists already! Ponovno uveljavi pravila @@ -386,8 +382,6 @@ Urejanje profilov Dodajanje/odstranjevanje profilov Profil - Prenos profilov - Selitev vseh podatkov profila Ime profila Dodaj To bo omogočilo prikazovanje sporočil ob blokadah @@ -428,8 +422,6 @@ (mdns) Multicast DNS Enable Multicast DNS Dnevnika aktivnosti ni bilo mogoče zagnati. Storitev ni vklopljena ali pa je polje LogTarget prazmo. - Izbris profila ni možen - Profiles migrated Ciljni naslov Naslov vira @@ -477,8 +469,6 @@ Oddalji Zaradi okvarjene nastavitve ne morem začeti z aktivnostjo. Prosimo izbrišite podatke aplikacije. AFWall+ je zaznal povezavo preko protokola IPv6. Prosimo vklopite podporo za IPv& na Nastavitve-Pravila/povezljivost in ponovno uveljavite pravila - Zakasnitev pri uveljavljanju pravil - AFWall+ bo počakal n-sekund po zagonu, preden bo uveljavil pravila Nastavitve zamika v sekundah Časovna omejitev za ping Prioriteta obvestila diff --git a/app/src/main/res/values-sr-rCS/strings.xml b/app/src/main/res/values-sr-rCS/strings.xml index db76b90c3..89f56d1a6 100644 --- a/app/src/main/res/values-sr-rCS/strings.xml +++ b/app/src/main/res/values-sr-rCS/strings.xml @@ -167,9 +167,6 @@ Profili Posebno ime za podrazumevani profil - Proizvoljno ime za profil 1 - Proizvoljno ime za profil 2 - Proizvoljno ime za profil 3 Uključi zaštitni zid Isključi zaštitni zid Aktiviraj podrazumevani profil @@ -182,7 +179,6 @@ Profil 2 Profil 3 NazivProfila - Molimo da premestite podatke profila da bi koristili ovu opciju Profil već postoji! Opet primeni pravila profila @@ -386,8 +382,6 @@ Upravljanje profilima Dodaj ili ukloni profile Profil - Preseli profile - Preseli sve podatke profila Naziv profila Dodaj Prikaži izvršna obaveštenja prilikom blokiranja @@ -428,8 +422,6 @@ (mdns) Multicast DNS Uključi Multicast DNS Nije moguće pokrenuti servis za vođenje dnevnika. LogTarget je prazan ili LogService nije uključen. - Nije moguće obrisati profil - Profili su preseljeni Adresa odredišta Adresa izvora @@ -477,8 +469,6 @@ Smanji Nije moguće pokrenuti aktivnost zbog grešaka u podešavanjima. Molimo očistite podatke aplikacije AFWall+ je detektovao IPv6 konekciju. Molimo omogućite u Podešavanjima-Pravila/Veza i ponovo primenite - Prilagođeno kašnjenje (u sekundama) - AFWall+ će sačekati zadani broj sekundi nakon pokretanja za primenu pravila Postavite kašnjenje u sekundama Vreme isteka pingovanja Prioritet obaveštenja diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 0776a9487..8ab91b32b 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -167,9 +167,6 @@ Профили Посебно име за подразумевани профил - Посебно име за профил 1 - Посебно име за профил 2 - Посебно име за профил 3 Укључи ватробран Искључи ватробран Активирај подразумевани профил @@ -182,7 +179,6 @@ Профил 2 Профил 3 НазивПрофила - Преселите податке профила да бисте користили ову опцију Профил већ постоји! Опет примени правила профила @@ -386,8 +382,6 @@ Управљање профилима Додај или уклони профиле Профил - Пресели профиле - Пресели све податке профила Назив профила Додај Прикажи екранско обавештење при блокирању @@ -428,8 +422,6 @@ (mdns) мултикаст ДНС Укључи мултикаст ДНС Не могу да покренем сервис дневника. LogTarget је празан или LogService није укључен. - Не могу да обришем профил - Профили пресељени Адреса одредишта Адреса извора @@ -477,8 +469,6 @@ Умањи Не могу да покренем радње због оштећених поставки. Обришите податке апликације AFWall+ је открио IPv6 везу. Омогућите је у Подешавање>Правила/Повезаност и поново их примените - Произвољна задршка - AFWall+ чека задати број секунди пре него што почне са применом правила одлагање у секундама Истек пинга Приоритет обавештења diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 7a53ec5f3..f82c09619 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -167,9 +167,6 @@ Profiler Eget namn för standardprofil - Eget namn för Profil 1 - Eget namn för Profil 2 - Eget namn för Profil 3 Aktivera brandväggen Inaktivera brandväggen Aktivera standardprofil @@ -182,7 +179,6 @@ Profil 2 Profil 3 Profilnamn - Vänligen migrera profildata för att använda det här alternativet Profilen finns redan! Tillämpa profilens regler @@ -386,8 +382,6 @@ Hantera profiler Lägga till eller ta bort profiler Profil - Migrera profiler - Migrera alla profiler Profilnamn Lägg tilll Aktivera för att visa popup-notifieringar vid nekade paket @@ -428,8 +422,6 @@ (mdns) Multicast DNS Aktivera Multicast DNS Det gick inte att starta loggtjänsten. LogTarget är tom eller LogService är inte aktiverad. - Det gick inte att ta bort profilen - Profiler migrerade Destinationsadress Källadress @@ -477,8 +469,6 @@ Zooma Ut Det går inte att starta på grund av skadade inställningar, appens data behöver rensas AFWall+ har upptäckt en IPv6-förbindelse. Vänligen aktivera IPv6 från Inställningar-Regler/Anslutningar - Anpassad fördröjning i sekunder - AFWall+ väntar angivet antal sekunder efter start innan regler appliceras Ange längden i sekunder Ping timeout Prioritet för avisering diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index ae1cc2249..f3bf720a8 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -167,9 +167,6 @@ Profiles Custom name for Default Profile - Custom name for Profile 1 - Custom name for Profile 2 - Custom name for Profile 3 Enable firewall Disable firewall Activate default profile @@ -182,7 +179,6 @@ Profile 2 Profile 3 ProfileName - Please migrate profile data to use this option Profile exists already! Reapply profile rules @@ -386,8 +382,6 @@ Manage profiles Add or remove profiles Profile - Migrate profiles - Migrate all profile data Profile name Add Show toast notification on block @@ -428,8 +422,6 @@ (mdns) Multicast DNS Enable Multicast DNS Unable to start log service. LogTarget is empty or LogService is not enabled. - Unable to delete profile - Profiles migrated Destination Address Source Address @@ -477,8 +469,6 @@ Zoom Out Unable to start the activity due to corrupted preference, Please clear the application data AFWall+ detected IPv6 connection. Please enable from Preference-Rules/Connectivity and Reapply - Custom Seconds Delay - AFWall+ waits for given seconds after startup before applying rules Set delay in seconds Ping timeout Notification Priority diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 328bef045..2fdc5d80e 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -167,9 +167,6 @@ Profiles Custom name for Default Profile - Custom name for Profile 1 - Custom name for Profile 2 - Custom name for Profile 3 Enable firewall Disable firewall Activate default profile @@ -182,7 +179,6 @@ Profile 2 Profile 3 ProfileName - Please migrate profile data to use this option Profile exists already! Reapply profile rules @@ -386,8 +382,6 @@ Manage profiles Add or remove profiles Profile - Migrate profiles - Migrate all profile data Profile name Add Show toast notification on block @@ -428,8 +422,6 @@ (mdns) Multicast DNS Enable Multicast DNS Unable to start log service. LogTarget is empty or LogService is not enabled. - Unable to delete profile - Profiles migrated Destination Address Source Address @@ -477,8 +469,6 @@ Zoom Out Unable to start the activity due to corrupted preference, Please clear the application data AFWall+ detected IPv6 connection. Please enable from Preference-Rules/Connectivity and Reapply - Custom Seconds Delay - AFWall+ waits for given seconds after startup before applying rules Set delay in seconds Ping timeout Notification Priority diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 922ceceae..da37370a9 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -167,9 +167,6 @@ Profiller Varsayılan profil için özel isim - Profil 1 için özel isim - Profil 2 için özel isim - Profil 3 için özel isim Güvenlik duvarını etkinleştir Güvenlik duvarını devre dışı bırak Varsayılan profili etkinleştir @@ -182,7 +179,6 @@ Profil 2 Profil 3 ProfilAdı - Bu seçeneği kullanmak için lütfen profil verinizi aktarın Profil zaten mevcut! Profil kurallarını yeniden uygula @@ -386,8 +382,6 @@ Profilleri yönet Profil ekle veya kaldır Profil - Profilleri taşı - Tüm profil verilerini taşı Profil adı Ekle Engellemelerde bildirim göster @@ -428,8 +422,6 @@ (mdns) Multicast DNS Multicast DNS\'i etkinleştir Günlük hizmeti başlatılamıyor. LogTarget boş veya LogService etkin değil. - Profil silinemiyor - Profiller taşındı Hedef Adres Kaynak Adres @@ -477,8 +469,6 @@ Uzaklaştır Bozuk tercihler nedeniyle uygulama başlatılamıyor, lütfen uygulama verilerini temizleyin AFWall+ IPv6 bağlantısı algılandı. Lütfen Tercihler-Kurallar/Bağlantı\'dan etkinleştirin ve yeniden uygulayın - Özel Gecikme Saniyesi - AFWall+, kuralları uygulamadan önce başlangıç sonrası belirli süre boyunca bekler Gecikmeyi saniye cinsinden ayarla Ping zaman aşımı Bildirim Önceliği diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index d136a2135..f4bb1638d 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -167,9 +167,6 @@ Профілі Власне ім\'я для типового профілю - Власне ім\'я для Профілю1 - Власне ім\'я для Профілю2 - Власне ім\'я для Профілю3 Увімкнути мережевий екран Вимкнути мережевий екран Активувати типовий профіль @@ -182,7 +179,6 @@ Профіль 2 Профіль 3 Назва профілю - Будь ласка, перенесіть дані профілю, щоб використовувати цю опцію Профіль вже існує! Повторне застосування правил профілю @@ -386,8 +382,6 @@ Керування профілями Додавання або вилучення профілів Профіль - Перенесення профілів - Перенести всі дані профілю Ім\'я профілю Додати Спливне сповіщення під час блокування @@ -428,8 +422,6 @@ (mdns) Multicast DNS Увімкнути Mutlicast DNS Не вдалося запустити службу журналу. LogTarget є порожнім або LogService не ввімкнуто. - Не вдалося видалити профіль - Профілі перенесено Адреса призначення Адреса джерела @@ -477,8 +469,6 @@ Зменшити Не вдалося розпочати діяльність через пошкоджену опцiю, будь ласка зніміть застосування даних AFWall + виявив IPv6 з\'єднання. Будь ласка, увімкніть Опцii-Правила/Підключення і відновiть - Ручка затримка в секундах - AFWall + чекає, враховуючи наданi секунди після запуску, перед застосуванням правила Установити тривалість у секундах Пінг очікування Пріоритет повідомлення diff --git a/app/src/main/res/values-ur-rPK/strings.xml b/app/src/main/res/values-ur-rPK/strings.xml index c73959a11..6be3ad20b 100644 --- a/app/src/main/res/values-ur-rPK/strings.xml +++ b/app/src/main/res/values-ur-rPK/strings.xml @@ -167,9 +167,6 @@ Profiles Custom name for Default Profile - Custom name for Profile 1 - Custom name for Profile 2 - Custom name for Profile 3 Enable firewall Disable firewall Activate default profile @@ -182,7 +179,6 @@ Profile 2 Profile 3 ProfileName - Please migrate profile data to use this option Profile exists already! Reapply profile rules @@ -386,8 +382,6 @@ Manage profiles Add or remove profiles Profile - Migrate profiles - Migrate all profile data Profile name Add Show toast notification on block @@ -428,8 +422,6 @@ (mdns) Multicast DNS Enable Multicast DNS Unable to start log service. LogTarget is empty or LogService is not enabled. - Unable to delete profile - Profiles migrated Destination Address Source Address @@ -477,8 +469,6 @@ Zoom Out Unable to start the activity due to corrupted preference, Please clear the application data AFWall+ detected IPv6 connection. Please enable from Preference-Rules/Connectivity and Reapply - Custom Seconds Delay - AFWall+ waits for given seconds after startup before applying rules Set delay in seconds Ping timeout Notification Priority diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 0a774b7a2..0448fe9ee 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -167,9 +167,6 @@ Cấu hình Đặt tên cho Cấu hình mặc định - Tên khác cho Cấu hình 1 - Tên khác cho Cấu hình 2 - Tên khác cho Cấu hình 3 Bật tường lửa Tắt tường lửa Kích hoạt cấu hình mặc định @@ -182,7 +179,6 @@ Cấu hình 2 Cấu hình 3 Tên_cấu_hình - Hãy đổi mới dữ liệu cấu hình để dùng tuỳ chọn này Cấu hình đã tồn tại! Áp dụng lại các quy tắc @@ -386,8 +382,6 @@ Quản lý cấu hình Tạo và xoá cấu hình Cấu hình - Đổi mới cấu hình - Đổi mới tất cả dữ liệu cấu hình Tên cấu hình Thêm Hiện thông báo khi chặn @@ -428,8 +422,6 @@ (mdns) Multicast DNS Bật Mutlicast DNS Không thể bật tác vụ ghi chép. LogTarget trống hoặc LogService đang tắt. - Không thể xoá cấu hình - Đã nhập cấu hình Địa chỉ đích Địa chỉ nguồn @@ -477,8 +469,6 @@ Thu nhỏ Không thể chạy hoạt động do tuỳ chỉnh gặp lỗi. Hãy xoá hết dữ liệu ứng dụng AFWall+ phát hiện kết nối IPv6. Hãy bật hỗ trợ trong Tuỳ chỉnh-Quy tắc/Kết nối và áp dụng lại - Đặt số giây chờ - AFWall+ đợi số giây này khi khởi động trước khi áp dụng quy tắc Đặt thời gian chờ bằng giây Thời gian chờ ping Mức ưu tiên thông báo diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 8ba48ca1e..3b46a3455 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -168,9 +168,6 @@ 配置文件 自定义默认配置名称 - 配置文件 1 自定义名称 - 配置文件 2 自定义名称 - 配置文件 3 自定义名称 启用防火墙 禁用防火墙 激活默认配置文件 @@ -183,7 +180,6 @@ 配置文件 2 配置文件 3 配置名 - 请迁移配置数据以使用此选项 配置文件已存在! 重新应用配置文件规则 @@ -387,8 +383,6 @@ 管理配置文件 添加或移除配置文件 配置文件 - 迁移配置文件 - 迁移所有配置数据 配置文件名称: 添加 阻止时显示 toast 通知 @@ -429,8 +423,6 @@ (mdns) 组播DNS 启用组播DNS 无法启用日志服务。LogTarget 为空或未启用 LogService。 - 无法删除配置 - 配置文件已迁移 目标地址 源地址 @@ -478,8 +470,6 @@ 缩小 由于首选项损坏,无法启动应用,请清除应用数据 AFWall+ 检测到 IPv6 连接。请从首选项 - 规则/连接中启用后重新应用 - 自定义延迟秒数 - AFWall+ 将等待若干秒后应用规则 设置延迟(秒) ping 时间 通知优先级 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index f02b4777a..bfc1086db 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -167,9 +167,6 @@ 設定檔 自定義預設配置名稱 - 自定義配置1名稱 - 自定義配置2名稱 - 自定義配置3名稱 啟用防火牆 停用防火牆 啟用預設配置 @@ -182,7 +179,6 @@ 設定檔 2 設定檔 3 配置文件名稱 - 請轉移設定檔資料以使用此選項 設定檔已存在! 重新應用設定檔規則 @@ -386,8 +382,6 @@ 管理設定檔 新增或刪除設定檔 設定檔 - 遷移設定檔 - 將所有設定檔資料移轉 檔案名稱 新增 在阻擋時顯示 toast 通知 @@ -428,8 +422,6 @@ (mdns) 組播 DNS 啟用組播 DNS 無法啟用開機記錄服務。LogTarget 為空或未啟用 LogService。 - 無法刪除項目 - Profiles migrated 目的地址 來源位址 @@ -477,8 +469,6 @@ 缩小 因為偏好設定毀損而無法啟動,請清除本程式資料 AFWall+ 檢測到 IPv6 連接。請從偏好設定 - 規則/連線中啟用後重新應用程式 - 自訂延遲秒數 - AFWall+ 將等待幾秒來套用規則 設定延遲秒數 Ping 超時 通知訊息的優先度 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index c1f83b15f..63c506daf 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -167,9 +167,6 @@ 設定檔 自定義預設配置名稱 - 自定義配置1名稱 - 自定義配置2名稱 - 自定義配置3名稱 啟用防火牆 停用防火牆 啟用預設配置 @@ -182,7 +179,6 @@ 設定檔 2 設定檔 3 配置文件名稱 - 請轉移設定檔資料以使用此選項 設定檔已存在! 重新應用設定檔規則 @@ -386,8 +382,6 @@ 管理設定檔 新增或刪除設定檔 設定檔 - 遷移設定檔 - 將所有設定檔資料移轉 檔案名稱 新增 在阻擋時顯示 toast 通知 @@ -428,8 +422,6 @@ (mdns) 組播 DNS 啟用組播 DNS 無法啟用開機記錄服務。LogTarget 為空或未啟用 LogService。 - 無法刪除項目 - Profiles migrated 目的地址 來源位址 @@ -477,8 +469,6 @@ 缩小 因為偏好設定毀損而無法啟動,請清除本程式資料 AFWall+ detected IPv6 connection. Please enable from Preference-Rules/Connectivity and Reapply - 自訂延遲秒數 - AFWall+ waits for given seconds after startup before applying rules 設定延遲秒數 Ping 超時 通知訊息的優先度 diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index ecaa062ef..404feb031 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -208,13 +208,25 @@ Dark Light (Donate) + Light High Contrast (Donate) Black (Donate) + Amber (Donate) + Ocean (Donate) + Forest (Donate) + Slate (Donate) + Plum (Donate) D L + LHC B + A + O + F + S + P diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index e2537b0c4..1e546adca 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -5,4 +5,10 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 227435e90..f05fdad73 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -23,6 +23,31 @@ #ffd740 #fddf6c + #ffb300 + #ff8f00 + #00acc1 + #2b2415 + + #0277bd + #01579b + #00acc1 + #102a43 + + #2e7d32 + #1b5e20 + #c0ca33 + #17251b + + #455a64 + #263238 + #ffb300 + #1f2428 + + #6a1b9a + #4a148c + #ff7043 + #241b2f + #f5f5f5 #95aab4 #ff7878 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 27375320d..fcb806e55 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -30,7 +30,13 @@ Could not find support for iptables targets and chains.\nAFWall+ might not work as expected due to this limitation. Error: (Any app) - Same as selecting all apps - !!WARNING!! - (kernel) - Linux kernel + Allow this app + Block this app + Notifications for this app\'s blocked connections turned off + Notifications for this app\'s blocked connections turned on + Loading more entries… + System service + (kernel) - kernel VPNs (WireGuard, IPsec) Android System Unknown (tethering) - DHCP+DNS services @@ -111,7 +117,51 @@ Cancel Open AFWall+ AFWall+ error - Error applying firewall rules. Click to open settings. + Custom script errors + + %1$d custom script line failed; the other rules were applied. %2$s + %1$d custom script lines failed; the other rules were applied. First: %2$s + + + %1$d app in the backup is not installed on this device; its rules were skipped. + %1$d apps in the backup are not installed on this device; their rules were skipped. + + Notification settings + Choose which AFWall+ notifications are shown, and how (sound, lock screen, importance) + Blocked connection notifications + Show a notification when the firewall blocks a connection + Allow notifications + AFWall+ uses notifications to show the firewall status, rules that could not be applied, newly installed apps and blocked connections. + Notifications are off + You won\'t see rules that could not be applied, newly installed apps or blocked connections. The firewall itself keeps working. + Open settings + Not now + Don\'t remind me + Always shown while AFWall+ runs: firewall status, active profile and quick actions + Rules that could not be applied, failed custom script lines, log monitoring problems + Newly installed apps that can use the internet + Connections blocked by the firewall (while logging is on) + Root access is not available (denied, or the superuser app did not answer). + Applying rules… + Firewall enabled · rules could not be applied + Switch profile + %1$s installed + %1$s (other profile) + No internet access. Allow it if it needs the internet. + Has internet access. Block it if it shouldn\'t. + Internet access on %1$s only. + New apps installed + Allow + Block + %1$s allowed + %1$s blocked + + %1$d blocked connection + %1$d blocked connections + + +%1$d more apps + Mute %1$s + Error applying firewall rules. Tap to open AFWall+. AFWall+ could not apply rules @@ -160,6 +210,9 @@ Log disabled Log target change succesful Log target change failed + Change Log Target + You are switching from %1$s to %2$s.\n\nThis will restart the log service which may cause a brief interruption in logging. Do you want to continue? + Log target changed to %1$s successfully Error toggling log status Error: Kernel is missing LOG/NFLOG support Log cleared @@ -179,9 +232,6 @@ Profiles Custom name for Default Profile - Custom name for Profile 1 - Custom name for Profile 2 - Custom name for Profile 3 Enable firewall Disable firewall Activate default profile @@ -194,7 +244,6 @@ Profile 2 Profile 3 ProfileName - Please migrate profile data to use this option Profile exists already! Reapply profile rules @@ -244,6 +293,10 @@ Tasker: AFWall+ DISABLED! Tasker: AFWall+ profile applied Tasker: AFWall+ multi-profile disabled + Tasker: AFWall+ profile \"%1$s\" not found. Edit the Tasker action. + Allow Tasker/Locale control + Let Tasker/Locale actions enable, disable or switch profiles. Any installed app can send these actions; turn off if you don\'t use them. + Profile selected. The firewall is disabled; its rules are applied when it is enabled. IPv6 support @@ -270,15 +323,19 @@ Experimental + Boot + Advanced + Not available: no boot script directory found (needs Magisk, KernelSU, APatch, init.d or su.d). + Off Applying rules. Please wait. - Startup Delay - AFWall+ will wait for \'n\' seconds before applying the rules. Enable this setting only if the startup rules are not applying properly during boot. + Re-apply rules after boot + Apply the rules again this many seconds after boot, for devices where the network comes up late and the boot rules don\'t stick. Off by default. Fix startup data leak - Prevent data leaks during system startup. Your ROM must have init.d or su.d (superSU) support. HTC devices must be configured for S-OFF. + Block all traffic from early boot until AFWall+ has applied its rules. Needs a root solution that runs boot scripts (Magisk, KernelSU, APatch; or init.d / su.d). This feature is available only in the donate version/Unlocker + Donate Only + Note: While you can export rules and preferences for free, importing them back requires the donate version. Continue with export? + Note: This will export your rules, profile settings, and preferences. You can import this backup later since you have the donate version. Continue with export? Purchase donate version or Unlocker tap to unlock donate You can now use donate features. @@ -450,8 +522,6 @@ Add or remove profiles Profile - Migrate profiles - Migrate all profile data Profile name Add Show toast notification on block @@ -488,6 +558,11 @@ Source: Protocol: Please enable log service via Preferences. If enabled, then no log data. + The log service is off. Enable it in Preferences → Log. + No blocked connections have been logged yet. + Log monitoring could not start: %1$s\n\nTry the other log target (LOG / NFLOG) in Preferences → Log. + Log monitoring could not start + %1$s. Tap to choose another log target. Loading… Retrieving log data @@ -500,8 +575,6 @@ (mdns) Multicast DNS Enable Multicast DNS Unable to start log service. LogTarget is empty or LogService is not enabled. - Unable to delete profile - Profiles migrated @@ -514,6 +587,9 @@ Copy text Destination copied Source copied + Copy Domain + Domain copied + No domain resolved for this entry Select The Action Result copied to clipboard Result @@ -552,6 +628,7 @@ Lock screen security not enabled in Settings Fingerprint Enabled Fingerprint Disabled + Fingerprint setup changed. Re-enable fingerprint protection in settings. Zoom In @@ -559,8 +636,6 @@ Unable to start the activity due to corrupted preference, Please clear the application data AFWall+ detected IPv6 connection. Please enable from Preference-Rules/Connectivity and Reapply - Custom Seconds Delay - AFWall+ waits for given seconds after startup before applying rules Set delay in seconds Ping timeout @@ -590,7 +665,7 @@ Show Quick Apply Donate Only! Show floating apply button to apply rules quickly - Startup directory path for script + Boot script directory Unable to mount the directory Unable to delete the startup script Notification will be shown when applying rules @@ -605,6 +680,10 @@ Display resolved hostname from DNS (Donate feature/Need INTERNET access to AFWall+) Show hostname Host: + Connection Blocked + Size: %s + %dx blocked + via %s Whitelist afwall+ doze AFWall+ needs to run in the background and apply rules when there is a connectivity change. Please remove/ignore AFWall+ from battery optimization/Doze preference. Firewall Service @@ -614,6 +693,8 @@ Hide Firewall Issue Log Notification + Firewall log monitoring active + Log monitoring Themes UI Theme @@ -632,12 +713,122 @@ Default Allow Connection Default Block Connection + In allow-list mode, new apps get internet access on the selected connections. Applies to the active profile. + In block-list mode, new apps are blocked on the selected connections. Applies to the active profile. Starting Log service Stopping Log service Please select log target first - Opening log watcher... + Opening log watcher… Started Log service Successfully! Unable to clone Add Delay Apply rules with 1sec delay. + Network change delay + Delay rule application when network changes frequently (seconds) + Ready + Loading iptables rules… + Network Interfaces + System Information + + Loading… + Loading system info… + Loading network info… + Finalizing… + IPv4 Rules + IPv6 Rules + Application Log + Custom Startup Script + Custom Shutdown Script + Enter iptables commands to execute on firewall startup + Enter iptables commands to execute on firewall shutdown + Thank you for your support! Supporter mode enabled. + Supporter mode + Unlock donate features (or install the donate key app) + Direct rules + Direct rules + No direct rules for this app. + Destination IP/CIDR + Example: 192.168.1.10, 10.0.0.0/8 or 2001:db8::/32 + Destination port + Example: 443 or 8000:8100 + Protocol + Add allow rule + Enable direct rules + Direct rules + Apply the per-app direct rules and show their button in the app list. Off: the rules are kept but not applied. + Apply firewall rules for changes to take effect. + Enter a valid IPv4 or IPv6 address or CIDR range. + IPv6 destinations need IPv6 support (Preferences > Rules/Connectivity). + Direct rules are not available for this entry: its traffic has no app UID to match. + Enter a valid port or port range. + Direct rule failed validation. + Enter a destination, a port, or both. + Choose TCP or UDP when using a port. + Direct rule added. + Direct rule removed. + Remove + Selected log item does not include enough app and destination data. + + Network Types + Wi-Fi + Mobile Data + LAN + Roaming + VPN + Tethering + Tor + Actions + Allow + Deny + Invert + Clear + Android Firewall+ + Advanced iptables firewall for Android + Key Features + Network Control + Restrict which apps can access data networks + Data Savings + Perfect for limited data plans and battery savings + Privacy Protection + Enhanced privacy by controlling app network access + About AFWall+ + AFWall+ (aka Android Firewall+) is a front-end application for the powerful iptables Linux firewall. It allows you to restrict which applications are permitted to access data networks. This is the perfect solution if you have a limited data plan, want to extend battery life, or want to improve your privacy. + Useful Links + Wiki & FAQ + Source Code + Report Issues + Developers + Umakanthan Chandran + Active + Kevin Cernekee + Special Thanks + • Pragma (Networklog)\n• Rodrigo Rosauro (DroidWall)\n• PdtS, werewolfdev, Material Design Icons (Icons)\n• CHEF-KOCH (AFWall+ Wiki)\n• All translators and contributors + License & Libraries + AFWall+ is licensed under GNU General Public License v3.0 + App list + Column icons + Tap a network icon above the list to check, uncheck or invert that column + Column actions + The gear above the list inverts, clones or clears columns + Clone + Copy the selection of one column to another + App actions + Tap an app\'s icon to show its actions + Notifications + Notify about the app\'s blocked connections, or not + App settings + Open Android\'s settings of the app + Log + The app\'s blocked connections + Direct rules + Let the app reach specific addresses or ports + Special entries + (kernel), (root), (dns), … are Android services without an app + Profiles and users + (W) work profile, (P) Private Space, (M) other users; work profile apps have a briefcase badge + Colors + User apps and system apps have different name colors (Preferences > Themes) + Firewall status + The shield next to the title shows whether the firewall is on + Key libraries: iptables (GPLv2), BusyBox (GPLv2), libsu (Apache 2.0), material-dialogs (MIT), DBFlow (MIT), RxJava (Apache 2.0), dnsjava (BSD), PrettyTime (Apache 2.0) diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 13cc6068c..d90bba614 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -10,6 +10,18 @@ false + + + + + + + + + + + + + + + + + + + @@ -86,6 +202,33 @@ @color/black + + + + + + + + + + + +