├── .gitattributes ├── package.json ├── medium-unlocker.png ├── android ├── gradle.properties ├── gradle │ └── wrapper │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties ├── app │ ├── src │ │ └── main │ │ │ ├── res │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── drawable │ │ │ │ ├── ic_launcher_logo.png │ │ │ │ ├── bg_accent_circle.xml │ │ │ │ ├── bg_primary_circle.xml │ │ │ │ ├── bg_secondary_rounded.xml │ │ │ │ ├── pulse_indicator.xml │ │ │ │ ├── badge_background.xml │ │ │ │ ├── ic_heart.xml │ │ │ │ ├── ic_update.xml │ │ │ │ ├── ic_github.xml │ │ │ │ └── ic_launcher_foreground.xml │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── values │ │ │ │ ├── ic_launcher_background.xml │ │ │ │ ├── strings.xml │ │ │ │ ├── colors.xml │ │ │ │ └── themes.xml │ │ │ ├── xml │ │ │ │ ├── backup_rules.xml │ │ │ │ └── network_security_config.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── menu │ │ │ │ └── webview_menu.xml │ │ │ └── layout │ │ │ │ ├── dialog_update.xml │ │ │ │ ├── dialog_about.xml │ │ │ │ ├── activity_webview.xml │ │ │ │ └── activity_main.xml │ │ │ ├── AndroidManifest.xml │ │ │ └── java │ │ │ └── com │ │ │ └── mediumunlocker │ │ │ ├── WebViewActivity.java │ │ │ └── MainActivity.java │ ├── proguard-rules.pro │ └── build.gradle ├── build.gradle ├── settings.gradle ├── .gitignore ├── gradlew.bat └── gradlew ├── website ├── src │ ├── index.js │ ├── index.css │ ├── App.css │ └── App.js ├── package.json └── public │ └── index.html ├── .gitignore ├── LICENSE ├── assets └── medium_unlock_logo.svg └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "vercel": "^32.3.0" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /medium-unlocker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inulute/medium-unlocker/HEAD/medium-unlocker.png -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 2 | android.useAndroidX=true 3 | android.enableJetifier=true 4 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inulute/medium-unlocker/HEAD/android/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inulute/medium-unlocker/HEAD/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inulute/medium-unlocker/HEAD/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/ic_launcher_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inulute/medium-unlocker/HEAD/android/app/src/main/res/drawable/ic_launcher_logo.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inulute/medium-unlocker/HEAD/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inulute/medium-unlocker/HEAD/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inulute/medium-unlocker/HEAD/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inulute/medium-unlocker/HEAD/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inulute/medium-unlocker/HEAD/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inulute/medium-unlocker/HEAD/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inulute/medium-unlocker/HEAD/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inulute/medium-unlocker/HEAD/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFFFFF 4 | 5 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/bg_accent_circle.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/bg_primary_circle.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/app/src/main/res/xml/backup_rules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/bg_secondary_rounded.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /website/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App'; 5 | 6 | const root = ReactDOM.createRoot(document.getElementById('root')); 7 | root.render( 8 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/pulse_indicator.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 8 | 9 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | gradlePluginPortal() 6 | } 7 | dependencies { 8 | classpath 'com.android.tools.build:gradle:8.1.2' 9 | } 10 | } 11 | 12 | tasks.register('clean', Delete) { 13 | delete rootProject.buildDir 14 | } 15 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/badge_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | gradlePluginPortal() 6 | } 7 | } 8 | 9 | dependencyResolutionManagement { 10 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 11 | repositories { 12 | google() 13 | mavenCentral() 14 | } 15 | } 16 | 17 | rootProject.name = "Medium Unlocker" 18 | include(":app") 19 | -------------------------------------------------------------------------------- /website/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vercel 2 | node_modules 3 | 4 | # Android build artifacts 5 | android/.gradle/ 6 | android/build/ 7 | android/app/build/ 8 | 9 | # Android local config 10 | local.properties 11 | *.keystore 12 | 13 | # Web build artifacts 14 | website/node_modules/ 15 | website/dist/ 16 | website/build/ 17 | 18 | # Logs 19 | *.log 20 | 21 | # Env/config secrets 22 | .env 23 | .env.* 24 | 25 | # IDE/editor files 26 | .idea/ 27 | *.iml 28 | .vscode/ 29 | 30 | # OS junk 31 | .DS_Store 32 | Thumbs.db 33 | 34 | keystore.properties -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/ic_heart.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /android/app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Keep WebView 2 | -keepclassmembers class * extends android.webkit.WebView { 3 | public *; 4 | } 5 | 6 | -keepclassmembers class * extends android.webkit.WebViewClient { 7 | public void *(android.webkit.WebView, java.lang.String); 8 | } 9 | 10 | # Keep OkHttp (for DNS over HTTPS) 11 | -dontwarn okhttp3.** 12 | -dontwarn okio.** 13 | -keep class okhttp3.** { *; } 14 | -keep class okio.** { *; } 15 | -keep interface okhttp3.** { *; } 16 | 17 | # Optimize 18 | -optimizationpasses 5 19 | -dontusemixedcaseclassnames 20 | -dontskipnonpubliclibraryclasses 21 | -verbose 22 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/ic_update.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /android/app/src/main/res/xml/network_security_config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | freedium.cfd 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "medium-unlocker-website", 3 | "version": "1.0.0", 4 | "private": true, 5 | "dependencies": { 6 | "react": "^18.2.0", 7 | "react-dom": "^18.2.0", 8 | "react-scripts": "5.0.1" 9 | }, 10 | "scripts": { 11 | "start": "react-scripts start", 12 | "build": "react-scripts build", 13 | "test": "react-scripts test", 14 | "eject": "react-scripts eject" 15 | }, 16 | "eslintConfig": { 17 | "extends": [ 18 | "react-app" 19 | ] 20 | }, 21 | "browserslist": { 22 | "production": [ 23 | ">0.2%", 24 | "not dead", 25 | "not op_mini all" 26 | ], 27 | "development": [ 28 | "last 1 chrome version", 29 | "last 1 firefox version", 30 | "last 1 safari version" 31 | ] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /android/app/src/main/res/menu/webview_menu.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 10 | 11 | 16 | 17 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /website/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Medium Unlocker - Break Free from Paywalls 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea 5 | .DS_Store 6 | /build 7 | /captures 8 | .externalNativeBuild 9 | .cxx 10 | local.properties 11 | 12 | # Built application files 13 | *.apk 14 | *.ap_ 15 | *.aab 16 | 17 | # Files for the ART/Dalvik VM 18 | *.dex 19 | 20 | # Generated files 21 | bin/ 22 | gen/ 23 | out/ 24 | 25 | # Gradle files 26 | .gradle/ 27 | build/ 28 | 29 | # Local configuration file 30 | local.properties 31 | 32 | # Log Files 33 | *.log 34 | 35 | # Android Studio 36 | *.iml 37 | .idea/ 38 | .navigation/ 39 | captures/ 40 | output.json 41 | 42 | # Keystore files 43 | *.jks 44 | *.keystore 45 | 46 | # External native build folder generated in Android Studio 2.2+ 47 | .externalNativeBuild 48 | .cxx/ 49 | 50 | # Version control 51 | vcs.xml 52 | 53 | # Lint 54 | lint/intermediates/ 55 | lint/generated/ 56 | lint/outputs/ 57 | lint/tmp/ 58 | lint-results*.xml 59 | lint-results*.html 60 | 61 | # Cache 62 | .cache/ 63 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Medium Unlocker 4 | Paste Medium URL here 5 | Unlock Article 6 | About 7 | Medium Unlocker helps you read Medium articles without restrictions by redirecting them through freedium.cfd.\n\nShare any Medium link to this app or paste it directly! 8 | Please enter a valid Medium URL 9 | Loading article... 10 | Oops! 11 | Something went wrong loading the article. Please try again. 12 | Retry 13 | Share App 14 | Open in Browser 15 | 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 inulute 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/ic_github.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #FFFFFF 7 | #F5F5F5 8 | #3B82F6 9 | #60A5FA 10 | 11 | 12 | #050505 13 | #0A0A0A 14 | #0F0F0F 15 | #1A1A1A 16 | 17 | 18 | #FFFFFF 19 | #A3A3A3 20 | #737373 21 | 22 | 23 | #262626 24 | #404040 25 | 26 | 27 | #22C55E 28 | #EF4444 29 | #F59E0B 30 | #3B82F6 31 | 32 | 33 | #FFFFFF 34 | #F5F5F5 35 | #FFFFFF 36 | #0A0A0A 37 | #525252 38 | #E5E5E5 39 | 40 | 41 | #80000000 42 | #1AFFFFFF 43 | #26000000 44 | 45 | 46 | #00000000 47 | 48 | -------------------------------------------------------------------------------- /android/app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | } 4 | 5 | import java.io.FileInputStream 6 | import java.util.Properties 7 | 8 | def keystorePropertiesFile = rootProject.file("keystore.properties") 9 | def keystoreProperties = new Properties() 10 | if (keystorePropertiesFile.exists()) { 11 | keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) 12 | } 13 | 14 | android { 15 | namespace 'com.inulute.mediumunlocker' 16 | compileSdk 34 17 | 18 | defaultConfig { 19 | applicationId "com.inulute.mediumunlocker" 20 | minSdk 21 21 | targetSdk 34 22 | versionCode 2 23 | versionName "1.2" 24 | } 25 | 26 | signingConfigs { 27 | if (keystorePropertiesFile.exists()) { 28 | release { 29 | storeFile file(keystoreProperties['storeFile']) 30 | storePassword keystoreProperties['storePassword'] 31 | keyAlias keystoreProperties['keyAlias'] 32 | keyPassword keystoreProperties['keyPassword'] 33 | } 34 | } 35 | } 36 | 37 | buildTypes { 38 | release { 39 | minifyEnabled true 40 | shrinkResources true 41 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 42 | if (keystorePropertiesFile.exists()) { 43 | signingConfig signingConfigs.release 44 | } 45 | } 46 | } 47 | 48 | applicationVariants.all { variant -> 49 | if (variant.buildType.name == "release") { 50 | variant.outputs.each { output -> 51 | def resolvedVersion = variant.versionName ?: variant.mergedFlavor?.versionName ?: variant.versionCode 52 | output.outputFileName = "MediumUnlocker_v${resolvedVersion}.apk" 53 | } 54 | } 55 | } 56 | 57 | compileOptions { 58 | sourceCompatibility JavaVersion.VERSION_1_8 59 | targetCompatibility JavaVersion.VERSION_1_8 60 | } 61 | 62 | buildFeatures { 63 | viewBinding true 64 | } 65 | } 66 | 67 | dependencies { 68 | implementation 'com.google.android.material:material:1.9.0' 69 | implementation 'androidx.appcompat:appcompat:1.6.1' 70 | implementation 'androidx.constraintlayout:constraintlayout:2.1.4' 71 | } 72 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 18 | 19 | 20 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /android/gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 48 | echo. 49 | echo Please set the JAVA_HOME variable in your environment to match the 50 | echo location of your Java installation. 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 62 | echo. 63 | echo Please set the JAVA_HOME variable in your environment to match the 64 | echo location of your Java installation. 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /assets/medium_unlock_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 37 | 38 | 39 | 46 | 47 | 48 | 59 | 60 | 61 | 68 | 69 | 70 | 78 | 79 | 80 | 85 | 86 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 14 | 15 | 16 | 19 | 20 | 21 | 24 | 25 | 26 | 29 | 30 | 31 | 34 | 37 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /website/src/App.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | } 6 | 7 | body { 8 | font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; 9 | background-color: #050505; 10 | color: #FFFFFF; 11 | -webkit-font-smoothing: antialiased; 12 | -moz-osx-font-smoothing: grayscale; 13 | } 14 | 15 | .App { 16 | min-height: 100vh; 17 | display: flex; 18 | flex-direction: column; 19 | padding: 24px; 20 | max-width: 800px; 21 | margin: 0 auto; 22 | } 23 | 24 | /* Top Bar */ 25 | .top-bar { 26 | display: flex; 27 | justify-content: flex-end; 28 | gap: 8px; 29 | margin-bottom: 32px; 30 | } 31 | 32 | .icon-button { 33 | width: 40px; 34 | height: 40px; 35 | border: none; 36 | background: transparent; 37 | color: #FFFFFF; 38 | cursor: pointer; 39 | border-radius: 50%; 40 | display: flex; 41 | align-items: center; 42 | justify-content: center; 43 | transition: all 0.2s ease; 44 | } 45 | 46 | .icon-button:hover { 47 | background: rgba(255, 255, 255, 0.1); 48 | transform: scale(1.05); 49 | } 50 | 51 | /* Main Container */ 52 | .container { 53 | flex: 1; 54 | display: flex; 55 | flex-direction: column; 56 | } 57 | 58 | /* Hero Title */ 59 | .hero-title { 60 | font-size: 56px; 61 | font-weight: 900; 62 | line-height: 0.9; 63 | letter-spacing: -0.02em; 64 | margin-bottom: 16px; 65 | color: #FFFFFF; 66 | } 67 | 68 | /* Subtitle */ 69 | .subtitle { 70 | font-size: 18px; 71 | line-height: 1.6; 72 | color: #A3A3A3; 73 | margin-bottom: 48px; 74 | } 75 | 76 | /* Input Card */ 77 | .input-card { 78 | background: #0F0F0F; 79 | border: 1px solid #262626; 80 | border-radius: 24px; 81 | padding: 24px; 82 | margin-bottom: 20px; 83 | box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3); 84 | } 85 | 86 | .input-wrapper { 87 | position: relative; 88 | background: #1A1A1A; 89 | border-radius: 16px; 90 | margin-bottom: 16px; 91 | border: 1px solid transparent; 92 | transition: border-color 0.2s ease; 93 | } 94 | 95 | .input-wrapper:focus-within { 96 | border-color: #404040; 97 | } 98 | 99 | .url-input { 100 | width: 100%; 101 | padding: 20px; 102 | padding-right: 50px; 103 | background: transparent; 104 | border: none; 105 | color: #FFFFFF; 106 | font-size: 15px; 107 | font-family: inherit; 108 | outline: none; 109 | } 110 | 111 | .url-input::placeholder { 112 | color: #A3A3A3; 113 | } 114 | 115 | .clear-button { 116 | position: absolute; 117 | right: 12px; 118 | top: 50%; 119 | transform: translateY(-50%); 120 | background: transparent; 121 | border: none; 122 | color: #A3A3A3; 123 | cursor: pointer; 124 | font-size: 18px; 125 | padding: 8px; 126 | display: flex; 127 | align-items: center; 128 | justify-content: center; 129 | border-radius: 4px; 130 | transition: all 0.2s ease; 131 | } 132 | 133 | .clear-button:hover { 134 | background: rgba(255, 255, 255, 0.1); 135 | color: #FFFFFF; 136 | } 137 | 138 | /* Button Group */ 139 | .button-group { 140 | display: grid; 141 | grid-template-columns: 1fr 1fr; 142 | gap: 12px; 143 | } 144 | 145 | .download-button, 146 | .unlock-button { 147 | height: 56px; 148 | border-radius: 16px; 149 | border: none; 150 | font-size: 15px; 151 | font-weight: 500; 152 | font-family: inherit; 153 | cursor: pointer; 154 | transition: all 0.2s ease; 155 | } 156 | 157 | .download-button { 158 | background: transparent; 159 | color: #FFFFFF; 160 | border: 1px solid #262626; 161 | } 162 | 163 | .download-button:hover { 164 | background: rgba(255, 255, 255, 0.05); 165 | border-color: #404040; 166 | } 167 | 168 | .unlock-button { 169 | background: #FFFFFF; 170 | color: #050505; 171 | } 172 | 173 | .unlock-button:hover { 174 | background: #F5F5F5; 175 | transform: translateY(-1px); 176 | box-shadow: 0 4px 12px rgba(255, 255, 255, 0.2); 177 | } 178 | 179 | /* Info Card */ 180 | .info-card { 181 | background: #1A1A1A; 182 | border: 1px solid #262626; 183 | border-radius: 20px; 184 | padding: 20px; 185 | margin-bottom: 24px; 186 | } 187 | 188 | .info-card h3 { 189 | font-size: 16px; 190 | font-weight: 500; 191 | margin-bottom: 12px; 192 | color: #FFFFFF; 193 | } 194 | 195 | .info-card ul { 196 | list-style: none; 197 | color: #A3A3A3; 198 | font-size: 14px; 199 | line-height: 2; 200 | } 201 | 202 | .info-card li::before { 203 | content: "• "; 204 | margin-right: 8px; 205 | } 206 | 207 | /* Powered By */ 208 | .powered-by { 209 | text-align: center; 210 | color: #737373; 211 | font-size: 11px; 212 | font-family: 'Courier New', monospace; 213 | margin-top: 32px; 214 | } 215 | 216 | /* Responsive Design */ 217 | @media (max-width: 768px) { 218 | .App { 219 | padding: 20px; 220 | } 221 | 222 | .hero-title { 223 | font-size: 42px; 224 | } 225 | 226 | .subtitle { 227 | font-size: 16px; 228 | margin-bottom: 32px; 229 | } 230 | 231 | .input-card { 232 | padding: 20px; 233 | } 234 | 235 | .button-group { 236 | grid-template-columns: 1fr; 237 | } 238 | } 239 | 240 | @media (max-width: 480px) { 241 | .hero-title { 242 | font-size: 36px; 243 | } 244 | 245 | .subtitle { 246 | font-size: 14px; 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | Medium Unlocker Logo 3 | 4 | # Medium Unlocker 5 | 6 | **End-to-end paywall bypasser built from scratch around the freedium.cfd index.** 7 | 8 | 9 | Website Badge 10 | 11 | 12 | Download Badge 13 | 14 | 15 |

16 | 17 | 18 | Stars 19 | 20 | 21 | Followers 22 | 23 |
24 | 25 | ## ✨ Features 26 | 27 | ## Project Background 28 | 29 | - Medium’s “3 free reads” wall is frustrating for casual browsing. 30 | - freedium.cfd hosts publicly cached versions of Medium stories, but there was no polished way to reach them. 31 | - Medium Unlocker bridges that gap with a custom UI, tailored network stack, and automation that lets readers paste or share a Medium URL and instantly open the matching freedium mirror. 32 | 33 | --- 34 | 35 | ## Feature Highlights 36 | 37 | ### Web 38 | - **Purpose-built frontend** – React 18 SPA with a bespoke dark interface. 39 | - **URL normalizer** – Cleans Medium query noise and rewrites the slug for freedium. 40 | - **Result inspector** – Shows whether the article was fetched from cache or proxied live. 41 | - **Share-ready links** – Generates clean freedium URLs you can copy anywhere. 42 | 43 | ### Android 44 | - **Native shell** – Java + WebView with Material Design 3 styling. 45 | - **One-tap share target** – Appears inside the Android share sheet for any Medium link. 46 | - **Inline resolver** – Performs the same URL normalization on-device, then loads freedium in a hardened WebView. 47 | - **Mirror Support** – Automatically switches to `freedium-mirror.cfd` if the primary server is blocked. 48 | - **Smart Auto-Retry** – Seamlessly detects connection failures and retries with the mirror server. 49 | - **Network extras** – Optional DoH, proxy toggles, custom SSL pinning for freedium’s cert chain. 50 | 51 | --- 52 | 53 | ## Quick Start 54 | 55 | ### Web 56 | 1. Go to [medium-unlocker.inulute.com](https://medium-unlocker.inulute.com). 57 | 2. Paste any Medium article URL. 58 | 3. Hit **Unlock** and read the freedium mirror. 59 | 60 | ### Android 61 | 1. [Grab the latest APK](https://github.com/inulute/medium-unlocker/releases/latest). 62 | 2. Install (you may need to allow side-loading). 63 | 3. Either: 64 | - Share a Medium link and pick **Medium Unlocker**, or 65 | - Open the app, paste a URL, tap **Unlock**. 66 | 67 | --- 68 | 69 | ## Tech Stack 70 | 71 | | Layer | Stack | 72 | |--------------|----------------------------------------------------------------------| 73 | | Web | React 18, Vite tooling, CSS Modules, Inter font, Cloudflare Pages | 74 | | Android | Java, Material 3, OkHttp, WebView | 75 | 76 | --- 77 | 78 | ## Disclaimer 79 | 80 | > [!WARNING] 81 | > Educational use only. You are responsible for respecting Medium’s Terms of Service and regional laws. This project does not host Medium content; it automates requests to freedium.cfd. 82 | 83 | > [!NOTE] 84 | > freedium.cfd is a public mirror. Its uptime, indexing speed, and article availability are outside my control. 85 | 86 | > [!WARNING] 87 | > **For Educational Purposes Only** 88 | > 89 | > This tool is provided for educational purposes to demonstrate web scraping and proxy techniques. Users are responsible for complying with Medium's Terms of Service and applicable laws. The developers are not responsible for any misuse. 90 | 91 | > [!NOTE] 92 | > **Service Availability** 93 | > 94 | > This tool relies on freedium.cfd, a third-party service. Availability and functionality may vary. Some articles may not be accessible if not indexed by the service. 95 | 96 | --- 97 | 98 | ## 🤝 Contributing 99 | 100 | ## Feedback & Support 101 | 102 | - Issues: [GitHub Issues](https://github.com/inulute/medium-unlocker/issues) 103 | - Ideas: [Discussions](https://github.com/inulute/medium-unlocker/discussions) 104 | - Contact: [socials.inulute.com](https://socials.inulute.com) 105 | - Helpdesk: [support.inulute.com](https://support.inulute.com) 106 | 107 | --- 108 | 109 | ## Donate 110 | 111 |
112 | 113 | Donate Badge 114 | 115 |
116 | 117 | --- 118 | 119 | ## License 120 | 121 | MIT License – see [`LICENSE`](https://github.com/inulute/medium-unlocker/blob/main/LICENSE) for details. 122 | 123 |
124 | License Badge 125 |
126 | 127 | --- 128 | 129 | ## Credits 130 | 131 | - **freedium.cfd** – public cache the project is built around. 132 | - **Material Design + React teams** – foundational tooling. 133 | 134 | --- 135 | 136 |
137 | 138 | **Created by [inulute](https://github.com/inulute)** 139 | [Website](https://medium-unlocker.inulute.com) • [Download](https://github.com/inulute/medium-unlocker/releases/latest) • [Support](https://support.inulute.com) • [GitHub](https://github.com/inulute) 140 | 141 |
142 | 143 | -------------------------------------------------------------------------------- /website/src/App.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import './App.css'; 3 | 4 | function App() { 5 | const [url, setUrl] = useState(''); 6 | const [useMirror, setUseMirror] = useState(() => { 7 | return localStorage.getItem('useMirror') === 'true'; 8 | }); 9 | 10 | useEffect(() => { 11 | localStorage.setItem('useMirror', useMirror); 12 | }, [useMirror]); 13 | 14 | const handleUnlock = () => { 15 | if (!url.trim()) { 16 | alert('Please enter a Medium URL'); 17 | return; 18 | } 19 | 20 | const mediumUrlPattern = /medium\.com/i; 21 | if (!mediumUrlPattern.test(url)) { 22 | alert('Please enter a valid Medium URL'); 23 | return; 24 | } 25 | 26 | const domain = useMirror ? 'freedium-mirror.cfd' : 'freedium.cfd'; 27 | const freediumUrl = `https://${domain}/${url}`; 28 | window.open(freediumUrl, '_blank'); 29 | setUrl(''); 30 | }; 31 | 32 | const handleKeyPress = (e) => { 33 | if (e.key === 'Enter') { 34 | handleUnlock(); 35 | } 36 | }; 37 | 38 | const handleStarRepo = () => { 39 | window.open('https://github.com/inulute/medium-unlocker', '_blank'); 40 | }; 41 | 42 | const handleDownload = () => { 43 | window.open('https://github.com/inulute/medium-unlocker/releases/latest', '_blank'); 44 | }; 45 | 46 | const handleSupport = () => { 47 | window.open('https://support.inulute.com', '_blank'); 48 | }; 49 | 50 | return ( 51 |
52 | {/* Top Bar */} 53 |
54 | 59 | 64 |
65 | 66 | {/* Main Content */} 67 |
68 | {/* Hero Section */} 69 |

70 | Medium
Unlocker. 71 |

72 |

73 | Break free from paywalls.
74 | Read any Medium article without limits. 75 |

76 | 77 | {/* Input Card */} 78 |
79 |
80 | setUrl(e.target.value)} 85 | onKeyPress={handleKeyPress} 86 | className="url-input" 87 | /> 88 | {url && ( 89 | 92 | )} 93 |
94 | 95 |
96 | setUseMirror(e.target.checked)} 101 | style={{ width: 'auto', margin: 0 }} 102 | /> 103 | 106 |
107 | 108 |
109 | 112 | 115 |
116 |
117 | 118 | {/* Info Card */} 119 |
120 |

How it works

121 |
    122 |
  • Paste any Medium URL above
  • 123 |
  • Click "Unlock Article" to bypass the paywall
  • 124 |
  • Automatically routes through freedium.cfd
  • 125 |
  • Download the Android app for mobile access
  • 126 |
  • Enjoy unlimited reading!
  • 127 |
128 |
129 | 130 | {/* Footer */} 131 |

Powered by freedium.cfd

132 |
133 |
134 | ); 135 | } 136 | 137 | export default App; 138 | -------------------------------------------------------------------------------- /android/app/src/main/res/layout/dialog_update.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 16 | 17 | 18 | 23 | 24 | 32 | 33 | 39 | 40 | 47 | 48 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 69 | 70 | 71 | 79 | 80 | 81 | 87 | 88 | 89 | 99 | 100 | 104 | 105 | 106 | 117 | 118 | 119 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | -------------------------------------------------------------------------------- /android/app/src/main/res/layout/dialog_about.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 16 | 17 | 18 | 23 | 24 | 29 | 30 | 35 | 36 | 43 | 44 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 65 | 66 | 67 | 75 | 76 | 77 | 85 | 86 | 90 | 91 | 99 | 100 | 101 | 102 | 103 | 108 | 109 | 110 | 122 | 123 | 124 | 136 | 137 | 138 | 139 | 140 | 153 | 154 | 155 | 156 | 157 | -------------------------------------------------------------------------------- /android/gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 87 | 88 | # Use the maximum available, or set MAX_FD != -1 to use that value. 89 | MAX_FD=maximum 90 | 91 | warn () { 92 | echo "$*" 93 | } >&2 94 | 95 | die () { 96 | echo 97 | echo "$*" 98 | echo 99 | exit 1 100 | } >&2 101 | 102 | # OS specific support (must be 'true' or 'false'). 103 | cygwin=false 104 | msys=false 105 | darwin=false 106 | nonstop=false 107 | case "$( uname )" in #( 108 | CYGWIN* ) cygwin=true ;; #( 109 | Darwin* ) darwin=true ;; #( 110 | MSYS* | MINGW* ) msys=true ;; #( 111 | NONSTOP* ) nonstop=true ;; 112 | esac 113 | 114 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 115 | 116 | 117 | # Determine the Java command to use to start the JVM. 118 | if [ -n "$JAVA_HOME" ] ; then 119 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 120 | # IBM's JDK on AIX uses strange locations for the executables 121 | JAVACMD=$JAVA_HOME/jre/sh/java 122 | else 123 | JAVACMD=$JAVA_HOME/bin/java 124 | fi 125 | if [ ! -x "$JAVACMD" ] ; then 126 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 127 | 128 | Please set the JAVA_HOME variable in your environment to match the 129 | location of your Java installation." 130 | fi 131 | else 132 | JAVACMD=java 133 | if ! command -v java >/dev/null 2>&1 134 | then 135 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 136 | 137 | Please set the JAVA_HOME variable in your environment to match the 138 | location of your Java installation." 139 | fi 140 | fi 141 | 142 | # Increase the maximum file descriptors if we can. 143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 144 | case $MAX_FD in #( 145 | max*) 146 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 147 | # shellcheck disable=SC3045 148 | MAX_FD=$( ulimit -H -n ) || 149 | warn "Could not query maximum file descriptor limit" 150 | esac 151 | case $MAX_FD in #( 152 | '' | soft) :;; #( 153 | *) 154 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 155 | # shellcheck disable=SC3045 156 | ulimit -n "$MAX_FD" || 157 | warn "Could not set maximum file descriptor limit to $MAX_FD" 158 | esac 159 | fi 160 | 161 | # Collect all arguments for the java command, stacking in reverse order: 162 | # * args from the command line 163 | # * the main class name 164 | # * -classpath 165 | # * -D...appname settings 166 | # * --module-path (only if needed) 167 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 168 | 169 | # For Cygwin or MSYS, switch paths to Windows format before running java 170 | if "$cygwin" || "$msys" ; then 171 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 172 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 173 | 174 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 175 | 176 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 177 | for arg do 178 | if 179 | case $arg in #( 180 | -*) false ;; # don't mess with options #( 181 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 182 | [ -e "$t" ] ;; #( 183 | *) false ;; 184 | esac 185 | then 186 | arg=$( cygpath --path --ignore --mixed "$arg" ) 187 | fi 188 | # Roll the args list around exactly as many times as the number of 189 | # args, so each arg winds up back in the position where it started, but 190 | # possibly modified. 191 | # 192 | # NB: a `for` loop captures its iteration list before it begins, so 193 | # changing the positional parameters here affects neither the number of 194 | # iterations, nor the values presented in `arg`. 195 | shift # remove old arg 196 | set -- "$@" "$arg" # push replacement arg 197 | done 198 | fi 199 | 200 | 201 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 202 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 203 | 204 | # Collect all arguments for the java command; 205 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 206 | # shell script including quotes and variable substitutions, so put them in 207 | # double quotes to make sure that they get re-expanded; and 208 | # * put everything else in single quotes, so that it's not re-expanded. 209 | 210 | set -- \ 211 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 212 | -classpath "$CLASSPATH" \ 213 | org.gradle.wrapper.GradleWrapperMain \ 214 | "$@" 215 | 216 | # Stop when "xargs" is not available. 217 | if ! command -v xargs >/dev/null 2>&1 218 | then 219 | die "xargs is not available" 220 | fi 221 | 222 | # Use "xargs" to parse quoted args. 223 | # 224 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 225 | # 226 | # In Bash we could simply go: 227 | # 228 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 229 | # set -- "${ARGS[@]}" "$@" 230 | # 231 | # but POSIX shell has neither arrays nor command substitution, so instead we 232 | # post-process each arg (as a line of input to sed) to backslash-escape any 233 | # character that might be a shell metacharacter, then use eval to reverse 234 | # that process (while maintaining the separation between arguments), and wrap 235 | # the whole thing up as a single "set" statement. 236 | # 237 | # This will of course break if any of these variables contains a newline or 238 | # an unmatched quote. 239 | # 240 | 241 | eval "set -- $( 242 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 243 | xargs -n1 | 244 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 245 | tr '\n' ' ' 246 | )" '"$@"' 247 | 248 | exec "$JAVACMD" "$@" 249 | -------------------------------------------------------------------------------- /android/app/src/main/res/layout/activity_webview.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 20 | 21 | 22 | 32 | 33 | 34 | 40 | 41 | 42 | 49 | 50 | 56 | 57 | 66 | 67 | 78 | 79 | 80 | 91 | 92 | 97 | 98 | 106 | 107 | 116 | 117 | 125 | 126 | 127 | 128 | 129 | 144 | 145 | 159 | 160 | 173 | 174 | 175 | 176 | 177 | 178 | 191 | 192 | 197 | 198 | 206 | 207 | 215 | 216 | 217 | 218 | 219 | -------------------------------------------------------------------------------- /android/app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 16 | 17 | 25 | 26 | 27 | 33 | 34 | 35 | 48 | 49 | 50 | 62 | 63 | 64 | 75 | 76 | 77 | 78 | 79 | 89 | 90 | 91 | 100 | 101 | 102 | 112 | 113 | 117 | 118 | 134 | 135 | 150 | 151 | 152 | 153 | 154 | 161 | 162 | 163 | 173 | 174 | 175 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 205 | 206 | 210 | 211 | 219 | 220 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/mediumunlocker/WebViewActivity.java: -------------------------------------------------------------------------------- 1 | package com.inulute.mediumunlocker; 2 | 3 | import android.content.Intent; 4 | import android.graphics.Bitmap; 5 | import android.net.Uri; 6 | import android.os.Build; 7 | import android.os.Bundle; 8 | import android.util.Log; 9 | import android.view.View; 10 | import android.webkit.SslErrorHandler; 11 | import android.webkit.WebChromeClient; 12 | import android.webkit.WebResourceError; 13 | import android.webkit.WebResourceRequest; 14 | import android.webkit.WebSettings; 15 | import android.webkit.WebView; 16 | import android.webkit.WebViewClient; 17 | import android.net.http.SslError; 18 | import android.widget.ScrollView; 19 | import android.widget.TextView; 20 | import android.widget.Toast; 21 | 22 | import androidx.appcompat.app.AppCompatActivity; 23 | 24 | import com.google.android.material.appbar.MaterialToolbar; 25 | import com.google.android.material.button.MaterialButton; 26 | import com.google.android.material.card.MaterialCardView; 27 | import com.google.android.material.progressindicator.LinearProgressIndicator; 28 | 29 | public class WebViewActivity extends AppCompatActivity { 30 | 31 | private static final String TAG = "WebViewActivity"; 32 | 33 | private WebView webView; 34 | private LinearProgressIndicator progressBar; 35 | private MaterialToolbar toolbar; 36 | private ScrollView errorLayout; 37 | private TextView errorMessage; 38 | private MaterialCardView blockingInfoCard; 39 | private TextView proxyStatusText; 40 | private MaterialButton retryButton; 41 | private MaterialButton tryProxyButton; 42 | private MaterialButton tryAlternativeButton; 43 | private android.widget.LinearLayout loadingOverlay; 44 | private TextView loadingText; 45 | 46 | private String currentUrl; 47 | private String originalUrl; 48 | 49 | @Override 50 | protected void onCreate(Bundle savedInstanceState) { 51 | super.onCreate(savedInstanceState); 52 | setContentView(R.layout.activity_webview); 53 | 54 | initializeViews(); 55 | setupToolbar(); 56 | setupWebView(); 57 | setupButtons(); 58 | loadUrl(); 59 | } 60 | 61 | private void initializeViews() { 62 | webView = findViewById(R.id.webView); 63 | progressBar = findViewById(R.id.progressBar); 64 | toolbar = findViewById(R.id.toolbar); 65 | errorLayout = findViewById(R.id.errorLayout); 66 | errorMessage = findViewById(R.id.errorMessage); 67 | blockingInfoCard = findViewById(R.id.blockingInfoCard); 68 | proxyStatusText = findViewById(R.id.proxyStatusText); 69 | retryButton = findViewById(R.id.retryButton); 70 | tryProxyButton = findViewById(R.id.tryProxyButton); 71 | tryProxyButton = findViewById(R.id.tryProxyButton); 72 | tryAlternativeButton = findViewById(R.id.tryAlternativeButton); 73 | loadingOverlay = findViewById(R.id.loadingOverlay); 74 | loadingText = findViewById(R.id.loadingText); 75 | } 76 | 77 | private void setupToolbar() { 78 | toolbar.setNavigationOnClickListener(v -> finish()); 79 | 80 | toolbar.inflateMenu(R.menu.webview_menu); 81 | toolbar.setOnMenuItemClickListener(item -> { 82 | int id = item.getItemId(); 83 | if (id == R.id.action_open_browser) { 84 | openInBrowser(); 85 | return true; 86 | } else if (id == R.id.action_share) { 87 | shareArticle(); 88 | return true; 89 | } else if (id == R.id.action_refresh) { 90 | webView.reload(); 91 | return true; 92 | } 93 | return false; 94 | }); 95 | } 96 | 97 | private void setupWebView() { 98 | WebSettings settings = webView.getSettings(); 99 | 100 | // Enable JavaScript and DOM storage 101 | settings.setJavaScriptEnabled(true); 102 | settings.setDomStorageEnabled(true); 103 | settings.setDatabaseEnabled(true); 104 | 105 | // Normal caching - use cache but validate with server for fresh content 106 | settings.setCacheMode(WebSettings.LOAD_DEFAULT); 107 | 108 | // Rendering optimizations 109 | settings.setLoadWithOverviewMode(true); 110 | settings.setUseWideViewPort(true); 111 | settings.setBuiltInZoomControls(true); 112 | settings.setDisplayZoomControls(false); 113 | settings.setTextZoom(100); 114 | 115 | // Allow mixed content for freedium.cfd 116 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { 117 | settings.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW); 118 | } 119 | 120 | // Hardware acceleration for smooth rendering 121 | webView.setLayerType(View.LAYER_TYPE_HARDWARE, null); 122 | 123 | // Desktop user agent for better content 124 | settings.setUserAgentString( 125 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" 126 | ); 127 | 128 | // Fast WebViewClient - no interception overhead 129 | webView.setWebViewClient(new WebViewClient() { 130 | @Override 131 | public void onPageStarted(WebView view, String url, Bitmap favicon) { 132 | super.onPageStarted(view, url, favicon); 133 | showLoading(); 134 | errorLayout.setVisibility(View.GONE); 135 | webView.setVisibility(View.VISIBLE); 136 | } 137 | 138 | @Override 139 | public void onPageFinished(WebView view, String url) { 140 | super.onPageFinished(view, url); 141 | hideLoading(); 142 | if (loadingOverlay != null) { 143 | loadingOverlay.setVisibility(View.GONE); 144 | } 145 | String title = view.getTitle(); 146 | if (title != null && !title.isEmpty() && !title.startsWith("http")) { 147 | toolbar.setTitle(title); 148 | } 149 | } 150 | 151 | @Override 152 | public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) { 153 | super.onReceivedError(view, request, error); 154 | if (request.isForMainFrame()) { 155 | hideLoading(); 156 | 157 | // Immediately hide WebView to prevent default error page 158 | view.setVisibility(View.GONE); 159 | 160 | // Auto-retry with mirror if using primary domain 161 | String url = request.getUrl().toString(); 162 | if (url.contains("freedium.cfd") && !url.contains("freedium-mirror.cfd")) { 163 | Log.d(TAG, "Primary domain failed, switching to mirror..."); 164 | 165 | // Show loading overlay instead of Toast 166 | if (loadingOverlay != null) { 167 | loadingOverlay.setVisibility(View.VISIBLE); 168 | if (loadingText != null) { 169 | loadingText.setText("Switching to mirror server..."); 170 | } 171 | } 172 | 173 | String mirrorUrl = url.replace("freedium.cfd", "freedium-mirror.cfd"); 174 | view.loadUrl(mirrorUrl); 175 | return; 176 | } 177 | 178 | showError(); 179 | } 180 | } 181 | 182 | @Override 183 | public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) { 184 | String url = error.getUrl(); 185 | // Accept SSL for freedium.cfd 186 | if (url != null && url.contains("freedium.cfd")) { 187 | handler.proceed(); 188 | } else { 189 | handler.cancel(); 190 | } 191 | } 192 | 193 | @Override 194 | public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { 195 | String url = request.getUrl().toString(); 196 | 197 | // Keep freedium and medium navigation in WebView 198 | if (url.contains("freedium.cfd") || url.contains("freedium-mirror.cfd") || url.contains("medium.com")) { 199 | return false; 200 | } 201 | 202 | // Open external links in browser 203 | try { 204 | startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url))); 205 | } catch (Exception e) { 206 | Log.e(TAG, "Failed to open URL: " + url, e); 207 | } 208 | return true; 209 | } 210 | }); 211 | 212 | // Progress updates 213 | webView.setWebChromeClient(new WebChromeClient() { 214 | @Override 215 | public void onProgressChanged(WebView view, int newProgress) { 216 | progressBar.setProgress(newProgress); 217 | if (newProgress == 100) { 218 | hideLoading(); 219 | } 220 | } 221 | }); 222 | } 223 | 224 | private void setupButtons() { 225 | retryButton.setOnClickListener(v -> { 226 | errorLayout.setVisibility(View.GONE); 227 | webView.setVisibility(View.VISIBLE); 228 | if (currentUrl != null) { 229 | webView.loadUrl(currentUrl); 230 | } 231 | }); 232 | 233 | // Hide proxy buttons - not needed for most users 234 | tryProxyButton.setVisibility(View.GONE); 235 | 236 | // Configure alternative button for mirror 237 | tryAlternativeButton.setText("Try Mirror Server"); 238 | tryAlternativeButton.setVisibility(View.VISIBLE); 239 | tryAlternativeButton.setOnClickListener(v -> { 240 | if (webView.getUrl() != null) { 241 | String current = webView.getUrl(); 242 | String newUrl; 243 | if (current.contains("freedium-mirror.cfd")) { 244 | // If already on mirror, switch back to primary (toggle behavior) 245 | newUrl = current.replace("freedium-mirror.cfd", "freedium.cfd"); 246 | if (loadingText != null) loadingText.setText("Switching to primary server..."); 247 | } else { 248 | // Switch to mirror 249 | newUrl = current.replace("freedium.cfd", "freedium-mirror.cfd"); 250 | if (loadingText != null) loadingText.setText("Switching to mirror server..."); 251 | } 252 | 253 | if (loadingOverlay != null) loadingOverlay.setVisibility(View.VISIBLE); 254 | errorLayout.setVisibility(View.GONE); 255 | webView.setVisibility(View.VISIBLE); 256 | webView.loadUrl(newUrl); 257 | } else if (currentUrl != null) { 258 | // Fallback if WebView URL is null 259 | String newUrl = currentUrl.replace("freedium.cfd", "freedium-mirror.cfd"); 260 | webView.loadUrl(newUrl); 261 | } 262 | }); 263 | 264 | blockingInfoCard.setVisibility(View.GONE); 265 | } 266 | 267 | private void loadUrl() { 268 | Intent intent = getIntent(); 269 | currentUrl = intent.getStringExtra("url"); 270 | originalUrl = intent.getStringExtra("originalUrl"); 271 | 272 | if (currentUrl != null && !currentUrl.isEmpty()) { 273 | webView.loadUrl(currentUrl); 274 | } else { 275 | showError(); 276 | } 277 | } 278 | 279 | private void showLoading() { 280 | progressBar.setVisibility(View.VISIBLE); 281 | progressBar.setIndeterminate(false); 282 | progressBar.setProgress(0); 283 | } 284 | 285 | private void hideLoading() { 286 | progressBar.setVisibility(View.GONE); 287 | } 288 | 289 | private void showError() { 290 | errorLayout.setVisibility(View.VISIBLE); 291 | webView.setVisibility(View.GONE); 292 | errorMessage.setText("Unable to load article. Please check your connection and try again."); 293 | } 294 | 295 | private void openInBrowser() { 296 | String url = webView.getUrl() != null ? webView.getUrl() : currentUrl; 297 | if (url != null) { 298 | startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url))); 299 | } 300 | } 301 | 302 | private void shareArticle() { 303 | String url = originalUrl != null ? originalUrl : webView.getUrl(); 304 | String title = webView.getTitle(); 305 | 306 | Intent shareIntent = new Intent(Intent.ACTION_SEND); 307 | shareIntent.setType("text/plain"); 308 | shareIntent.putExtra(Intent.EXTRA_SUBJECT, title); 309 | shareIntent.putExtra(Intent.EXTRA_TEXT, url); 310 | startActivity(Intent.createChooser(shareIntent, "Share article")); 311 | } 312 | 313 | @Override 314 | public void onBackPressed() { 315 | if (webView.canGoBack()) { 316 | webView.goBack(); 317 | } else { 318 | super.onBackPressed(); 319 | } 320 | } 321 | 322 | @Override 323 | protected void onDestroy() { 324 | if (webView != null) { 325 | webView.destroy(); 326 | } 327 | super.onDestroy(); 328 | } 329 | } 330 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/mediumunlocker/MainActivity.java: -------------------------------------------------------------------------------- 1 | package com.inulute.mediumunlocker; 2 | 3 | import android.content.ClipData; 4 | import android.content.ClipboardManager; 5 | import android.content.Context; 6 | import android.content.Intent; 7 | import android.content.SharedPreferences; 8 | import android.content.pm.PackageInfo; 9 | import android.content.pm.PackageManager; 10 | import android.net.Uri; 11 | import android.os.Bundle; 12 | import android.util.Log; 13 | import android.view.KeyEvent; 14 | import android.view.View; 15 | import android.view.inputmethod.EditorInfo; 16 | import android.widget.Toast; 17 | 18 | import android.app.Dialog; 19 | import android.graphics.Color; 20 | import android.graphics.drawable.ColorDrawable; 21 | import android.view.LayoutInflater; 22 | import android.view.Window; 23 | import android.widget.TextView; 24 | 25 | import androidx.appcompat.app.AppCompatActivity; 26 | 27 | import com.google.android.material.button.MaterialButton; 28 | import com.google.android.material.textfield.TextInputEditText; 29 | 30 | import org.json.JSONObject; 31 | 32 | import java.io.BufferedReader; 33 | import java.io.InputStreamReader; 34 | import java.net.HttpURLConnection; 35 | import java.net.URL; 36 | import java.util.concurrent.ExecutorService; 37 | import java.util.concurrent.Executors; 38 | 39 | public class MainActivity extends AppCompatActivity { 40 | 41 | private static final String TAG = "MainActivity"; 42 | 43 | // Primary: Shields.io (no rate limit) 44 | private static final String SHIELDS_API_URL = "https://img.shields.io/github/v/release/inulute/medium-unlocker.json"; 45 | // Backup: GitHub API (has rate limit) 46 | private static final String GITHUB_API_URL = "https://api.github.com/repos/inulute/medium-unlocker/releases/latest"; 47 | private static final String GITHUB_RELEASES_URL = "https://github.com/inulute/medium-unlocker/releases/latest"; 48 | 49 | private static final String PREFS_NAME = "MediumUnlockerPrefs"; 50 | private static final String PREF_SKIP_VERSION = "skip_version"; 51 | private static final String PREF_POPUP_SHOWN_VERSION = "popup_shown_version"; 52 | private static final String PREF_CACHED_VERSION = "cached_latest_version"; 53 | private static final String PREF_LAST_CHECK = "last_update_check"; 54 | private static final String PREF_USE_MIRROR = "use_mirror"; 55 | private static final long UPDATE_CHECK_INTERVAL = 6 * 60 * 60 * 1000; // 6 hours 56 | 57 | private TextInputEditText urlInput; 58 | private MaterialButton unlockButton; 59 | private MaterialButton aboutButton; 60 | private MaterialButton supportButton; 61 | private MaterialButton githubButton; 62 | private MaterialButton updateButton; 63 | 64 | private ExecutorService executor; 65 | 66 | // Pending update info 67 | private String pendingUpdateVersion = null; 68 | private String pendingUpdateUrl = null; 69 | 70 | @Override 71 | protected void onCreate(Bundle savedInstanceState) { 72 | super.onCreate(savedInstanceState); 73 | setContentView(R.layout.activity_main); 74 | 75 | executor = Executors.newSingleThreadExecutor(); 76 | 77 | initializeViews(); 78 | setupListeners(); 79 | handleIntent(getIntent()); 80 | 81 | // Check for updates in background 82 | checkForUpdates(); 83 | } 84 | 85 | private void initializeViews() { 86 | urlInput = findViewById(R.id.urlInput); 87 | unlockButton = findViewById(R.id.unlockButton); 88 | aboutButton = findViewById(R.id.aboutButton); 89 | supportButton = findViewById(R.id.supportButton); 90 | githubButton = findViewById(R.id.githubButton); 91 | updateButton = findViewById(R.id.updateButton); 92 | } 93 | 94 | private void setupListeners() { 95 | unlockButton.setOnClickListener(v -> processUrl()); 96 | 97 | aboutButton.setOnClickListener(v -> showAboutDialog()); 98 | 99 | // Top bar buttons 100 | updateButton.setOnClickListener(v -> showUpdateDialog()); 101 | 102 | supportButton.setOnClickListener(v -> openUrl("https://support.inulute.com")); 103 | 104 | githubButton.setOnClickListener(v -> openUrl("https://github.com/inulute/medium-unlocker")); 105 | 106 | // Handle keyboard "Go" button 107 | urlInput.setOnEditorActionListener((v, actionId, event) -> { 108 | if (actionId == EditorInfo.IME_ACTION_GO || 109 | (event != null && event.getKeyCode() == KeyEvent.KEYCODE_ENTER)) { 110 | processUrl(); 111 | return true; 112 | } 113 | return false; 114 | }); 115 | 116 | // Auto-paste from clipboard if it contains a Medium URL 117 | tryAutoPasteFromClipboard(); 118 | } 119 | 120 | private void openUrl(String url) { 121 | try { 122 | Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); 123 | startActivity(intent); 124 | } catch (Exception e) { 125 | Log.e(TAG, "Failed to open URL: " + url, e); 126 | Toast.makeText(this, "Could not open link", Toast.LENGTH_SHORT).show(); 127 | } 128 | } 129 | 130 | @Override 131 | protected void onNewIntent(Intent intent) { 132 | super.onNewIntent(intent); 133 | setIntent(intent); 134 | handleIntent(intent); 135 | } 136 | 137 | private void handleIntent(Intent intent) { 138 | if (intent == null) return; 139 | 140 | String action = intent.getAction(); 141 | String type = intent.getType(); 142 | Log.d(TAG, "Handling intent - Action: " + action + ", Type: " + type); 143 | 144 | // Handle URL shared from browser or other apps 145 | if (Intent.ACTION_VIEW.equals(action)) { 146 | Uri data = intent.getData(); 147 | if (data != null) { 148 | String url = data.toString(); 149 | Log.d(TAG, "Received VIEW intent with URL: " + url); 150 | processAndOpenUrl(url); 151 | } 152 | } 153 | // Handle text/URL shared via share menu 154 | else if (Intent.ACTION_SEND.equals(action) && "text/plain".equals(type)) { 155 | String sharedText = intent.getStringExtra(Intent.EXTRA_TEXT); 156 | Log.d(TAG, "Received SEND intent with text: " + sharedText); 157 | if (sharedText != null) { 158 | String url = extractUrl(sharedText); 159 | if (url != null && isMediumUrl(url)) { 160 | Log.d(TAG, "Valid Medium URL found in shared text: " + url); 161 | processAndOpenUrl(url); 162 | } else { 163 | Log.w(TAG, "No valid Medium URL found in shared text"); 164 | urlInput.setText(sharedText); 165 | Toast.makeText(this, "Please paste a valid Medium URL", Toast.LENGTH_SHORT).show(); 166 | } 167 | } 168 | } 169 | } 170 | 171 | private void tryAutoPasteFromClipboard() { 172 | ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); 173 | if (clipboard != null && clipboard.hasPrimaryClip()) { 174 | ClipData.Item item = clipboard.getPrimaryClip().getItemAt(0); 175 | if (item != null && item.getText() != null) { 176 | String clipText = item.getText().toString(); 177 | String url = extractUrl(clipText); 178 | if (url != null && isMediumUrl(url)) { 179 | urlInput.setText(url); 180 | urlInput.setSelection(url.length()); 181 | } 182 | } 183 | } 184 | } 185 | 186 | private void processUrl() { 187 | String url = urlInput.getText() != null ? urlInput.getText().toString().trim() : ""; 188 | Log.d(TAG, "Processing URL from input: " + url); 189 | 190 | if (url.isEmpty()) { 191 | Toast.makeText(this, "Please enter a URL", Toast.LENGTH_SHORT).show(); 192 | return; 193 | } 194 | 195 | // Extract URL if text contains other content 196 | String extractedUrl = extractUrl(url); 197 | if (extractedUrl != null) { 198 | Log.d(TAG, "Extracted URL: " + extractedUrl); 199 | url = extractedUrl; 200 | } 201 | 202 | if (!isMediumUrl(url)) { 203 | Log.w(TAG, "Not a Medium URL: " + url); 204 | Toast.makeText(this, getString(R.string.invalid_url), Toast.LENGTH_SHORT).show(); 205 | return; 206 | } 207 | 208 | processAndOpenUrl(url); 209 | } 210 | 211 | private void processAndOpenUrl(String mediumUrl) { 212 | Log.d(TAG, "Processing Medium URL: " + mediumUrl); 213 | String freediumUrl = convertToFreedium(mediumUrl); 214 | Log.d(TAG, "Converted to Freedium URL: " + freediumUrl); 215 | 216 | Intent intent = new Intent(this, WebViewActivity.class); 217 | intent.putExtra("url", freediumUrl); 218 | intent.putExtra("originalUrl", mediumUrl); 219 | startActivity(intent); 220 | 221 | // Clear the input for next use 222 | if (urlInput != null) { 223 | urlInput.setText(""); 224 | } 225 | } 226 | 227 | private String convertToFreedium(String mediumUrl) { 228 | SharedPreferences prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE); 229 | boolean useMirror = prefs.getBoolean(PREF_USE_MIRROR, true); // Default to true 230 | String domain = useMirror ? "freedium-mirror.cfd" : "freedium.cfd"; 231 | return "https://" + domain + "/" + mediumUrl; 232 | } 233 | 234 | private boolean isMediumUrl(String url) { 235 | if (url == null || url.isEmpty()) return false; 236 | 237 | String lowerUrl = url.toLowerCase(); 238 | return lowerUrl.contains("medium.com") || 239 | lowerUrl.matches(".*://[a-zA-Z0-9-]+\\.medium\\.com.*"); 240 | } 241 | 242 | private String extractUrl(String text) { 243 | if (text == null) return null; 244 | 245 | String urlPattern = "(https?://[^\\s]+)"; 246 | java.util.regex.Pattern pattern = java.util.regex.Pattern.compile(urlPattern); 247 | java.util.regex.Matcher matcher = pattern.matcher(text); 248 | 249 | if (matcher.find()) { 250 | return matcher.group(1); 251 | } 252 | 253 | if (text.toLowerCase().contains("medium.com")) { 254 | if (!text.startsWith("http")) { 255 | return "https://" + text; 256 | } 257 | return text; 258 | } 259 | 260 | return null; 261 | } 262 | 263 | // ==================== Update Checker ==================== 264 | 265 | private void checkForUpdates() { 266 | SharedPreferences prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE); 267 | long lastCheck = prefs.getLong(PREF_LAST_CHECK, 0); 268 | long now = System.currentTimeMillis(); 269 | String currentVersion = getAppVersion(); 270 | 271 | // Check if we should use cached version or fetch new 272 | if (now - lastCheck < UPDATE_CHECK_INTERVAL) { 273 | // Use cached version 274 | String cachedVersion = prefs.getString(PREF_CACHED_VERSION, ""); 275 | Log.d(TAG, "Using cached version: " + cachedVersion); 276 | 277 | if (!cachedVersion.isEmpty() && isNewerVersion(currentVersion, cachedVersion)) { 278 | String skipVersion = prefs.getString(PREF_SKIP_VERSION, ""); 279 | if (!cachedVersion.equals(skipVersion)) { 280 | pendingUpdateVersion = cachedVersion; 281 | pendingUpdateUrl = GITHUB_RELEASES_URL; 282 | updateButton.setVisibility(View.VISIBLE); 283 | Log.d(TAG, "Update button visible (from cache)"); 284 | } 285 | } 286 | return; 287 | } 288 | 289 | // Fetch from network 290 | Log.d(TAG, "Starting update check from network..."); 291 | 292 | executor.execute(() -> { 293 | try { 294 | Log.d(TAG, "Current app version: " + currentVersion); 295 | 296 | // Try shields.io first (no rate limit) 297 | String latestVersion = fetchVersionFromShields(); 298 | 299 | // Fallback to GitHub API if shields.io fails 300 | if (latestVersion == null) { 301 | Log.d(TAG, "Shields.io failed, trying GitHub API..."); 302 | latestVersion = fetchVersionFromGitHub(); 303 | } 304 | 305 | if (latestVersion != null) { 306 | Log.d(TAG, "Latest version from remote: " + latestVersion); 307 | 308 | // Cache the response 309 | prefs.edit() 310 | .putString(PREF_CACHED_VERSION, latestVersion) 311 | .putLong(PREF_LAST_CHECK, now) 312 | .apply(); 313 | 314 | if (isNewerVersion(currentVersion, latestVersion)) { 315 | String skipVersion = prefs.getString(PREF_SKIP_VERSION, ""); 316 | String popupShownVersion = prefs.getString(PREF_POPUP_SHOWN_VERSION, ""); 317 | Log.d(TAG, "New version available! Current: " + currentVersion + ", Latest: " + latestVersion); 318 | 319 | if (!latestVersion.equals(skipVersion)) { 320 | pendingUpdateVersion = latestVersion; 321 | pendingUpdateUrl = GITHUB_RELEASES_URL; 322 | 323 | final boolean shouldShowPopup = !latestVersion.equals(popupShownVersion); 324 | final String versionToSave = latestVersion; 325 | 326 | runOnUiThread(() -> { 327 | updateButton.setVisibility(View.VISIBLE); 328 | Log.d(TAG, "Update button now visible"); 329 | 330 | // Show popup only once per version 331 | if (shouldShowPopup) { 332 | showUpdateDialog(); 333 | // Mark popup as shown for this version 334 | prefs.edit() 335 | .putString(PREF_POPUP_SHOWN_VERSION, versionToSave) 336 | .apply(); 337 | } 338 | }); 339 | } 340 | } else { 341 | Log.d(TAG, "App is up to date"); 342 | } 343 | } else { 344 | Log.e(TAG, "Failed to fetch version from all sources"); 345 | } 346 | } catch (Exception e) { 347 | Log.e(TAG, "Error checking for updates", e); 348 | } 349 | }); 350 | } 351 | 352 | private String fetchVersionFromShields() { 353 | HttpURLConnection connection = null; 354 | try { 355 | URL url = new URL(SHIELDS_API_URL); 356 | connection = (HttpURLConnection) url.openConnection(); 357 | connection.setRequestMethod("GET"); 358 | connection.setRequestProperty("Accept", "application/json"); 359 | connection.setConnectTimeout(10000); 360 | connection.setReadTimeout(10000); 361 | 362 | int responseCode = connection.getResponseCode(); 363 | Log.d(TAG, "Shields.io response code: " + responseCode); 364 | 365 | if (responseCode == HttpURLConnection.HTTP_OK) { 366 | BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream())); 367 | StringBuilder response = new StringBuilder(); 368 | String line; 369 | while ((line = reader.readLine()) != null) { 370 | response.append(line); 371 | } 372 | reader.close(); 373 | 374 | Log.d(TAG, "Shields.io response: " + response.toString()); 375 | 376 | JSONObject json = new JSONObject(response.toString()); 377 | String version = json.getString("value"); 378 | // Clean version string (remove 'v' prefix if present) 379 | version = version.replace("v", "").trim(); 380 | Log.d(TAG, "Parsed version from shields.io: " + version); 381 | return version; 382 | } 383 | } catch (Exception e) { 384 | Log.e(TAG, "Failed to fetch from shields.io", e); 385 | } finally { 386 | if (connection != null) { 387 | connection.disconnect(); 388 | } 389 | } 390 | return null; 391 | } 392 | 393 | private String fetchVersionFromGitHub() { 394 | HttpURLConnection connection = null; 395 | try { 396 | URL url = new URL(GITHUB_API_URL); 397 | connection = (HttpURLConnection) url.openConnection(); 398 | connection.setRequestMethod("GET"); 399 | connection.setRequestProperty("Accept", "application/vnd.github.v3+json"); 400 | connection.setConnectTimeout(10000); 401 | connection.setReadTimeout(10000); 402 | 403 | int responseCode = connection.getResponseCode(); 404 | Log.d(TAG, "GitHub API response code: " + responseCode); 405 | 406 | if (responseCode == HttpURLConnection.HTTP_OK) { 407 | BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream())); 408 | StringBuilder response = new StringBuilder(); 409 | String line; 410 | while ((line = reader.readLine()) != null) { 411 | response.append(line); 412 | } 413 | reader.close(); 414 | 415 | JSONObject json = new JSONObject(response.toString()); 416 | String tagName = json.getString("tag_name"); 417 | // Clean version string 418 | tagName = tagName.replace("v", "").trim(); 419 | Log.d(TAG, "Parsed version from GitHub: " + tagName); 420 | return tagName; 421 | } 422 | } catch (Exception e) { 423 | Log.e(TAG, "Failed to fetch from GitHub API", e); 424 | } finally { 425 | if (connection != null) { 426 | connection.disconnect(); 427 | } 428 | } 429 | return null; 430 | } 431 | 432 | private String getAppVersion() { 433 | try { 434 | PackageInfo pInfo = getPackageManager().getPackageInfo(getPackageName(), 0); 435 | return pInfo.versionName; 436 | } catch (PackageManager.NameNotFoundException e) { 437 | return "0.0"; 438 | } 439 | } 440 | 441 | private boolean isNewerVersion(String current, String latest) { 442 | try { 443 | // Handle null or empty 444 | if (current == null || latest == null || current.isEmpty() || latest.isEmpty()) { 445 | return false; 446 | } 447 | 448 | String[] currentParts = current.split("\\."); 449 | String[] latestParts = latest.split("\\."); 450 | 451 | int maxLength = Math.max(currentParts.length, latestParts.length); 452 | for (int i = 0; i < maxLength; i++) { 453 | int currentNum = 0; 454 | int latestNum = 0; 455 | 456 | if (i < currentParts.length) { 457 | String part = currentParts[i].replaceAll("[^0-9]", ""); 458 | if (!part.isEmpty()) currentNum = Integer.parseInt(part); 459 | } 460 | if (i < latestParts.length) { 461 | String part = latestParts[i].replaceAll("[^0-9]", ""); 462 | if (!part.isEmpty()) latestNum = Integer.parseInt(part); 463 | } 464 | 465 | if (latestNum > currentNum) return true; 466 | if (latestNum < currentNum) return false; 467 | } 468 | } catch (Exception e) { 469 | Log.e(TAG, "Error comparing versions: " + current + " vs " + latest, e); 470 | } 471 | return false; 472 | } 473 | 474 | private void showUpdateDialog() { 475 | if (pendingUpdateVersion == null || pendingUpdateUrl == null) { 476 | Toast.makeText(this, "No update available", Toast.LENGTH_SHORT).show(); 477 | return; 478 | } 479 | 480 | Dialog dialog = new Dialog(this); 481 | dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); 482 | dialog.setContentView(R.layout.dialog_update); 483 | dialog.getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); 484 | dialog.getWindow().setLayout( 485 | android.view.ViewGroup.LayoutParams.MATCH_PARENT, 486 | android.view.ViewGroup.LayoutParams.WRAP_CONTENT 487 | ); 488 | dialog.setCancelable(true); 489 | 490 | // Set version text 491 | TextView versionText = dialog.findViewById(R.id.updateVersionText); 492 | versionText.setText("v" + pendingUpdateVersion + " is now available"); 493 | 494 | // Skip button (left) 495 | MaterialButton skipButton = dialog.findViewById(R.id.updateSkipButton); 496 | skipButton.setOnClickListener(v -> { 497 | getSharedPreferences(PREFS_NAME, MODE_PRIVATE) 498 | .edit() 499 | .putString(PREF_SKIP_VERSION, pendingUpdateVersion) 500 | .apply(); 501 | updateButton.setVisibility(View.GONE); 502 | pendingUpdateVersion = null; 503 | pendingUpdateUrl = null; 504 | dialog.dismiss(); 505 | }); 506 | 507 | // Cancel button 508 | MaterialButton cancelButton = dialog.findViewById(R.id.updateCancelButton); 509 | cancelButton.setOnClickListener(v -> dialog.dismiss()); 510 | 511 | // Update button 512 | MaterialButton updateNowButton = dialog.findViewById(R.id.updateNowButton); 513 | updateNowButton.setOnClickListener(v -> { 514 | openUrl(pendingUpdateUrl); 515 | dialog.dismiss(); 516 | }); 517 | 518 | dialog.show(); 519 | } 520 | 521 | // ==================== About Dialog ==================== 522 | 523 | private void showAboutDialog() { 524 | String version = getAppVersion(); 525 | 526 | Dialog dialog = new Dialog(this); 527 | dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); 528 | dialog.setContentView(R.layout.dialog_about); 529 | dialog.getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); 530 | dialog.getWindow().setLayout( 531 | android.view.ViewGroup.LayoutParams.MATCH_PARENT, 532 | android.view.ViewGroup.LayoutParams.WRAP_CONTENT 533 | ); 534 | dialog.setCancelable(true); 535 | 536 | // Set version text 537 | TextView versionText = dialog.findViewById(R.id.aboutVersion); 538 | versionText.setText("Version " + version); 539 | 540 | // GitHub button 541 | MaterialButton githubButton = dialog.findViewById(R.id.aboutGithubButton); 542 | githubButton.setOnClickListener(v -> { 543 | openUrl("https://github.com/inulute/medium-unlocker"); 544 | dialog.dismiss(); 545 | }); 546 | 547 | // Share button 548 | MaterialButton shareButton = dialog.findViewById(R.id.aboutShareButton); 549 | shareButton.setOnClickListener(v -> { 550 | shareApp(); 551 | dialog.dismiss(); 552 | }); 553 | 554 | // Close button 555 | MaterialButton closeButton = dialog.findViewById(R.id.aboutCloseButton); 556 | closeButton.setOnClickListener(v -> dialog.dismiss()); 557 | 558 | dialog.show(); 559 | } 560 | 561 | private void shareApp() { 562 | Intent shareIntent = new Intent(Intent.ACTION_SEND); 563 | shareIntent.setType("text/plain"); 564 | shareIntent.putExtra(Intent.EXTRA_SUBJECT, "Medium Unlocker"); 565 | shareIntent.putExtra(Intent.EXTRA_TEXT, 566 | "Check out Medium Unlocker - Read Medium articles without restrictions!\n\nhttps://github.com/inulute/medium-unlocker"); 567 | startActivity(Intent.createChooser(shareIntent, "Share via")); 568 | } 569 | 570 | @Override 571 | protected void onDestroy() { 572 | super.onDestroy(); 573 | if (executor != null) { 574 | executor.shutdown(); 575 | } 576 | } 577 | } 578 | --------------------------------------------------------------------------------