#define WV_DEBUG using Android.App; using Android.OS; using Android.Util; using Android.Views; using Android.Webkit; using Java.Interop; using Java.Lang; using System; using System.IO; using System.Linq; using System.Threading.Tasks; using Android; using Android.Content; using Android.Content.PM; using Android.Widget; namespace LnoidWv { using JsLab.JsApi; // ReSharper disable once ClassNeverInstantiated.Global [Activity(Label = "!轻离阅", MainLauncher = true, Icon = "@drawable/icon")] public partial class MainActivity : Activity { } public class NativeFunctions : JsApiBase { [JsApi] // ReSharper disable once UnusedMember.Global public bool PathExist(string path) { return Directory.Exists(path); } [JsApi] // ReSharper disable once UnusedMember.Global public object GetDirs(string path, bool full = false) { var directories = Directory.GetDirectories(path); var array = full ? directories : directories.Select(Path.GetFileName).ToArray(); Array.Sort(array); return array; } [JsApi] // ReSharper disable once UnusedMember.Global public object GetPrefixes(string dataPath, Action cbProcess = null) { var directories = Directory.GetDirectories(dataPath); Array.Sort(directories); var progress = cbProcess == null ? new Action(delegate { }) : p => cbProcess(new { Total = directories.Length, Current = p }); return directories.Select(delegate (string prefixPath, int index) { var foo = new { Path = prefixPath, Prefix = Path.GetFileName(prefixPath)?.ToUpper(), SeriesCount = Directory.GetDirectories(prefixPath).Length, }; progress(index + 1); return foo; }).ToArray(); } [JsApi] // ReSharper disable once UnusedMember.Global public object GetSeries(string prefixPath, Action cbProcess = null) { var directories = Directory.GetDirectories(prefixPath); Array.Sort(directories); var progress = cbProcess == null ? new Action(delegate { }) : p => cbProcess(new { Total = directories.Length, Current = p }); return directories .OrderBy(p => p) .Select((seriesPath, index) => { var subDirs = Directory.GetDirectories(seriesPath).OrderBy(p => p).ToArray(); var obj = new { Path = seriesPath, SeriesName = File.ReadAllText(Path.Combine(seriesPath, "sname.txt")), VolumeCount = subDirs.Length, CoverData = subDirs .Select(s => Path.Combine(s, "cover.jpg")).Where(File.Exists) .Select(p => "data:image/jpeg;base64," + Convert.ToBase64String(File.ReadAllBytes(p))) .FirstOrDefault() }; progress(index + 1); return obj; }).ToArray(); } [JsApi] // ReSharper disable once UnusedMember.Global public object GetVolumes(string seriesPath, Action cbProcess = null) { var directories = Directory.GetDirectories(seriesPath); Array.Sort(directories); var progress = cbProcess == null ? new Action(delegate { }) : p => cbProcess(new { Total = directories.Length, Current = p }); return directories.OrderBy(p => p) .Select((volumePath, index) => { var b64 = Convert.ToBase64String(File.ReadAllBytes(Path.Combine(volumePath, "cover.jpg")), Base64FormattingOptions.None); var obj = new { Path = volumePath, VolumeName = File.ReadAllText(Path.Combine(volumePath, "vname.txt")), ChapterCount = Directory.GetDirectories(volumePath).Length, CoverData = $"data:image/jpeg;base64,{b64}", }; progress(index + 1); return obj; }).ToArray(); } [JsApi] // ReSharper disable once UnusedMember.Global public object GetChapters(string volumePath, Action cbProcess = null) { var directories = Directory.GetDirectories(volumePath); Array.Sort(directories); var progress = cbProcess == null ? new Action(delegate { }) : p => cbProcess(new { Total = directories.Length, Current = p }); return directories .OrderBy(p => p) .Select((chapterPath, index) => { var pictures1 = Directory.GetFiles(chapterPath, "*.jpg"); var pictures2 = Directory.GetFiles(chapterPath, "*.JPG"); var firstBase64 = pictures1.Concat(pictures2) .OrderBy(filename => filename) .Select(filename => "data:image/jpeg;base64," + Convert.ToBase64String(File.ReadAllBytes(filename), Base64FormattingOptions.None)) .FirstOrDefault(); var obj = new { Path = chapterPath, ChapterName = File.ReadAllText(Path.Combine(chapterPath, "cname.txt")), FirstPicture = firstBase64 }; progress(index + 1); return obj; }).ToArray(); } [JsApi] // ReSharper disable once UnusedMember.Global public object GetChapterContent(string chapterPath, Action cbProcess = null) { var lines = File.ReadAllLines(Path.Combine(chapterPath, "ctext.txt")); var progress = cbProcess == null ? new Action(delegate { }) : p => cbProcess(new { Total = lines.Length, Current = p }); for (var i = 0; i < lines.Length; i++) { var line = lines[i]; if (!line.StartsWith("", "") ; var picabs = Path.Combine(chapterPath, picnam); var b64 = Convert.ToBase64String(File.ReadAllBytes(picabs), Base64FormattingOptions.None); lines[i] = $""; progress(i + 1); } return lines; } [JsApi] // ReSharper disable once UnusedMember.Global public string ReadSetting() { #if !Android22 if (Android.OS.Environment.IsExternalStorageManager == false) { var uri = Android.Net.Uri.Parse("package:" + Application.Context.ApplicationInfo.PackageName); var intent = new Intent(Android.Provider.Settings.ActionManageAppAllFilesAccessPermission, uri); _mainActivity.StartActivityForResult(intent, 9527); } #endif var localStorage = _mainActivity.Application.FilesDir.Path; var configFilePath = Path.Combine(localStorage, "config.json"); return File.Exists(configFilePath) ? File.ReadAllText(configFilePath) : null; } [JsApi] // ReSharper disable once UnusedMember.Global public void WriteSetting(string json) { var localStorage = _mainActivity.Application.FilesDir.Path; var configFilePath = Path.Combine(localStorage, "config.json"); File.WriteAllText(configFilePath, json); } [JsApi] // ReSharper disable once UnusedMember.Global public void ExitApp() { JavaSystem.Exit(0); } [JsApi] // ReSharper disable once UnusedMember.Global public void WriteLog(string content) { Log.Debug($"{_mainActivity.Application.PackageName}::JsApi::{nameof(WriteLog)}", content); } [JsApi] // ReSharper disable once UnusedMember.Global public void CopyText(string text) { var myClipboard = (ClipboardManager)_mainActivity.GetSystemService(Context.ClipboardService); var myClip = ClipData.NewPlainText("text", text); myClipboard.PrimaryClip = myClip; Toast.MakeText(_mainActivity.Application, "文本已复制", ToastLength.Short).Show(); } private readonly MainActivity _mainActivity; public NativeFunctions(MainActivity mainActivity, string jsBindName) : base(jsBindName) { _mainActivity = mainActivity; } protected override void CallJs(string code) { _mainActivity.RunOnUiThread(() => _mainActivity.CallJs(code)); } #if DEBUG || WV_DEBUG [JsApi] // ReSharper disable once UnusedMember.Global public void ReLoad() { _mainActivity.RunOnUiThread(() => _mainActivity.ReLoad()); } #endif // TODO: UI Config - GridSize(0.5x-10x) - TextSize(5-127) - UiZoom(25-500%) - TextZoom(25-500%) - PictureMode(Auto,FitW/H) // TODO: Fav/Continue (Chapter Entry) -> (Content Scroll Percent) // TODO: State S/L -- Restore on back // TODO: Read Zip file directly // TODO: Indexing for Improve load speed // BUG: DON'T Reload after screen rotated // TODO: Implement more functions to improve this app. } // ReSharper disable once ClassNeverInstantiated.Global public partial class MainActivity { private const string MainUrl = //#if DEBUG // "http://192.168.23.97/main.html" // // "http://192.168.1.233/main.html" // // "http://lnoidebugserver/main.html" //#else "file:///android_asset/main.html" //#endif ; private const string JsBindName = "LnoidNativeFunctions"; private WebView _view; protected override void OnCreate(Bundle bundle) { base.OnCreate(bundle); #if !Android22 if (CheckSelfPermission(Manifest.Permission.ReadExternalStorage) != Permission.Granted) RequestPermissions(new[] { Manifest.Permission.ReadExternalStorage }, 123321123); // is an app-defined int constant that should be quite unique //var uri = Android.Net.Uri.Parse("package:" + Application.Context.ApplicationInfo.PackageName); //var intent = new Intent(Android.Provider.Settings.ActionManageAppAllFilesAccessPermission, uri, ApplicationContext, GetType()); //StartActivity(intent); #endif RequestWindowFeature(WindowFeatures.NoTitle); #if DEBUG || WV_DEBUG #if !Android22 if (Build.VERSION.SdkInt >= BuildVersionCodes.Kitkat) WebView.SetWebContentsDebuggingEnabled(true); #else WebView.EnablePlatformNotifications(); #endif #else #if !Android22 if (Build.VERSION.SdkInt >= BuildVersionCodes.Kitkat) WebView.SetWebContentsDebuggingEnabled(false); #endif #endif _view = new WebView(this); _view.SetWebChromeClient(new WebChromeClient()); #pragma warning disable 618 _view.LayoutParameters = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.FillParent, ViewGroup.LayoutParams.FillParent); #pragma warning restore 618 _view.SetPadding(0, 0, 0, 0); SetContentView(_view); _view.SetInitialScale(100); _view.Settings.JavaScriptEnabled = true; var nativeFunctions = new NativeFunctions(this, JsBindName); _view.AddJavascriptInterface(nativeFunctions, JsBindName); _view.LoadUrl(MainUrl); } internal void CallJs(string code) { _view.LoadUrl($"javascript:{code}"); } public override void OnBackPressed() { CallJs("__backButton_Pressed();"); } public override bool OnKeyUp(Keycode keyCode, KeyEvent e) { if (keyCode == Keycode.Menu) { CallJs("__menuButton_Pressed();"); return true; } return base.OnKeyUp(keyCode, e); } #if WV_DEBUG public void ReLoad() { _view.LoadUrl(MainUrl); } #endif } } // ReSharper disable once CheckNamespace namespace JsLab.JsApi { using System; using System.Collections.Generic; using System.Linq; using System.Reflection; public abstract class JsApiBase : Java.Lang.Object { private readonly string _jsBindName; private readonly IReadOnlyDictionary _jsApiMethodDictionary; [AttributeUsage(AttributeTargets.Method)] protected sealed class JsApiAttribute : Attribute { } // ReSharper disable UnusedAutoPropertyAccessor.Local // ReSharper disable ClassNeverInstantiated.Local private sealed class InvokeRequestModel { public string MethodName { get; set; } public object[] Arguments { get; set; } } private sealed class InvokeRequestCallback { public string CallBack { get; set; } } // ReSharper restore ClassNeverInstantiated.Local // ReSharper restore UnusedAutoPropertyAccessor.Local private class JsApiMethod { public MethodInfo MethodInfo { get; } public IReadOnlyList ParameterInfos { get; } public JsApiMethod(MethodInfo methodInfo, IReadOnlyList parameterInfos) { MethodInfo = methodInfo; ParameterInfos = parameterInfos; } } private class JsApiParmeter { public string Name { get; } public bool IsCallback { get; } public JsApiParmeter(string name, bool isCallback) { Name = name; IsCallback = isCallback; } } protected JsApiBase(string jsBindName) { var myType = GetType(); var tObjectAction = typeof(Action); _jsApiMethodDictionary = myType.GetMethods() .Where(p => p.IsDefined(typeof(JsApiAttribute), true)) .Select(p => new JsApiMethod( p, p.GetParameters() .Select(q => new JsApiParmeter(q.Name, q.ParameterType == tObjectAction)) .ToArray() )) .ToDictionary(p => p.MethodInfo.Name); _jsBindName = jsBindName; } [Export] #if !Android22 [JavascriptInterface] #endif // ReSharper disable once MemberCanBePrivate.Global public string GenerateFunctionJsonStub() { var methods = _jsApiMethodDictionary.Values.ToArray(); // ReSharper disable once UseObjectOrCollectionInitializer var list = new List(methods.Length); list.Add("$mkCallback:function(c,r){" + "if(typeof c!=='function') " + "return undefined;" + "if(!this.$cbIdx)" + "this.$cbIdx=1;" + "else " + "this.$cbIdx++;" + "var cbName='__cb_'+this.$cbIdx;" + "window[cbName]=function(d){" + "if(r)delete window[cbName];" + "c(d);" + "};" + "return cbName;" + "}"); foreach (var jsApiMethod in methods) { var method = jsApiMethod.MethodInfo; var parameterInfos = jsApiMethod.ParameterInfos; var lstArgs = parameterInfos.Select((p, i) => "p" + i + "_" + p.Name).ToList(); var argList = string.Join(",", lstArgs); var fnSync = $"{method.Name}:function({argList})" + "{" + "var arg={" + $"MethodName:'{method.Name}',Arguments:[{argList}]" + "};" + "var argson=JSON.stringify(arg);" + $"return JSON.parse({_jsBindName}.InvokeInterface(argson));" + "}"; lstArgs.Insert(0, "p_callback"); var argListAsync = string.Join(",", lstArgs); //Async version with callback var fnAsync = $"{method.Name}Async:function({argListAsync})" + "{" + $"if(!p_callback) throw 'callback are required.[{method.Name}Async]';" + "var args=[];"; var lstCbToDel = new List(parameterInfos.Count); for (var i = 0; i < parameterInfos.Count; i++) { var item = parameterInfos[i]; var pName = lstArgs[i + 1];// skip p_callback if (item.IsCallback) { fnAsync += $"var cbName_{i}='undefined';" + $"if(typeof {pName}!=='function')" + "{" + "args.push(null);" + "}else{" + $"cbName_{i}=this.$mkCallback({pName},false);" + $"args.push(cbName_{i});" + "}"; lstCbToDel.Add($"cbName_{i}"); } else { fnAsync += $"args.push({pName});"; } } fnAsync += "var cbName=this.$mkCallback(function(retr){" + string.Join("", lstCbToDel.Select(p => $"delete window[{p}];")) + "p_callback(retr);" + "},true);" + "var arg={" + $"MethodName:'{method.Name}'" + ",Arguments:args" + ",CallBack:cbName" + "};" + "var argson=JSON.stringify(arg);" + $"{_jsBindName}.InvokeInterfaceAsync(argson)" + // cancel token handle: too lazy to implement! "}"; list.Add(fnSync); list.Add(fnAsync); } return "{" + string.Join(",", list) + "}"; } [Export] #if !Android22 [JavascriptInterface] #endif // ReSharper disable once UnusedMember.Global public string GenerateFunctionStubForEval() { var ret = $"({GenerateFunctionJsonStub()})"; return ret; } [Export] #if !Android22 [JavascriptInterface] #endif // ReSharper disable once UnusedMember.Global public void InvokeInterfaceAsync(string json) { // cancel token handle: too lazy to implement! #if Android22 var im = fastJSON.JSON.ToObject(json); #else var im = Newtonsoft.Json.JsonConvert.DeserializeObject(json); #endif Task.Factory.StartNew(() => { var ret = InvokeInterface(json); var code = $"{im.CallBack}({ret})"; CallJs(code); }); } [Export] #if !Android22 [JavascriptInterface] #endif // ReSharper disable once UnusedMember.Global // ReSharper disable once MemberCanBePrivate.Global public string InvokeInterface(string json) { object r; try { #if Android22 var im = fastJSON.JSON.ToObject(json); #else var im = Newtonsoft.Json.JsonConvert.DeserializeObject(json); #endif var method = _jsApiMethodDictionary[im.MethodName]; for (var i = 0; i < im.Arguments.Length; i++) { if (im.Arguments[i] == null) { im.Arguments[i] = Type.Missing; } else if (method.ParameterInfos[i].IsCallback) { var jsFunction = (string)im.Arguments[i]; im.Arguments[i] = new Action( o => CallJs($"{jsFunction}({Newtonsoft.Json.JsonConvert.SerializeObject(o)})")); } } r = new { Success = true, Result = method.MethodInfo.Invoke(this, im.Arguments) }; } catch (Exception ex) { r = new { Success = false, Exception = ex }; } #if Android22 return fastJSON.JSON.ToJSON(r); #else return Newtonsoft.Json.JsonConvert.SerializeObject(r); #endif } protected abstract void CallJs(string code); } } #if Android22 namespace System.Net { internal class WebUtility { public static readonly Func HtmlEncode = Web.HttpUtility.HtmlEncode; } } #endif