diff --git a/.classpath b/.classpath deleted file mode 100644 index dec02b3..0000000 --- a/.classpath +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/.gitignore b/.gitignore index 64d3c97..38641d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,23 +1,42 @@ # Built application files +build/ +app/apk/ +app/debug/ +app/release/ +app/beta/ + +# Crashlytics configuations +/*/com_crashlytics_export_strings.xml + +# Local configuration file (sdk path, etc) +local.properties +/*/local.properties + +# Gradle generated files +.gradle/ +gradlew.bat + +# Signing files +/*/.signing/ + +.idea/ + +*.iml + +# built application files *.apk *.ap_ -# Files for the Dalvik VM +# files for the dex VM *.dex # Java class files *.class -# Generated files -bin/ -gen/ - -# Gradle files -.gradle/ -build/ +# built native files +*.o +*.so -# Local configuration file (sdk path, etc) -local.properties +# ProGuard mapping files +*_mapping.txt -# Proguard folder generated by Eclipse -proguard/ diff --git a/.project b/.project deleted file mode 100644 index dd5d4a1..0000000 --- a/.project +++ /dev/null @@ -1,33 +0,0 @@ - - - andwebserver - - - - - - com.android.ide.eclipse.adt.ResourceManagerBuilder - - - - - com.android.ide.eclipse.adt.PreCompilerBuilder - - - - - org.eclipse.jdt.core.javabuilder - - - - - com.android.ide.eclipse.adt.ApkBuilder - - - - - - com.android.ide.eclipse.adt.AndroidNature - org.eclipse.jdt.core.javanature - - diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs deleted file mode 100644 index 885a520..0000000 --- a/.settings/org.eclipse.jdt.core.prefs +++ /dev/null @@ -1,5 +0,0 @@ -#Sun Jul 19 12:56:49 CEST 2009 -eclipse.preferences.version=1 -org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.5 -org.eclipse.jdt.core.compiler.compliance=1.5 -org.eclipse.jdt.core.compiler.source=1.5 diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..032d695 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,15 @@ +language: android +android: + components: + - tools + - platform-tools + # The BuildTools version used by your project + - build-tools-28.0.3 + # The SDK version used to compile your project + - android-28 +before_script: + - chmod ug+x ./gradlew + #- sed -i -e '/signingConfigs.debug/d' app/build.gradle +before_install: + - yes | sdkmanager "platforms;android-28" + diff --git a/AndroidManifest.xml b/AndroidManifest.xml deleted file mode 100644 index 3cdc2c1..0000000 --- a/AndroidManifest.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7b2c49b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,138 @@ +#### v07.00.00 (g) +* Support Android O/P +* Fallback bind to loopback +* Several fixes + +#### v06.03.00b (g1b3161b) +* Fix get AP IP address +* Fix screen state after switcing off service from notification + +#### v06.02.00b (g521f4fb) +* Request permission if need +* FOREGROUND_SERVICE permission if need +* Fix UTF-8 encoded document root + +#### v06.01.00b (g728324c) +* Fix notification for Android 9 (P) (API 28) +* Bind to loopback as fallback +* minSdk is 10 + +#### v06.00.00f (gbf50d54) +#### v05.01.02f (g64632fd) +#### v05.01.01f (g9538229) +* Fake versions to trigger F-Driod publish + +#### v05.01.00b (g462fd90) +* Update gradle configuration for Android Studio 3.2 + +#### v05.00.00 (g2145b67) +* Change lWS.QR call according to new API + +#### v04.02.00 (g7a05764) +* Increase target API from 21 to 26 + +#### v04.01.00 (g1bda4e4) +* Change lWS.QR call according to new API + +### v04.00.00 (gd747f64) +* HEAD request and Last modified (incremental download) +* Multipart range (video seek) +* Directory index sort by name, date and size. New icons. +* Add MIME types for audio files +* Several fixes. + +#### v03.06.00b (g372ca97) +* Default favicon.ico +* Prepare code for release + +#### v03.05.00b (g4ccb846) +* New design of directory index page +* Fix bug with non ASCII file names v03.04.00b (g0efab1b) +* Tweak directory listing design + +#### v03.03.00b (g6897fe2) +* Directory listing sort + +#### v03.02.00b (g13af687) +Since this version incremental download is possible. +* Support Last-Modified +* Support HEAD request + +#### v03.01.00b (g6141abe) +* Support partial content (allow to seek video). + +### v03.00.00 (g579b06e) +* Add share URL by QR code. +* Offer to install external program if need +* Click on main screen variable values call corresponding settings. + +#### v02.03.00b (g7fb3791) +* Offer to install OI File manager in attempt to use and it is not installed. + +#### v02.02.00b (ge329d5f) +* support [QR code plugin](http://play.google.com/store/apps/details?id=net.basov.lws.qr.gpm") +* Interface tweaks + +#### v02.01.00b (ga2077c8) +* Add QR Code URL function +* Fix service restart if configuration changed + +### v02.00.00 (g36b7530) +* Another service model to stable run in background +* New 'Send URL' button +* Add button to stop service from notification +* More informative logging +* Minimal Android version is 4.1 now +* Check and fix wrong configuration values +* Fix several NPE, ANR and other bugs + +#### v01.11.00b (g63a4a2d) +* Add tethering AP switch off detection +* More informative logging + +#### v01.10.00b (g8c53bf8) +* Fix 'Use OI File Manager' preference issue +* Several build system tweaks + +#### v01.09.00b (g5b9da2a) +* New 'Send URL' button + +#### v01.08.00b (g6a995d3) +* Fix issue with return from preferences + +#### v01.07.00b (g68e6e24) +* Beta release to test ProGuard settings + +#### v01.06.00b (ca00cdb) +* Display connection diagnostic on log screen +* Fix default document root creation on legacy devices +* Fix restart on configuration changed on legacy devices + +#### v01.05.00b (8fcb81b) +* Improve stop service from notification issue +* Make log selectable +* Fix several bugs + +#### v01.04.00b (g7bf53e8) +* Display application version at start +* Fix configuration check and reset to default + +#### v01.03.00b (g722356c) +* Fix NumberFormatException exception + +#### v01.02.00b (g86e678b) +* Improve service stability +* Add button to stop service from notification +* Fix several NPE + +#### v01.01.00b (ga889b46) +* Another service model to stable run in background +* Check and fix configuration values +* More informative logging +* Fix several bugs + +### v00.01.01b (g9efcd5f) +* Fix 0 content length + +### v01.00.00 (g06f4f90) +* 1-st public release. diff --git a/LICENSE b/LICENSE index 70566f2..867f547 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -GNU GENERAL PUBLIC LICENSE + GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. @@ -631,8 +631,9 @@ to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - {one line to give the program's name and a brief idea of what it does.} - Copyright (C) {year} {name of author} + lightweight Web Server (lWS) for Android. + Copyright (C) 2017,2018 Mikhail Basov + Copyright (C) 2009-2014 Markus Bode This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -652,7 +653,9 @@ Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: - {project} Copyright (C) {year} {fullname} + lWS Copyright (C) 2017,2018 Mikhail Basov + androidwebserver Copyright (C) 2009-2014 Markus Bode + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. @@ -671,4 +674,4 @@ into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read -. \ No newline at end of file +. diff --git a/README.md b/README.md index 2bd629e..d425ee8 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,56 @@ -Webserver for Android -===================== - -Smallest webserver for android. Works only when connected to a wifi-network. Use at your own risk and feel free to contribute :-) - -Twitter: [@bodeme](https://twitter.com/bodeme) - -License -======= -Android Webserver is licensed under the [GPLv3 License](COPYING). - -Features -======== -* GET-Requests -* Handling of ASCII and BINARY Files -* Tasker: List all tasks (http://example.org/tasker/) -* Tasker: Execute task (http://example.org/tasker/taskname) +## lightweight Web Server (lWS) for Android + +Available on Google Play +Tavis CI Build Status +Available on F-Droid + + + + + + +
lWS

lWS

+

It is ...

+
    +
  • ... Web Server for static content.
  • +
  • ... lightweight. APK size less then 100 Kb.
  • +
  • ... as simple as possible. Only essential features implemented.
  • +
  • ... open. Source code released under GPL-3.0.
  • +
  • ... personal solution. It is not optimized/tested for many parallel connections and large file transfer.
  • +
  • ... network state responsive. Require WiFi connected or tethering enabled. Service stop automatically if network disconnected.
  • +
  • ... connect to lopback interface if no other available.
  • +
+
+ +### Derivate from +This project based on [another open source project](https://github.com/bodeme/androidwebserver) +Unfortunately, original project didn't maintained for 3 years. + +### What is configurable +* Document root. Path may be entered as text or optional elected using OI File Manager. +* Port. May be from 1024 to 65535. Default is 8080 +In attempt to set wrong value parameter automatically set to default. + +### Document root +Document root by default set to application private directory. Example index file automatically created. It is safe configuration. You can place your pages in this directory. But be carefully! If you use Android 5.0 or above and deinstall the application this directory and it's content will be removed. + +### "Open in browser" and "Send URL" +After server starts you can press "Open in browser" button for check. +You can send working server URL to another device by Bluetooth, Android Beam, E-Mail and other way available on your device. + +### On screen log +The application has no permanent logging. I treat this as redundant functionality. I doing my best to make notification actual all time. On screen log actual only then application visible. Log screen may be cleared after returning from background. + +### Security warning +You can change document root to any readable point of file system, but you need to understand what are you doing. +Be careful: you could (suddenly?) create the configuration so way, than anyone on the same WiFi network could access to the data on your device either you don't like it. +All files from document root and below available for reading without any restrictions to anyone who connected to network and known URL of the server. + +### License +lWS is licensed under the [GPLv3 License](LICENSE) because [original project](https://github.com/bodeme/androidwebserver) +Directory listing sort based on [this project](https://github.com/wmentzel/table-sort) licensed under GPL-3.0 + +### Artwork +* File listing icons from [Feather project](https://feathericons.com/) released under MIT license. +* Application icon designed specially for this application. + diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..cc73e87 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,81 @@ +apply plugin: 'com.android.application' + +android { + + namespace "net.basov.lws" + + def last_tag = getGitRevParseInfo("describe --tags --abbrev=0") + def commit_count = getGitRevParseInfo("rev-list --count ${last_tag}..") + def current_commit = getGitRevParseInfo("rev-parse --short") + + applicationVariants.all { variant -> + variant.outputs.all { output -> + outputFileName = "lWS.${variant.versionName}.apk" + if (variant.getBuildType().isMinifyEnabled()) { + variant.assemble.doLast{ + copy { + from variant.mappingFile + into output.outputFile.parent + rename { String fileName -> + "lWS." + versionName + "_mapping.txt" + } + } + } + } + } + + } + + compileSdkVersion 34 + + defaultConfig { +// Keep two lines around applicationId unchanged to allow use patch for F-Droid build + + + applicationId "net.basov.lws" + + +// End of strings reserved for F-Droid patch + minSdkVersion 10 + targetSdkVersion 34 + + versionCode 70000 + versionName "07.00.00" + } + + buildTypes { + release { + minifyEnabled true + shrinkResources true + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt' + zipAlignEnabled true + applicationIdSuffix '.r' + versionNameSuffix "r-g" + current_commit + resValue "string", "git_describe", getGitRevParseInfo("describe --tags --abbrev=1") + } + beta { + minifyEnabled true + shrinkResources true + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt' + applicationIdSuffix '.b' + versionNameSuffix "b-g" + current_commit + debuggable false + resValue "string", "git_describe", getGitRevParseInfo("describe --tags --abbrev=1") + } + debug { + debuggable true + versionNameSuffix 'a-' + commit_count + "-g" + current_commit + resValue "string", "git_describe", getGitRevParseInfo("describe --tags --abbrev=1") + } + } +} + +dependencies { + testImplementation 'org.junit.jupiter:junit-jupiter:5.6.2' +} + +static def getGitRevParseInfo (what) { + def cmd = "git " + what + " HEAD" + def proc = cmd.execute () + proc.text.trim () +} diff --git a/app/icons/.gitignore b/app/icons/.gitignore new file mode 100644 index 0000000..c35faf2 --- /dev/null +++ b/app/icons/.gitignore @@ -0,0 +1,3 @@ +./res +*.png + diff --git a/app/icons/convert.sh b/app/icons/convert.sh new file mode 100755 index 0000000..c39207b --- /dev/null +++ b/app/icons/convert.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +icon="lws_ic" + +for size in 24 36 48 72 96 144 192 512; do + inkscape -z --export-background=#000000 --export-background-opacity=0 --export-png=${icon}_${size}.png --export-width=${size} --export-height=${size} $icon.svg +done + +for res in ldpi mdpi hdpi xhdpi xxhdpi xxxhdpi; do + mkdir -p ./res/mipmap-$res +done + +pngcrush -q ${icon}_36.png ./res/mipmap-ldpi/${icon}.png; rm ${icon}_36.png +pngcrush -q ${icon}_48.png ./res/mipmap-mdpi/${icon}.png; rm ${icon}_48.png +pngcrush -q ${icon}_72.png ./res/mipmap-hdpi/${icon}.png; rm ${icon}_72.png +pngcrush -q ${icon}_96.png ./res/mipmap-xhdpi/${icon}.png; rm ${icon}_96.png +pngcrush -q ${icon}_144.png ./res/mipmap-xxhdpi/${icon}.png; rm ${icon}_144.png +pngcrush -q ${icon}_192.png ./res/mipmap-xxxhdpi/${icon}.png; rm ${icon}_192.png + +pngcrush -q ${icon}_24.png ./res/${icon}_24.png; rm ${icon}_24.png; mv ./res/${icon}_24.png ./ +pngcrush -q ${icon}_512.png ./res/${icon}_512.png; rm ${icon}_512.png; mv ./res/${icon}_512.png ./ diff --git a/app/icons/lws_ic.svg b/app/icons/lws_ic.svg new file mode 100644 index 0000000..5c79a0e --- /dev/null +++ b/app/icons/lws_ic.svg @@ -0,0 +1,219 @@ + + + +image/svg+xml \ No newline at end of file diff --git a/app/proguard-rules.txt b/app/proguard-rules.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/proguard-rules.txt @@ -0,0 +1 @@ + diff --git a/app/src/betta/res/mipmap-xxxhdpi/lws_ic.png b/app/src/betta/res/mipmap-xxxhdpi/lws_ic.png new file mode 100644 index 0000000..6604a4c Binary files /dev/null and b/app/src/betta/res/mipmap-xxxhdpi/lws_ic.png differ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..0b31774 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/403.html b/app/src/main/assets/403.html new file mode 100644 index 0000000..872b3c7 --- /dev/null +++ b/app/src/main/assets/403.html @@ -0,0 +1,13 @@ + + + + + Error 403 + + + + + 403 - Forbidden + + + diff --git a/app/src/main/assets/404.html b/app/src/main/assets/404.html new file mode 100644 index 0000000..b3c7b5d --- /dev/null +++ b/app/src/main/assets/404.html @@ -0,0 +1,13 @@ + + + + + Error 404 + + + + + 404 - File or directory not found + + + diff --git a/app/src/main/assets/416.html b/app/src/main/assets/416.html new file mode 100644 index 0000000..35c7b51 --- /dev/null +++ b/app/src/main/assets/416.html @@ -0,0 +1,13 @@ + + + + + Error 416 + + + + + 416 - Range Not Satisfiable + + + diff --git a/app/src/main/assets/500.html b/app/src/main/assets/500.html new file mode 100644 index 0000000..6c918d4 --- /dev/null +++ b/app/src/main/assets/500.html @@ -0,0 +1,13 @@ + + + + + Error 500 + + + + + 500 - Internal server error + + + diff --git a/app/src/main/java/net/basov/lws/Constants.java b/app/src/main/java/net/basov/lws/Constants.java new file mode 100644 index 0000000..9ebe4ea --- /dev/null +++ b/app/src/main/java/net/basov/lws/Constants.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2017-2019 Mikhail Basov + * + * Licensed under the GNU General Public License v3 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package net.basov.lws; + +import java.util.HashMap; +import java.util.Map; + +/** + * Created by mvb on 12/18/17. + */ + +final class Constants { + public static final String LOG_TAG = "lWS"; + public static final String ACTION_STOP = "net.basov.lws.stop_service"; + public static final int NOTIFICATION_ID = 690927; + public static final int DIRECTORY_REQUEST = 170; + public static final int MAIN_SCREEN_REQUEST = 171; + public static final int STOP_SERVICE_REQUEST = 172; + public static final int GRANT_WRITE_EXTERNAL_STORAGE = 173; + public static final MimeType MIME_OCTAL = new MimeType("application/octet-stream", "file"); + public static final Map MIME = new HashMap(30, 1.0F) { + { + put("html", new MimeType("text/html; charset=utf-8", "web")); + put("css", new MimeType("text/css; charset=utf-8", "code")); + put("js", new MimeType("text/javascript; charset=utf-8", "code")); + put("txt", new MimeType("text/plain; charset=utf-8", "file-text")); + put("md", new MimeType("text/markdown; charset=utf-8", "file-text")); + put("gif", new MimeType("image/gif", "image")); + put("png", new MimeType("image/png", "image")); + put("jpg", new MimeType("image/jpeg", "image")); + put("bmp", new MimeType("image/bmp", "image")); + put("svg", new MimeType("image/svg+xml", "image")); + put("ico", new MimeType("image/x-icon", "image")); + put("zip", new MimeType("application/zip", "package")); + put("gz", new MimeType("application/gzip", "package")); + put("tgz", new MimeType("application/gzip", "package")); + put("pdf", new MimeType("application/pdf", "file-text")); + put("mp4", new MimeType("video/mp4", "video")); + put("avi", new MimeType("video/x-msvideo", "video")); + put("3gp", new MimeType("video/3gpp", "video")); + put("mp3", new MimeType("audio/mpeg", "music")); + put("ogg", new MimeType("audio/ogg", "music")); + put("wav", new MimeType("audio/wav", "music")); + put("flac", new MimeType("audio/flac", "music")); + put("java", new MimeType("text/plain", "code")); + put(".c", new MimeType("text/plain", "code")); + put(".cpp", new MimeType("text/plain", "code")); + put(".sh", new MimeType("text/plain", "code")); + put(".py", new MimeType("text/plain", "code")); + } + }; + + static class MimeType { + final String contentType; + final String kind; + + public MimeType(String contentType, String kind) { + this.contentType = contentType; + this.kind = kind; + } + } +} diff --git a/app/src/main/java/net/basov/lws/PreferencesActivity.java b/app/src/main/java/net/basov/lws/PreferencesActivity.java new file mode 100644 index 0000000..df97154 --- /dev/null +++ b/app/src/main/java/net/basov/lws/PreferencesActivity.java @@ -0,0 +1,222 @@ +/* + * Copyright (C) 2017-2019 Mikhail Basov + * + * Licensed under the GNU General Public License v3 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package net.basov.lws; + +/** + * Created by mvb on 6/22/17. + */ + +import android.content.ActivityNotFoundException; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Bundle; +import android.preference.EditTextPreference; +import android.preference.Preference; +import android.preference.PreferenceActivity; +import android.preference.PreferenceManager; +import android.preference.PreferenceScreen; +import android.util.Log; +import android.widget.Toast; + +import java.io.File; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; + +import static net.basov.lws.Constants.*; + +public class PreferencesActivity extends PreferenceActivity implements + SharedPreferences.OnSharedPreferenceChangeListener { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + addPreferencesFromResource(R.xml.preferences); + + SharedPreferences defSharedPref = + PreferenceManager.getDefaultSharedPreferences(this); + + Preference prefDocumentRoot = findPreference(getString(R.string.pk_document_root)); + prefDocumentRoot.setSummary(defSharedPref.getString(getString(R.string.pk_document_root), "")); + enableDirPicker( + prefDocumentRoot, + defSharedPref.getBoolean(getString(R.string.pk_use_directory_pick), false) + ); + + Preference prefPort = findPreference(getString(R.string.pk_port)); + prefPort.setSummary(defSharedPref.getString(getString(R.string.pk_port), "8080")); + + Intent incomingIntent = getIntent(); + Bundle incomingExtras = incomingIntent.getExtras(); + if (incomingExtras != null) { + int incomingIndex = incomingExtras.getInt("item"); + if (incomingIndex >= 0 && incomingIndex <= 2 ) { + PreferenceScreen screen = getPreferenceScreen(); + screen.onItemClick(null, null, incomingIndex , 0); + } + } + + } + + private void enableDirPicker(Preference p, Boolean enable) { + if (enable) { + p.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + ((EditTextPreference) preference).getDialog().dismiss(); + Intent intent = new Intent("org.openintents.action.PICK_DIRECTORY"); + intent.putExtra("org.openintents.extra.BUTTON_TEXT", "Select document root"); + try { + startActivityForResult(intent, DIRECTORY_REQUEST); + } catch (ActivityNotFoundException e) { + Toast.makeText(PreferencesActivity.this, + "OI File Manager not installed. Install or disable using.", + Toast.LENGTH_LONG + ).show(); + Intent i = new Intent(Intent.ACTION_VIEW); + i.setData(Uri.parse("market://details?id=org.openintents.filemanager")); + startActivity(i); + Log.w("lWS", "OI File Manager not found", e); + } + return true; + } + }); + } else { + p.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + return false; + } + }); + } + + } + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + if (requestCode == DIRECTORY_REQUEST && data != null) { + String newValue = null; + Uri uri = data.getData(); + if (uri != null) { + String path = uri.toString(); + if (path.toLowerCase().startsWith("file://")) { + newValue = path.replace("file://","") + "/"; + } + } + try { + newValue = URLDecoder.decode(newValue, "UTF-8"); + } catch (UnsupportedEncodingException e) { + Log.e("lWS", "Invalid document root picked up (URLDecoder)", e); + } + SharedPreferences sharedPreferences = + PreferenceManager.getDefaultSharedPreferences(this); + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.putString(getString(R.string.pk_document_root), newValue); + editor.putBoolean(getString(R.string.pk_pref_changed), true); + editor.commit(); + findPreference(getString(R.string.pk_document_root)).setSummary(newValue); + } + } + + @Override + protected void onResume() { + super.onResume(); + getPreferenceScreen().getSharedPreferences() + .registerOnSharedPreferenceChangeListener(this); + } + + @Override + protected void onPause() { + super.onPause(); + getPreferenceScreen().getSharedPreferences() + .unregisterOnSharedPreferenceChangeListener(this); + } + + @Override + public void onDestroy(){ + super.onDestroy(); + } + + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, + String key) { + Preference pref = findPreference(key); + SharedPreferences.Editor prefEdit = sharedPreferences.edit(); + + String pref_document_root = getString(R.string.pk_document_root); + if (pref_document_root.equals(key)) { + String defaultDocumentRoot = StartActivity.getFilesDir(this).getPath() + "/html/"; + String documentRoot = sharedPreferences.getString(pref_document_root, defaultDocumentRoot); + int docRootLength = documentRoot.length(); + if (! new File(documentRoot).canRead() || docRootLength == 0){ + documentRoot = defaultDocumentRoot; + docRootLength = documentRoot.length(); + Toast.makeText(PreferencesActivity.this, + "Document root doesn't exists. Set to default.", + Toast.LENGTH_LONG + ).show(); + Log.w("lWS", "Document root doesn't exists. Set to default."); + prefEdit.putString(getString(R.string.pk_document_root), defaultDocumentRoot).commit(); + } else if (documentRoot.charAt(docRootLength - 1) != '/') { + // existing directory readable with and without trailing slash + // but slash need for correct filename forming + documentRoot = documentRoot + "/"; + prefEdit.putString(getString(R.string.pk_document_root), documentRoot).commit(); + } + prefEdit.putBoolean(getString(R.string.pk_pref_changed), true).commit(); + pref.setSummary(documentRoot); + } + + String pref_port = getString(R.string.pk_port); + if (pref_port.equals(key)) { + Integer port; + String portAsString = sharedPreferences.getString(pref_port,"8080"); + try { + port = Integer.valueOf(portAsString); + } catch (NumberFormatException e) { + port = 8080; + Log.w(Constants.LOG_TAG, "Port preferences may be empty"); + } + if (port < 1024 || port > 65535 || portAsString.length() == 0) { + port = 8080; + portAsString = Integer.toString(port); + Toast.makeText(PreferencesActivity.this, + "Port less then 1024 or grate then 65535. Set to default.", + Toast.LENGTH_LONG + ).show(); + Log.w("lWS", "Port less then 1024 or grate then 65535. Set to default."); + prefEdit.putString(getString(R.string.pk_port), portAsString).commit(); + } + prefEdit.putBoolean(getString(R.string.pk_pref_changed), true).commit(); + pref.setSummary(portAsString); + } + + String pref_use_directory_pick = getString(R.string.pk_use_directory_pick); + if (pref_use_directory_pick.equals(key)) { + // don't set preferences changed flag if only use directory pick changed + Preference prefDocumentRoot = findPreference(getString(R.string.pk_document_root)); + enableDirPicker( + prefDocumentRoot, + sharedPreferences.getBoolean(pref_use_directory_pick, false) + ); + } + + } +} diff --git a/app/src/main/java/net/basov/lws/Server.java b/app/src/main/java/net/basov/lws/Server.java new file mode 100644 index 0000000..ba80af9 --- /dev/null +++ b/app/src/main/java/net/basov/lws/Server.java @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2017-2019 Mikhail Basov + * Copyright (C) 2009-2014 Markus Bode + * + * Licensed under the GNU General Public License v3 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package net.basov.lws; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.LinkedList; + +import android.content.Context; +import android.os.Handler; +import android.util.Log; + +import static net.basov.lws.Constants.*; + +class Server extends Thread { + private ServerSocket listener = null; + private boolean running = true; + private final String documentRoot; + private static Handler mHandler; + private final Context context; + + private static final LinkedList clientList = new LinkedList<>(); + + public Server(Handler handler, String documentRoot, String ip, int port, Context context) throws IOException { + super(); + this.documentRoot = documentRoot; + this.context = context; + Server.mHandler = handler; + InetAddress ipAddress = InetAddress.getByName(ip); + listener = new ServerSocket(port,0,ipAddress); + } + + @Override + public void run() { + while( running ) { + try { + Socket client = listener.accept(); + new ServerHandler(documentRoot, context, client, Server.mHandler).start(); + clientList.add(client); + } catch (IOException e) { + // Don't set running=false at this point! + // Give the server chance to create new socket if it is temporary problem + // This lead repeating message 'Socket closed' message many (more then 100) times + // but they suppressed by '... previous string repeated x times' logging functionality. + StartActivity.putToLogScreen("I: " + e.getMessage(), mHandler); + Log.e(LOG_TAG, e.getMessage()); + } + } + } + + public void stopServer() { + running = false; + try { + listener.close(); + } catch (IOException e) { + StartActivity.putToLogScreen("E: " + e.getMessage(), mHandler); + Log.e("lWS", e.getMessage()); + } + } + + public synchronized static void remove(Socket s) { + clientList.remove(s); + } + +} diff --git a/app/src/main/java/net/basov/lws/ServerHandler.java b/app/src/main/java/net/basov/lws/ServerHandler.java new file mode 100644 index 0000000..d05f687 --- /dev/null +++ b/app/src/main/java/net/basov/lws/ServerHandler.java @@ -0,0 +1,531 @@ +/* + * Copyright (C) 2017-2019 Mikhail Basov + * Copyright (C) 2009-2014 Markus Bode + * + * Licensed under the GNU General Public License v3 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package net.basov.lws; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.AssetFileDescriptor; +import android.content.res.AssetManager; +import android.os.Handler; +import android.util.Log; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.PrintWriter; +import java.io.UnsupportedEncodingException; +import java.net.Socket; +import java.net.URLEncoder; +import java.net.URLDecoder; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.TimeZone; +import java.util.regex.Pattern; + +import static net.basov.lws.Constants.*; + +class ServerHandler extends Thread { + private static final Pattern LINE_ENDINGS = Pattern.compile("\\n|\\r|\\n\\r"); + private static final Pattern FOLDERS = Pattern.compile("[/]+"); + private final Socket toClient; + private final String documentRoot; + private final Context context; + private final Handler msgHandler; + private final DateFormat DF; + private final DateFormat FLDF; + private Boolean requestHEAD = false; + + public ServerHandler(String documentRoot, Context context, Socket toClient, Handler msgHandler) { + this.toClient = toClient; + this.documentRoot = documentRoot; + this.context = context; + this.msgHandler = msgHandler; + DF = new SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss"); + DF.setTimeZone(TimeZone.getTimeZone("GMT")); + FLDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + } + + public void run() { + String document = ""; + String[] rangesArray = {}; + requestHEAD = false; + + try { + BufferedReader in = new BufferedReader(new InputStreamReader(toClient.getInputStream())); + // Receive data + while (true) { + String s = in.readLine().trim(); + if (s.equals("")) { + break; + } + + if (s.startsWith("HEAD")) + requestHEAD = true; + if (s.startsWith("GET") || s.startsWith("HEAD")) { + int leerstelle = s.indexOf(" HTTP/"); + document = s.substring(5,leerstelle); + document = FOLDERS.matcher(document).replaceAll("/"); + document = URLDecoder.decode(document, "UTF-8"); + } + if (s.startsWith("Range:")) { + rangesArray = s + .split("=", 2)[1] + .split(","); + } + } + } catch (Exception e) { + Server.remove(toClient); + try { + toClient.close(); + } + catch (Exception ignored){} + } + showHtml(document, rangesArray); + } + + private void send(String text) { + String header = context.getString(R.string.header, + context.getString(R.string.rc200), + text.getBytes().length, + DF.format(new Date()) + " GMT", // workaround to avoid +00:00 + "text/html" + ); + try { + PrintWriter out = new PrintWriter(toClient.getOutputStream(), true); + out.print(header); + out.print(text); + out.flush(); + Server.remove(toClient); + toClient.close(); + } catch (Exception ignored) { + } + } + + private void showHtml(String document, String[] ranges) { + int rc = 200; + long fileSize = 0L; + String fileModified = ""; + String clientIP = ""; + if(toClient != null + && toClient.getRemoteSocketAddress() != null + && toClient.getRemoteSocketAddress().toString() != null + && toClient.getRemoteSocketAddress().toString().length() > 2 + ) { + clientIP = toClient.getRemoteSocketAddress().toString().substring(1); + int clientIPColon = clientIP.indexOf(':'); + if (clientIPColon > 0) + clientIP = clientIP.substring(0, clientIPColon); + } + + // Standard-Doc + if (document.equals("")) { + document = "/"; + } + + // Don't allow directory traversal + if (document.contains("..")) { + rc = 403; + } + + // Search for files in document root + document = documentRoot + document; + document = FOLDERS.matcher(document).replaceAll("/"); + + try { + if (!new File(document).exists()) { + if (document.replace(documentRoot, "").equals("favicon.ico")) { + // set fake rc for default favicon.ico + rc = -2; + } else { + rc = 404; + } + } else if(document.charAt(document.length()-1) == '/') { + // This is directory + if (new File(document+"index.html").exists()) { + document = document + "index.html"; + } else { + send(directoryHtmlIndex(document)); + StartActivity.putToLogScreen( + "rc: " + + rc + + ", " + + clientIP + + ", /" + + document.replace(documentRoot, "") + + " (dir. index)", + msgHandler + ); + return; + } + } + + } catch (Exception e) { + e.printStackTrace(); + return; + } + + try { + String rcStr; + String header; + String contType; + BufferedOutputStream outStream = new BufferedOutputStream(toClient.getOutputStream()); + BufferedInputStream in; + + if (rc == 200) { + in = new BufferedInputStream(new FileInputStream(document)); + rcStr = context.getString(R.string.rc200); + contType = getMimeTypeForDocument(document).contentType; + } else if (rc == -2) { + // favicon.ico doesn't exist. Send application icon instead. + @SuppressLint("ResourceType") + final AssetFileDescriptor raw = context + .getResources() + .openRawResourceFd(R.mipmap.lws_ic); + in = new BufferedInputStream(raw.createInputStream()); + fileSize = (long) in.available(); + // mipmap resource modification time difficult to obtain + // and has no meaning. Set current date instead. + fileModified = DF.format(new Date()); + rcStr = context.getString(R.string.rc200); + contType = getMimeTypeForDocument(document).contentType; + rc = 200; + } else { + String errAsset; + AssetManager am = context.getAssets(); + switch (rc) { + case 404: + rcStr = context.getString(R.string.rc404); + errAsset = "404.html"; + break; + case 403: + rcStr = context.getString(R.string.rc403); + errAsset = "403.html"; + break; + case 416: + errAsset = "416.html"; + rcStr = context.getString(R.string.rc416); + break; + default: + errAsset = "500.html"; + rcStr = context.getString(R.string.rc500); + break; + } + + contType = "text/html"; + in = new BufferedInputStream(am.open(errAsset)); + fileSize = (long) in.available(); + fileModified = DF.format(new File("file:///android_asset/"+errAsset).lastModified()) + " GMT"; // workaround to avoid +00:00 + + } + // If fileSize not 0 some error detected and fileSize already set + // to assets file length + File documentFile = new File(document); + if (fileSize == 0L) fileSize = documentFile.length(); + if (fileModified.length() == 0) fileModified = DF.format(documentFile.lastModified()) + " GMT"; // workaround to avoid +00:00 + if(ranges.length == 0 || rc != 200) { + header = context.getString(R.string.header, + rcStr, + fileSize, + fileModified, + contType + ); + + header = normalizeLineEnd(header); + outStream.write(header.getBytes()); + if (!requestHEAD) { + byte[] fileBuffer = new byte[8192]; + int bytesCount; + while ((bytesCount = in.read(fileBuffer)) != -1) { + outStream.write(fileBuffer, 0, bytesCount); + } + } + String headMark = requestHEAD ? "(HEAD)":""; + StartActivity.putToLogScreen( + "rc: " + + rc + + ", " + + clientIP + + ", /" + + document.replace(documentRoot, "") + + headMark, + msgHandler + ); + } else { + // TODO: range error processing + // TODO: number conversion error processing + rc = 206; + long partialHeaderLength = 0L; + PartialRange[] boundaries = new PartialRange[ranges.length]; + + for (int i = 0; i < ranges.length; i++) { + String strRangeBegin = ranges[i].split("-",2)[0]; + String strRangeEnd = ranges[i].split("-",2)[1]; + boundaries[i] = new PartialRange(); + try { + if (strRangeBegin.length() != 0 && strRangeEnd.length() != 0) { + boundaries[i].begin = Long.parseLong(strRangeBegin); + boundaries[i].end = Long.parseLong(strRangeEnd); + } else if (strRangeBegin.length() != 0 && strRangeEnd.length() == 0) { + boundaries[i].begin = Long.parseLong(strRangeBegin); + boundaries[i].end = fileSize - 1; + } else if (strRangeBegin.length() == 0 && strRangeEnd.length() != 0) { + boundaries[i].begin = fileSize - Long.parseLong(strRangeEnd); + boundaries[i].end = fileSize - 1; + } + } catch (NumberFormatException e ) { + e.printStackTrace(); + handleError416(outStream); + return; + } + boundaries[i].size = boundaries[i].end - boundaries[i].begin + 1; + if (boundaries[i].size <= 0 + || boundaries[i].end > fileSize + || boundaries[i].begin > fileSize) { + handleError416(outStream); + return; + } + boundaries[i].header = ""; + if (i != 0) boundaries[i].header += "\n"; + boundaries[i].header += context.getString(R.string.range_header, + context.getString(R.string.boundary_string), + contType, + boundaries[i].begin, // begin + boundaries[i].end, // end + fileSize // length + ); + boundaries[i].header = normalizeLineEnd(boundaries[i].header); + + partialHeaderLength += boundaries[i].size + boundaries[i].header.length(); + } + if (ranges.length > 1) partialHeaderLength += context.getString(R.string.boundary_string).length() + 2 + 4; // I don't know why + 4 + + String headMark = requestHEAD ? "(HEAD)":""; + StartActivity.putToLogScreen( + "rc: " + + rc + + ", " + + clientIP + + ", /" + + document.replace(documentRoot, "") + + ", Range: " + + Arrays.toString(ranges) + + headMark, + msgHandler + ); + + header = context.getString(R.string.header_partial, + context.getString(R.string.rc206), + ranges.length > 1 ? "" : "\nContent-Range: bytes " + boundaries[0].begin+"-"+boundaries[0].end+"/" + fileSize, + ranges.length > 1 ? partialHeaderLength : boundaries[0].size, + ranges.length > 1 ? "multipart/byteranges; boundary=" + context.getString(R.string.boundary_string) : contType + ); + header = normalizeLineEnd(header); + outStream.write(header.getBytes()); + + if (!requestHEAD) { + for (PartialRange b : boundaries) { + if (boundaries.length > 1) { + outStream.write(b.header.getBytes()); + } + byte[] fileBuffer = new byte[8192]; + int bytesCount; + long currentPosition = b.begin; + in = new BufferedInputStream(new FileInputStream(document)); + in.skip(currentPosition); + while ((bytesCount = in.read(fileBuffer)) != -1) { + if (currentPosition + bytesCount <= b.end) + currentPosition += bytesCount; + else { + outStream.write(fileBuffer, 0, (int) (b.end - currentPosition + 1)); + break; + } + outStream.write(fileBuffer, 0, bytesCount); + } + } + if (boundaries.length > 1) + outStream.write(("\r\n--" + context.getString(R.string.boundary_string) + "\r\n").getBytes()); + } + + } + outStream.flush(); + + Server.remove(toClient); + toClient.close(); + } catch (Exception e) { + e.printStackTrace(); + Log.e(Constants.LOG_TAG, "showHtml() very complex and need to be written simpler ... "); + } + } + + private String directoryHtmlIndex(String dir) { + StringBuilder html = new StringBuilder(context.getString( + R.string.dir_list_top_html, + dir.replace(documentRoot, ""), + dir.replace(documentRoot, ""), + dir.equals(documentRoot) ? "" : context.getString(R.string.dir_list_parent_dir) + )); + + File[] allFiles = new File(dir).listFiles(); + ArrayList dirs = new ArrayList(allFiles.length); + ArrayList files = new ArrayList(allFiles.length); + + for (File i : allFiles) { + if (i.isDirectory()) { + dirs.add(new FileInfo()); + dirs.get(dirs.size() - 1).name = i.getName(); + dirs.get(dirs.size() - 1).date = FLDF.format(i.lastModified()); + } else if (i.isFile()) { + files.add(new FileInfo()); + files.get(files.size() - 1).name = i.getName(); + files.get(files.size() - 1).size = i.length(); + files.get(files.size() - 1).date = FLDF.format(i.lastModified()); + } + } + + Comparator fileNameCmp = new Comparator(){ + @Override + public int compare(FileInfo f1, FileInfo f2) { + return f1.name.compareToIgnoreCase(f2.name); + } + }; + Collections.sort(dirs, fileNameCmp); + Collections.sort(files, fileNameCmp); + + for (FileInfo d : dirs) { + html.append(context.getString( + R.string.dir_list_item, + "folder", + "folder", + fileName2URL(d.name) + "/", + d.name, + d.date, + 0, + "-" + )); + } + for (FileInfo f : files) { + html.append(context.getString( + R.string.dir_list_item, + "file", + getMimeTypeForDocument(f.name).kind, + fileName2URL(f.name), + f.name, + f.date, + f.size, + bytesToKMGT(f.size) + )); + } + + html.append(context.getString(R.string.dir_list_bottom_html)); + + return html.toString(); + } + + static MimeType getMimeTypeForDocument(String document) { + String fileExt = document.substring( + document.lastIndexOf('.')+1 + ).toLowerCase(); + MimeType mimeType = MIME.get(fileExt); + if (mimeType == null) { + return mimeType; + } else + return MIME_OCTAL; + } + + static String fileName2URL(String fn) { + String ref = ""; + try { + ref = URLEncoder.encode(fn, "UTF-8").replace("+", "%20"); + } catch (UnsupportedEncodingException ignored) { + } + return ref; + } + + private void handleError416(BufferedOutputStream outStream) { + try { + AssetManager am = context.getAssets(); + BufferedInputStream in = new BufferedInputStream(am.open("416.html")); + + String header = context.getString(R.string.header, + context.getString(R.string.rc500), + (long) in.available(), + DF.format(new Date()) + " GMT", // workaround to avoid +00:00 + "text/html" + ); + outStream.write(header.getBytes()); + + byte[] fileBuffer = new byte[8192]; + int bytesCount; + while ((bytesCount = in.read(fileBuffer)) != -1) { + outStream.write(fileBuffer, 0, bytesCount); + } + outStream.flush(); + Server.remove(toClient); + toClient.close(); + } catch (NumberFormatException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } + + } + private String bytesToKMGT(Long size) { + String ret; + if (size <= 1024) + ret = String.format("%d b", size); + else if (size > 1024 && size <= 1024*1024) + ret = String.format("%.2f Kb", (float) size/1024.0); + else if (size > 1024.0*1024.0 && size <= 1024.0*1024.0*1024.0) + ret = String.format("%.2f Mb", (float) size/(1024*1024)); + else if (size > 1024.0*1024.0*1024.0 && size <= 1024.0*1024.0*1024.0*1024.0) + ret = String.format("%.2f Gb", (float) size/(1024.0*1024.0*1024.0)); + else // Yes. I am optimist :) + ret = String.format("%.2f Tb", (float) size/(1024.0*1024.0*1024.0*1024.0)); + return ret; + } + + private String normalizeLineEnd (String src) { + return LINE_ENDINGS.matcher(src).replaceAll("\r\n"); + } + + static class PartialRange { + long begin; + long end; + long size; + String header; + } + + static class FileInfo { + String name; + long size; + String date; + } +} diff --git a/app/src/main/java/net/basov/lws/ServerService.java b/app/src/main/java/net/basov/lws/ServerService.java new file mode 100644 index 0000000..c461e0f --- /dev/null +++ b/app/src/main/java/net/basov/lws/ServerService.java @@ -0,0 +1,327 @@ +/* + * Copyright (C) 2017-2019 Mikhail Basov + * Copyright (C) 2009-2014 Markus Bode + * + * Licensed under the GNU General Public License v3 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package net.basov.lws; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.graphics.BitmapFactory; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.net.wifi.SupplicantState; +import android.net.wifi.WifiInfo; +import android.net.wifi.WifiManager; +import android.os.Binder; +import android.os.Build; +import android.os.Handler; +import android.os.IBinder; +import android.preference.PreferenceManager; +import android.util.Log; + +import java.lang.reflect.Method; +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.util.Enumeration; + +import static net.basov.lws.Constants.*; + +public class ServerService extends Service { + private NotificationManager mNM; + private Server server; + private boolean isRunning = false; + private String ipAddress = ""; + private static Handler gHandler; + private static BroadcastReceiver mReceiver = null; + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + + if (intent != null + && intent.getAction() != null + && intent.getAction().equals(ACTION_STOP) + ) stopServer(); + + return super.onStartCommand(intent, flags, startId); + } + + @Override + public void onCreate() { + mNM = (NotificationManager)getSystemService(NOTIFICATION_SERVICE); + } + + public void startServer(Handler handler) { + ServerService.gHandler = handler; + try { + WifiManager wifiManager = + (WifiManager) getApplicationContext().getSystemService(WIFI_SERVICE); + // Check Tethering AP state + Boolean isWifiAPEnabled = isSharingWiFi(wifiManager); + // Check WiFi state + WifiInfo wifiInfo = wifiManager.getConnectionInfo(); + + final SharedPreferences sharedPreferences = + PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); + + // Start server + isRunning = true; + if (isWifiAPEnabled) { + ipAddress = getAPIpAddress(); + } else { + ipAddress = intToIp(wifiInfo.getIpAddress()); + } + int port = Integer.valueOf( + sharedPreferences.getString( + getString(R.string.pk_port), + "8080" + ) + ); + + if ( + ( + (!wifiManager.isWifiEnabled()) + || (wifiInfo.getSupplicantState() != SupplicantState.COMPLETED) + || (wifiInfo.getIpAddress() == 0) + ) + && !isWifiAPEnabled + ) { + ipAddress="127.0.0.1"; + StartActivity.putToLogScreen( + "Connected to loopback interface (127.0.0.1)\nTo change it connect to a WiFi-network or start Tethering, then restart server.", + gHandler, + true + ); + } + + server = new Server( + gHandler, + sharedPreferences.getString(getString(R.string.pk_document_root), ""), + ipAddress, + port, + getApplicationContext() + ); + server.start(); + + startForegroundService("Running on " + ipAddress + ":" + port); + + StartActivity.putToLogScreen( + "I: Web server address http://" + + ipAddress + + ":" + + port, + gHandler + ); + + // register broadcast receiver to monitor WiFi state + if (mReceiver == null) { + mReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + + if (getIpAddress().equals("127.0.0.1")) return; + + NetworkInfo info = intent.getParcelableExtra(WifiManager.EXTRA_NETWORK_INFO); + if (info != null && info.getState() == NetworkInfo.State.DISCONNECTED) { + StartActivity.putToLogScreen( + "I: Web server stopped because WiFi disconnected.", + gHandler + ); + stopServer(); + } + + Integer tetheringState = intent.getIntExtra(WifiManager.EXTRA_WIFI_STATE, 0); + if (tetheringState > 10) tetheringState -= 10; // Old android fix + if (tetheringState == 10) { + StartActivity.putToLogScreen( + "I: Web server stopped because AP switched off.", + gHandler + ); + stopServer(); + } + } + }; + IntentFilter filter = new IntentFilter(); + filter.addAction(ConnectivityManager.CONNECTIVITY_ACTION); + filter.addAction(WifiManager.SUPPLICANT_STATE_CHANGED_ACTION); + filter.addAction("android.net.wifi.WIFI_AP_STATE_CHANGED"); + registerReceiver(mReceiver, filter); + } + + } catch (Exception e) { + isRunning = false; + mNM.cancel(NOTIFICATION_ID); + Log.e(LOG_TAG, e.getMessage()+ "(from ServerService.startServer())"); + StartActivity.putToLogScreen("E: " + e.getMessage(), gHandler); + } + } + + private static String intToIp(int i) { + return ((i ) & 0xFF) + "." + + ((i >> 8 ) & 0xFF) + "." + + ((i >> 16 ) & 0xFF) + "." + + ( i >> 24 & 0xFF); + } + + public void stopServer() { + isRunning = false; + ipAddress = ""; + mNM.cancel(NOTIFICATION_ID); + try { + //TODO: Exception when unregister receiver which is new... + if (mReceiver != null) { + unregisterReceiver(mReceiver); + mReceiver = null; + } + } catch (IllegalArgumentException e) { + StartActivity.putToLogScreen( + "E: Receiver unregister error again :( (stopServer())", + gHandler + ); + Log.e(LOG_TAG, e.getMessage() + "on ServerService.stopServer()"); + } + if (null != server) { + server.stopServer(); + server.interrupt(); + } + stopForeground(true); + stopSelf(); + } + + private void startForegroundService(String message) { + if (null == message || message.length()==0) return; + + PendingIntent contentIntent = PendingIntent.getActivity( + this, + Constants.MAIN_SCREEN_REQUEST, + new Intent(this, StartActivity.class), + PendingIntent.FLAG_UPDATE_CURRENT + ); + Intent stopIntent = new Intent(this,ServerService.class); + stopIntent.setAction(Constants.ACTION_STOP); + PendingIntent stopPendingIntent = PendingIntent.getService( + this, + Constants.STOP_SERVICE_REQUEST, + stopIntent, + PendingIntent.FLAG_UPDATE_CURRENT + ); + if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + Notification.Builder notificationBuilder = new Notification.Builder(this) + .setSmallIcon(R.mipmap.lws_ic) + .setLargeIcon(BitmapFactory.decodeResource(getResources(), R.mipmap.lws_ic)) + .setContentTitle(getString(R.string.app_name)) + .setContentText(message) + .setWhen(System.currentTimeMillis()) + .setContentIntent(contentIntent) + .addAction(0, "Stop service", stopPendingIntent) + .setOngoing(true); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + notificationBuilder.setChannelId(this.getString(R.string.notif_ch_id)); + } + startForeground(NOTIFICATION_ID, notificationBuilder.build()); + } + } + + @Override + public IBinder onBind(Intent intent) { + if (!isRunning) mNM.cancel(NOTIFICATION_ID); + return mBinder; + } + + private final IBinder mBinder = new LocalBinder(); + + public class LocalBinder extends Binder { + ServerService getService() { + return ServerService.this; + } + } + + public boolean isRunning() { + return isRunning; + } + + @Override + public void onDestroy() { + stopServer(); + stopSelf(); + super.onDestroy(); + } + + @Override + public void onTaskRemoved(Intent rootIntent) { + stopServer(); + stopSelf(); + super.onTaskRemoved(rootIntent); + } + + public String getIpAddress() { return ipAddress; } + + // Code from https://stackoverflow.com/a/20432036 + // Check Tethering AP enabled + private static boolean isSharingWiFi(final WifiManager manager) { + try { + final Method method = manager.getClass().getDeclaredMethod("isWifiApEnabled"); + method.setAccessible(true); //in the case of visibility change in future APIs + return (Boolean) method.invoke(manager); + } + catch (final Throwable ignored) { } + return false; + } + + // Code from https://stackoverflow.com/questions/17302220/android-get-ip-address-of-a-hotspot-providing-device + // Get IP address in WiFi hotspot (tethering) mode + private String getAPIpAddress() { + String ip = ""; + try { + Enumeration enumNetworkInterfaces = NetworkInterface + .getNetworkInterfaces(); + while (enumNetworkInterfaces.hasMoreElements()) { + NetworkInterface networkInterface = enumNetworkInterfaces + .nextElement(); + Enumeration enumInetAddress = networkInterface + .getInetAddresses(); + while (enumInetAddress.hasMoreElements()) { + InetAddress inetAddress = enumInetAddress.nextElement(); + + if (inetAddress.isSiteLocalAddress() + && ( + networkInterface.getName().toLowerCase().contains("wlan") + || networkInterface.getName().toLowerCase().contains("ap") + ) + ) { + ip = inetAddress.getHostAddress(); + } + } + } + + } catch (SocketException e) { + Log.e(LOG_TAG, e.getMessage()); + } + return ip; + } +} diff --git a/app/src/main/java/net/basov/lws/StartActivity.java b/app/src/main/java/net/basov/lws/StartActivity.java new file mode 100644 index 0000000..3df2fa0 --- /dev/null +++ b/app/src/main/java/net/basov/lws/StartActivity.java @@ -0,0 +1,496 @@ +/* + * Copyright (C) 2017-2019 Mikhail Basov + * Copyright (C) 2009-2014 Markus Bode + * + * Licensed under the GNU General Public License v3 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package net.basov.lws; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileWriter; + +import android.Manifest; +import android.app.Activity; +import android.app.NotificationManager; +import android.app.NotificationChannel; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Environment; +import android.os.Handler; +import android.os.IBinder; +import android.os.Message; +import android.preference.PreferenceManager; +import android.util.Log; +import android.view.View; +import android.view.View.OnClickListener; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.TextView; +import android.widget.Toast; +import android.widget.ToggleButton; + +import static net.basov.lws.Constants.*; + +public class StartActivity extends Activity { + private static int prevMsgCount; + private static String prevMsg; + private ToggleButton btnStartStop; + private static TextView viewLog; + private static ScrollView viewScroll; + private String documentRoot; + + private ServerService mBoundService; + private ServiceConnection mConnection; + + + @Override + public void onCreate(Bundle savedInstanceState) { + StartActivity.prevMsg = ""; + StartActivity.prevMsgCount = 0; + super.onCreate(savedInstanceState); + setContentView(R.layout.main); + setTitle(R.string.hello); + + btnStartStop = (ToggleButton) findViewById(R.id.buttonStartStop); + viewLog = (TextView) findViewById(R.id.log); + viewScroll = (ScrollView) findViewById(R.id.ScrollView); + + findViewById(R.id.buttonSettings) + .setOnClickListener(makePrefListener(-1)); + + try { + android.content.pm.PackageInfo pInfo = + this.getPackageManager().getPackageInfo(this.getPackageName(), 0); + String appName = + this.getString(R.string.hello) + + " v" + + pInfo.versionName; + log( + appName + + "\n" + + new String(new char[appName.length()]).replace('\0', '*') + ); + } catch (PackageManager.NameNotFoundException e) { + e.printStackTrace(); + } + + PreferenceManager.setDefaultValues(this, R.xml.preferences, false); + documentRoot = getDocumentRoot(); + + if(null == documentRoot) { + log("E: Document-Root could not be found."); + } + + /** + * Hide QR Code plugin call button if run on SdkVersion older then 16 + * because plugin doesn't operate on older versions + * If version is higher set total button group weight to 3 else set to 2 + */ + LinearLayout btnGroup = findViewById(R.id.buttonsBlock); + View btnQR = findViewById(R.id.buttonQRCodeURL); + if (Build.VERSION.SDK_INT < 16) { + btnQR.setVisibility(View.GONE); + btnGroup.setWeightSum(2f); + } else { + btnGroup.setWeightSum(3f); + } + + btnStartStop.setOnClickListener(new OnClickListener() { + public void onClick(View arg0) { + Intent intent = new Intent(StartActivity.this, ServerService.class); + if(btnStartStop.isChecked()) { + startServer(mHandler); + startService(intent); + } else { + stopServer(); + stopService(intent); + } + refreshMainScreen(); + } + }); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationManager mNM = (NotificationManager)getSystemService(NOTIFICATION_SERVICE); + NotificationChannel channel = new NotificationChannel( + this.getString(R.string.notif_ch_id), + this.getString(R.string.notif_ch_hr), + NotificationManager.IMPORTANCE_DEFAULT + ); + channel.setSound(null, null); + mNM.createNotificationChannel(channel); + } + + mConnection = new ServiceConnection() { + public void onServiceConnected(ComponentName className, IBinder service) { + mBoundService = ((ServerService.LocalBinder)service).getService(); + Toast.makeText( + StartActivity.this, + "Service connected", + Toast.LENGTH_SHORT + ).show(); + refreshMainScreen(); + } + + public void onServiceDisconnected(ComponentName className) { + mBoundService = null; + Toast.makeText( + StartActivity.this, + "Service disconnected", + Toast.LENGTH_SHORT + ).show(); + } + }; + + doBindService(); + + refreshMainScreen(); + } + + private void doUnbindService() { + if (mBoundService != null) { + getApplicationContext().unbindService(mConnection); + } + + } + + private void startServer(Handler handler) { + if (mBoundService == null) { + Toast.makeText( + StartActivity.this, + "Service not connected", + Toast.LENGTH_SHORT + ).show(); + } else { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + String defaultDocumentRoot = StartActivity.getFilesDir(this).getPath() + "/html/"; + if (!documentRoot.equals(defaultDocumentRoot)) { + if ( + checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) + != PackageManager.PERMISSION_GRANTED + ) { + requestPermissions( + new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, + GRANT_WRITE_EXTERNAL_STORAGE + ); + } + } + } + mBoundService.startServer(handler); + } + } + + private void stopServer() { + if (mBoundService == null) { + Toast.makeText( + StartActivity.this, + "Service not connected", + Toast.LENGTH_SHORT + ).show(); + } else { + mBoundService.stopServer(); + } + } + + private void doBindService() { + getApplicationContext() + .bindService( + new Intent( + StartActivity.this, + ServerService.class), + mConnection, + Context.BIND_AUTO_CREATE + ); + } + + @Override + protected void onDestroy() { + doUnbindService(); + super.onDestroy(); + } + + @Override + protected void onResume() { + super.onResume(); + doBindService(); + refreshMainScreen(); + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + // Called then status bar pulled up + if (hasFocus) refreshMainScreen(); + super.onWindowFocusChanged(hasFocus); + } + + private void refreshMainScreen() { + final TextView viewDirectoryRoot = (TextView) findViewById(R.id.document_root); + final TextView viewAddress = (TextView) findViewById(R.id.address); + final TextView viewPort = (TextView) findViewById(R.id.port); + final Button btnBrowser = (Button) findViewById(R.id.buttonOpenBrowser); + final Button btnSendURL = (Button) findViewById(R.id.buttonSendURL); + final Button btnQRCodeURL = (Button) findViewById(R.id.buttonQRCodeURL); + + final SharedPreferences sharedPreferences = + PreferenceManager.getDefaultSharedPreferences(this); + + documentRoot = getDocumentRoot(); + viewDirectoryRoot.setText(documentRoot); + viewDirectoryRoot.setOnClickListener(makePrefListener(1)); + + final String port = sharedPreferences.getString( + getString(R.string.pk_port), + "8080" + ); + viewPort.setText(port); + + viewPort.setOnClickListener(makePrefListener(2)); + + if(mBoundService != null) { + btnStartStop.setChecked(mBoundService.isRunning()); + if (mBoundService.isRunning()) { + if (sharedPreferences.getBoolean(getString(R.string.pk_pref_changed), false)) { + SharedPreferences.Editor editor = sharedPreferences.edit(); + stopServer(); + startServer(mHandler); + Toast.makeText(StartActivity.this,"Service restarted because configuration changed", Toast.LENGTH_SHORT).show(); + editor.putBoolean(getString(R.string.pk_pref_changed), false); + editor.commit(); + } + final String ipAddress = mBoundService.getIpAddress(); + viewAddress.setText(ipAddress); + viewAddress.setTextColor(0xFFFFFF00); + + final String url = + "http://" + + ipAddress + + ":" + + port + + "/"; + btnBrowser.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + Intent i = new Intent(Intent.ACTION_VIEW); + i.setData(Uri.parse(url)); + startActivity(i); + } + }); + btnBrowser.setEnabled(true); + + btnQRCodeURL.setOnClickListener( new OnClickListener() { + @Override + public void onClick(View v) { + PackageManager pm = getApplicationContext().getPackageManager(); + try { + pm.getPackageInfo(getString(R.string.qrPluginPackage), 0); + Intent i = new Intent(getString(R.string.qrIntentAction)); + i.setData(Uri.parse("createqr:")); + i.putExtra("ENCODE_DATA", url); + i.putExtra("ENCODE_LABEL", "Open lWS page
(" + url + ")"); + i.putExtra("ENCODE_CORRECTION", "L"); + i.putExtra("ENCODE_MODULE_SIZE", 6); + i.putExtra("ENCODE_MASK", -1); + i.putExtra("ENCODE_MIN_VERSION", 1); + +// i.putExtra("ENCODE_DATA", url); +// i.putExtra("ENCODE_SIZE", "256"); +// i.putExtra("ENCODE_DARK", "#000"); +// i.putExtra("ENCODE_LIGHT", "#e0ffff"); +// i.putExtra("ENCODE_CORRECTION", "L"); + + startActivity(i); + } catch (PackageManager.NameNotFoundException e_lws_qr) { + try { + pm.getPackageInfo("com.google.zxing.client.android", 0); + Intent i = new Intent("com.google.zxing.client.android.ENCODE"); + i.putExtra("ENCODE_TYPE", "TEXT_TYPE"); + i.putExtra("ENCODE_DATA", url); + i.putExtra("ENCODE_FORMAT", "QRCODE"); + startActivity(i); + } catch (PackageManager.NameNotFoundException e_zxing) { + Intent i = new Intent(Intent.ACTION_VIEW); + i.setData(Uri.parse("market://details?id=" + getString(R.string.qrPluginPackage))); + startActivity(i); + } + } + } + }); + if (!ipAddress.equals("127.0.0.1")) + btnQRCodeURL.setEnabled(true); + + OnClickListener sendListener = new OnClickListener() { + @Override + public void onClick(View v) { + Intent i = new Intent(Intent.ACTION_SEND); + i.setData(Uri.parse(url)); + i.setType("text/html"); + i.putExtra(Intent.EXTRA_SUBJECT, "Current lWS URL"); + i.putExtra(Intent.EXTRA_TEXT, url); + startActivity(i); + } + }; + if (!ipAddress.equals("127.0.0.1")) { + btnSendURL.setOnClickListener(sendListener); + btnSendURL.setEnabled(true); + viewAddress.setOnClickListener(sendListener); + } + + } else { + viewAddress.setText("not running"); + viewAddress.setTextColor(0xFFFF0000); + viewAddress.setOnClickListener(null); + viewAddress.setClickable(false); + btnBrowser.setEnabled(false); + btnSendURL.setEnabled(false); + btnQRCodeURL.setEnabled(false); + } + } else { + viewAddress.setText("not running"); + viewAddress.setTextColor(0xFFFF0000); + viewAddress.setOnClickListener(null); + viewAddress.setClickable(false); + btnBrowser.setEnabled(false); + btnSendURL.setEnabled(false); + btnQRCodeURL.setEnabled(false); + } + } + + public static File getFilesDir(Context c) { + File filesDir; + if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) { + if (Build.VERSION.SDK_INT <= 18) + filesDir = new File(Environment.getExternalStorageDirectory() + + "/Android/data/" + + c.getPackageName() + +"/files" + ); + else + filesDir = c.getExternalFilesDir(null); + } else { + filesDir = c.getFilesDir(); + } + return filesDir; + } + + private String getDocumentRoot(){ + String defaultDocumentRoot = getFilesDir(this).getPath() + "/html/"; + final SharedPreferences sharedPreferences = + PreferenceManager.getDefaultSharedPreferences(this); + + String documentRoot = sharedPreferences.getString( + getString(R.string.pk_document_root), + "" + ); + + if (documentRoot.length() == 0 ) { + // if preferences contain empty string or absent reset it to default + documentRoot = defaultDocumentRoot; + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.putString(getString(R.string.pk_document_root), documentRoot); + editor.commit(); + } + + if (documentRoot.equals(defaultDocumentRoot)) createDefaultIndex(); + + return documentRoot; + } + + private void createDefaultIndex() { + try { + String defaultDocumentRoot = getFilesDir(this).getPath() + "/html/"; + File defaultDocumentRootDirectory = new File(defaultDocumentRoot); + if (!defaultDocumentRootDirectory.exists()) { + if(defaultDocumentRootDirectory.mkdirs()) { + BufferedWriter bout = new BufferedWriter(new FileWriter(defaultDocumentRoot + "index.html")); + bout.write(getString(R.string.def_doc_root_index, defaultDocumentRoot)); + bout.flush(); + bout.close(); + log("I: Default DocumentRoot HTML index file created."); + } else { + throw new Exception("Can't create document root."); + } + } + } catch (Exception e) { + log("E: Error creating HTML index file."); + Log.e(LOG_TAG,e.getMessage()); + } + } + + /** + * Application main screen related functions and handler + */ + + private static void log(String s) { + if (prevMsg.equals(s)) { + prevMsgCount++; + }else { + if (prevMsgCount != 0) + viewLog.append("... previous string repeated " + prevMsgCount +" times.\n"); + prevMsgCount = 0; + prevMsg = s; + viewLog.append(s + "\n"); + } + viewScroll.fullScroll(ScrollView.FOCUS_DOWN); + } + + final Handler mHandler = new Handler() { + @Override + public void handleMessage(Message msg) { + Bundle b = msg.getData(); + if (b.containsKey("toast")){ + Toast.makeText(StartActivity.this, b.getString("msg"), Toast.LENGTH_SHORT).show(); + } + log(b.getString("msg")); + } + }; + + public static void putToLogScreen(String message, Handler msgHandler) { + putToLogScreen(message, msgHandler, false); + } + + public static void putToLogScreen(String message, Handler msgHandler, Boolean isToast) { + Message msg = new Message(); + Bundle b = new Bundle(); + b.putString("msg", message); + if (isToast) + b.putBoolean("toast",true); + msg.setData(b); + msgHandler.sendMessage(msg); + } + + private OnClickListener makePrefListener(final int index) { + return new OnClickListener() { + @Override + public void onClick(View v) { + Intent i = new Intent(StartActivity.this, PreferencesActivity.class); + if (index != -1 ) + i.putExtra("item", index); + startActivity(i); + } + }; + } + +} diff --git a/app/src/main/res/layout/main.xml b/app/src/main/res/layout/main.xml new file mode 100644 index 0000000..7c6ded8 --- /dev/null +++ b/app/src/main/res/layout/main.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + +