├── .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 |
3 |
4 | # Medium Unlocker
5 |
6 | **End-to-end paywall bypasser built from scratch around the freedium.cfd index.**
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
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 |
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 |
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 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
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 | setUrl('')}>
90 | ✕
91 |
92 | )}
93 |
94 |
95 |
96 | setUseMirror(e.target.checked)}
101 | style={{ width: 'auto', margin: 0 }}
102 | />
103 |
104 | Use Mirror Server (freedium-mirror.cfd)
105 |
106 |
107 |
108 |
109 |
110 | Download App
111 |
112 |
113 | Unlock Article
114 |
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 |
--------------------------------------------------------------------------------