From 9f9fb637b87170fb4620d8151c758043d33707e9 Mon Sep 17 00:00:00 2001 From: Tom Marshall Date: Fri, 1 Jun 2012 15:20:26 -0700 Subject: [PATCH] Initial commit. This is (mostly) functional. Still needs some cleanup. --- .gitignore | 3 + AndroidManifest.xml | 33 ++ ant.properties | 17 + build.xml | 83 +++++ main.xml | 7 + proguard-project.txt | 20 + proguard.cfg | 36 ++ project.properties | 14 + res/drawable-hdpi/ic_menu_compose.png | Bin 0 -> 2324 bytes res/drawable-hdpi/ic_menu_delete.png | Bin 0 -> 2029 bytes res/drawable-hdpi/ic_menu_discard.png | Bin 0 -> 3698 bytes res/drawable-hdpi/ic_menu_edit.png | Bin 0 -> 2399 bytes res/drawable-hdpi/ic_menu_fetch.png | Bin 0 -> 534 bytes res/drawable-hdpi/ic_menu_install.png | Bin 0 -> 534 bytes res/drawable-hdpi/ic_menu_refresh.png | Bin 0 -> 922 bytes res/drawable-hdpi/ic_menu_revert.png | Bin 0 -> 2093 bytes res/drawable-hdpi/ic_menu_save.png | Bin 0 -> 2050 bytes res/drawable-hdpi/ic_menu_settings.png | Bin 0 -> 2324 bytes res/drawable-hdpi/icon.png | Bin 0 -> 8435 bytes res/drawable-ldpi/icon.png | Bin 0 -> 2993 bytes res/drawable-mdpi/icon.png | Bin 0 -> 7687 bytes res/layout/list_item.xml | 7 + res/layout/romlist.xml | 10 + res/layout/romlist_entry.xml | 11 + res/menu/romdetail_options_menu.xml | 11 + res/menu/romlist_options_menu.xml | 11 + res/values/arrays.xml | 12 + res/values/strings.xml | 23 ++ res/xml/preferences.xml | 25 ++ src/tdm/romkeeper/BrowserFileDownloader.java | 93 +++++ src/tdm/romkeeper/BrowserFilePatcher.java | 109 ++++++ src/tdm/romkeeper/DbAdapter.java | 193 ++++++++++ .../FileDownloadProgressUpdater.java | 72 ++++ src/tdm/romkeeper/FileDownloadService.java | 348 ++++++++++++++++++ src/tdm/romkeeper/FileDownloader.java | 92 +++++ src/tdm/romkeeper/FilePatcher.java | 122 ++++++ src/tdm/romkeeper/FileVerifier.java | 93 +++++ src/tdm/romkeeper/HttpFileDownloader.java | 57 +++ src/tdm/romkeeper/HttpFilePatcher.java | 68 ++++ src/tdm/romkeeper/ManifestCheckerService.java | 190 ++++++++++ src/tdm/romkeeper/ManifestFetcher.java | 79 ++++ src/tdm/romkeeper/ManifestReader.java | 77 ++++ src/tdm/romkeeper/Rom.java | 112 ++++++ src/tdm/romkeeper/RomDataProvider.java.hide | 210 +++++++++++ src/tdm/romkeeper/RomKeeperActivity.java | 316 ++++++++++++++++ .../RomKeeperPreferenceActivity.java | 14 + src/tdm/romkeeper/StartupReceiver.java | 25 ++ src/tdm/romkeeper/StreamListener.java | 7 + 48 files changed, 2600 insertions(+) create mode 100644 .gitignore create mode 100644 AndroidManifest.xml create mode 100644 ant.properties create mode 100644 build.xml create mode 100644 main.xml create mode 100644 proguard-project.txt create mode 100644 proguard.cfg create mode 100644 project.properties create mode 100644 res/drawable-hdpi/ic_menu_compose.png create mode 100644 res/drawable-hdpi/ic_menu_delete.png create mode 100644 res/drawable-hdpi/ic_menu_discard.png create mode 100644 res/drawable-hdpi/ic_menu_edit.png create mode 100644 res/drawable-hdpi/ic_menu_fetch.png create mode 100644 res/drawable-hdpi/ic_menu_install.png create mode 100644 res/drawable-hdpi/ic_menu_refresh.png create mode 100644 res/drawable-hdpi/ic_menu_revert.png create mode 100644 res/drawable-hdpi/ic_menu_save.png create mode 100644 res/drawable-hdpi/ic_menu_settings.png create mode 100644 res/drawable-hdpi/icon.png create mode 100644 res/drawable-ldpi/icon.png create mode 100644 res/drawable-mdpi/icon.png create mode 100644 res/layout/list_item.xml create mode 100644 res/layout/romlist.xml create mode 100644 res/layout/romlist_entry.xml create mode 100644 res/menu/romdetail_options_menu.xml create mode 100644 res/menu/romlist_options_menu.xml create mode 100644 res/values/arrays.xml create mode 100644 res/values/strings.xml create mode 100644 res/xml/preferences.xml create mode 100644 src/tdm/romkeeper/BrowserFileDownloader.java create mode 100644 src/tdm/romkeeper/BrowserFilePatcher.java create mode 100644 src/tdm/romkeeper/DbAdapter.java create mode 100644 src/tdm/romkeeper/FileDownloadProgressUpdater.java create mode 100644 src/tdm/romkeeper/FileDownloadService.java create mode 100644 src/tdm/romkeeper/FileDownloader.java create mode 100644 src/tdm/romkeeper/FilePatcher.java create mode 100644 src/tdm/romkeeper/FileVerifier.java create mode 100644 src/tdm/romkeeper/HttpFileDownloader.java create mode 100644 src/tdm/romkeeper/HttpFilePatcher.java create mode 100644 src/tdm/romkeeper/ManifestCheckerService.java create mode 100644 src/tdm/romkeeper/ManifestFetcher.java create mode 100644 src/tdm/romkeeper/ManifestReader.java create mode 100644 src/tdm/romkeeper/Rom.java create mode 100644 src/tdm/romkeeper/RomDataProvider.java.hide create mode 100644 src/tdm/romkeeper/RomKeeperActivity.java create mode 100644 src/tdm/romkeeper/RomKeeperPreferenceActivity.java create mode 100644 src/tdm/romkeeper/StartupReceiver.java create mode 100644 src/tdm/romkeeper/StreamListener.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f31c53d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +local.properties +bin/* +gen/* diff --git a/AndroidManifest.xml b/AndroidManifest.xml new file mode 100644 index 0000000..440b167 --- /dev/null +++ b/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ant.properties b/ant.properties new file mode 100644 index 0000000..b0971e8 --- /dev/null +++ b/ant.properties @@ -0,0 +1,17 @@ +# This file is used to override default values used by the Ant build system. +# +# This file must be checked into Version Control Systems, as it is +# integral to the build system of your project. + +# This file is only used by the Ant script. + +# You can use this to override default values such as +# 'source.dir' for the location of your java source folder and +# 'out.dir' for the location of your output folder. + +# You can also use it define how the release builds are signed by declaring +# the following properties: +# 'key.store' for the location of your keystore and +# 'key.alias' for the name of the key to use. +# The password will be asked during the build when you use the 'release' target. + diff --git a/build.xml b/build.xml new file mode 100644 index 0000000..eaecd6f --- /dev/null +++ b/build.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/main.xml b/main.xml new file mode 100644 index 0000000..f02c9fd --- /dev/null +++ b/main.xml @@ -0,0 +1,7 @@ + + + + diff --git a/proguard-project.txt b/proguard-project.txt new file mode 100644 index 0000000..f2fe155 --- /dev/null +++ b/proguard-project.txt @@ -0,0 +1,20 @@ +# To enable ProGuard in your project, edit project.properties +# to define the proguard.config property as described in that file. +# +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in ${sdk.dir}/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the ProGuard +# include property in project.properties. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} diff --git a/proguard.cfg b/proguard.cfg new file mode 100644 index 0000000..12dd039 --- /dev/null +++ b/proguard.cfg @@ -0,0 +1,36 @@ +-optimizationpasses 5 +-dontusemixedcaseclassnames +-dontskipnonpubliclibraryclasses +-dontpreverify +-verbose +-optimizations !code/simplification/arithmetic,!field/*,!class/merging/* + +-keep public class * extends android.app.Activity +-keep public class * extends android.app.Application +-keep public class * extends android.app.Service +-keep public class * extends android.content.BroadcastReceiver +-keep public class * extends android.content.ContentProvider +-keep public class * extends android.app.backup.BackupAgentHelper +-keep public class * extends android.preference.Preference +-keep public class com.android.vending.licensing.ILicensingService + +-keepclasseswithmembernames class * { + native ; +} + +-keepclasseswithmembernames class * { + public (android.content.Context, android.util.AttributeSet); +} + +-keepclasseswithmembernames class * { + public (android.content.Context, android.util.AttributeSet, int); +} + +-keepclassmembers enum * { + public static **[] values(); + public static ** valueOf(java.lang.String); +} + +-keep class * implements android.os.Parcelable { + public static final android.os.Parcelable$Creator *; +} diff --git a/project.properties b/project.properties new file mode 100644 index 0000000..b7c2081 --- /dev/null +++ b/project.properties @@ -0,0 +1,14 @@ +# This file is automatically generated by Android Tools. +# Do not modify this file -- YOUR CHANGES WILL BE ERASED! +# +# This file must be checked in Version Control Systems. +# +# To customize properties used by the Ant build system edit +# "ant.properties", and override values to adapt the script to your +# project structure. +# +# To enable ProGuard to shrink and obfuscate your code, uncomment this (available properties: sdk.dir, user.home): +#proguard.config=${sdk.dir}/tools/proguard/proguard-android.txt:proguard-project.txt + +# Project target. +target=android-10 diff --git a/res/drawable-hdpi/ic_menu_compose.png b/res/drawable-hdpi/ic_menu_compose.png new file mode 100644 index 0000000000000000000000000000000000000000..bc153fac0f710309ed366cfc433ee0260df1cf81 GIT binary patch literal 2324 zcmZ{lX*3&%7RO^3r7~$cty08N)E3($_N7!ps~U>hCP73>tg$s(q-YG)1htJ?YHMkW z(4etQ)hKNy#nf6$Q6)&!P)pSqeeaxi-uv)A-1|TG{_g+WkNZ6#|<}796p{%YIZD87$kRW8~=qbj#^^1nBz$;*0MCJ9nW;i=7?? z$8HQ_{bVYD)uB6ystcCs1^VLlmouwel@(=&bZyX&TM9RnDs*0g5b2?=`Ur)xle*}1 z%Eq3i?l)u`!tD~_UTAjLs`HHH7`P8p_1i}i6?=Uz{I2jWa@r_hO0?)o+G-xfzE@tdO-dZ_dq_7e zV}Sp#l@aOtUbovZ(fj_QPQ>aD)`pwXOM(_QBEFZUxhH;MhC!rrl~z1nw5(k}sKkuG zFa_#&uz<*n&1&i4ov6A`#kCx!#Iya5E5qB>3qxl!Gl~-Y1aTkSLL%8CD~<;Y)J2xa zRqfOb{={}V{Ow7SkX^7Sre<^G-T`CMclUGumo|AzRzNw?o%3_)qHGqo-<~=sX>%v4 z#Md%#&5@Avh87*^v5?z!pH%Ba(~~^&DaZ;&29 zRhdK3i9Zpnp}jCbK6z&63)g7}^R!7BM)i4J`_@OFJ9Y^#U*GTW)(I4 zq;EXe`ykz*A_j4c>uY-Ut0%TR^TWH2c^GnYj0`;ClCQUdvn9X*TC?%dpBPvlwdZFy z2_F^RWPTK(egG(*)@$DJ~Yv9R+T^6}}ii2=VVLqw@N`Mb5+9(Y?v{~Qb1 zEks(|oNU?ToqC4(xQplC^fThZ!4^mrK!Bf{d59rxL7CX~LjJTz?q7N*u#}{T)%OJU*7kVxp zEu?oB?7Qp^XmMSzj#B8hQe4+hyMyKUYhec~ftN0y2}XshMvG)Sr-Xjnqe=}{&3#?A z^0zDLi~YJ)(`B&4Go4Po$!y0q)gT6rI~`k^-Vg(bTg^!Ra`ZH}fEjjU`14=gr*F07 zx4TaWH_Y$WCwwj<$K|DE!;o89>Y*tYTyIN?9^7ya>X|Jz<%igkS!131pYWfvOx0qY8tu;+(?#CUa%lrd--s=0fzWwD`d&BW+t6BN%M7jkeILoF=7MM#v2pmY5lnJlGL_Mcv0?uzp_{6U2KiY}%HwQ4qT> zoTS0GO%LSsASKQ`wdf>BE2t8G5bH|3b+2LX-`R@(YWXfvAiX>@>zznk+F|~ylI%Q4 z0ay~w#6J>uC;)w!9!wjC(AG0_)i*FPFgAf1Xv5$pFqr@KS%v>G5W@lpf${&}09LzN zacGeK{vnVMLc&Fw_=QAA0$@6NE+l-yAv*eBC?w1e7X>hkekiVVNMybfm#{!mEH)Ac hunstciYOu$k3-|I0r3(2IJ3he0LsqE_Nfhq@(*hrIp_cY literal 0 HcmV?d00001 diff --git a/res/drawable-hdpi/ic_menu_delete.png b/res/drawable-hdpi/ic_menu_delete.png new file mode 100644 index 0000000000000000000000000000000000000000..ce5ecc48a161a5a4852cd3f28b205e3a91a780b0 GIT binary patch literal 2029 zcmZ{ldpr{g8^`C8a=ev$iKwj$t>v;UmpbmT)xnsQCYP~E%-D>%X3ZhFge1msC(~Jo z^19|;uCs_E>_qNiX`~5z=l$dTyzd|HAJ6mqJkR&}eV#vm@hDsCgTEa61pojXL?SF5 zM2gs}1CpXHoC)s}i8%I}wIu*ho&?epP0|4fXY$_3zssuI*LZIk9AM`CpXEU4*L?th zR14D5%<&F)B`Y$~<+@VKz4_*ftjU$-;NIZc5?W_PxhV&@wSUkuX#`+~RIq`-Ig)R7 zTQXtAaE>M;IzHan{FJ-jDU`xzDFs-%?E7dpF^sX|xgT-|7E3Y8A9om&S)shi@NG|g zi1+J;YJTjCwMT}+Wo}M5PtZhW*UdNT52hvu6@!iogvz036DYr4Bpk8iT#r_UF?`tj z*2Y`qa;;2*Gn=^4l9XJgPJu>Eg~2*fa>q@ul^^fg3Ruj~ut$o?TVD27p zXI4p@YY_SB%dNR40asykJ4$GxzG>|F#9FW%CZSd9@ZD~vzfxFp@hpKQDFuq_MH&#$qN38f;BjpRax)Ys8H9Hw*2;%iwTs^E|-ZZ3|EI1@5 zJDigPwsP99Me5nPJFXB<%*Ztgd~>L4;!KQBD17V6=;_eUiu9Bvay{8;+W1U%7?LqH z(aN4NnQNYWwu)^y0Hn^qIv$%3UQ5;O>3*+8wTxLpDy~D>2A!75l=9>n7}b4~uKz52 z9idQd^XvM{zD4qQ>1y*rPk$zZqjpm2T6{{c`y!)DR?Z_Jy&LNCj9h$kAhz?;TOmp? zs`~!Va@f8ePQTyObjW5Is_E&$`<(@nR9Nhz)=#K(z)(IK+jC3yo@Y)%rk_cy8aXD& zNAn}`*6>(R)=xvSER3|o6SP&CO9x+1Y5fjt!< z{exC;+uqbOK{D5{d*C~bE35+lutMI1lFNq(5_IwBG1lyloSd-!y=edRouP$|w{86SD+22ZzTJx(b61FI)S_VtJ0rSgt>5aZ2$Bv2cZA$FaPgxTe(V17vEB-(jixRhfZ{9Kk67<5b>bhgNZO zcXLojho-Eolo-+m6DgFvSVQfDh&#u`l|wRtf^RK5k=lJO@U@Mx#%kIqe|u0)TSeGb z=c@pAjq>kp`u(0=ldzwdQSYb}C(e4E3=uap4K#3gm1aBfU83||(gZe+a4ekDp0}l? zcA;yul*vzn)*f$ro46eKlzF$S)JP#EO&)zR`0G+jINqCO-FDfr>ziGdH<`>?FoQ4_6;nQ)y?Je?tr8?zmdDl@2Y61KWh zPjq@9kqF&#YQ2Gz#e-=C5~-C>eFX|u@zg|j4&#XHG9W`cw<~3NlXo?74+{9HY8x8I zC-fuHI5@l-aD7wB}I3jkZRXZt?O+xn)nZc(d z27B_LlqYo?&qu_{jAIo~E0q8f;vwhpnT3fGYg_7Lq!XN>-0~&!n;LxojT~~>x$>gM zS$5T+3{K+iTB&ROf?|o+<)&3aVW_q_qU!r&et>ne`HAGTkcJ`swRxKxa~Mre2wG9D zK<{fOPs#P_i=b>!M{Q%^5O;AkU>p4F1MLp_Pu_%(wAvnADdY7=w798~BuGq^-(8E2 zsMq~O$Pc22&{6pb9SI9O+veJQO>2lab}lSJOHtU(cA2u>bm@Hu|Leeqe#a@wl{S zn|-D};Bk!PT_({%eab9K_3{R%wwd9I7Jm3saLzg{AYy^0DhvxG9S*-Rqph*x61c)5 zti6lLHcb2qsUBZP3>Uif8kH2;P8i&%VDrq!h)MNddimf(|Gz>0taFjbaCq+n-p4NpM>4|tQAhxt3%cka?+6h(^k2x2h{cfsFiP10 vpopmK5i}7W6yia`0nEHa@C1_J;f-^^d3c2ecH=IKjsVCjww5o<-S7VkfcMI5 literal 0 HcmV?d00001 diff --git a/res/drawable-hdpi/ic_menu_discard.png b/res/drawable-hdpi/ic_menu_discard.png new file mode 100644 index 0000000000000000000000000000000000000000..4683f61935a92132db1aa9f9394931f24840c8c8 GIT binary patch literal 3698 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 C1XCXX literal 0 HcmV?d00001 diff --git a/res/drawable-hdpi/ic_menu_fetch.png b/res/drawable-hdpi/ic_menu_fetch.png new file mode 100644 index 0000000000000000000000000000000000000000..9f7d9820260a6868915b6af8ac0506aa3c92ea29 GIT binary patch literal 534 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA0wn)(8}a}trX+877l!}s{b%+Ad7K3vk;OpT z1B~5HX4`=T%L*LRfizez!?|}o;S3Cna-J@ZArXh)PIt^ZWFX)g|7L+#Xx|lC!P#*d z<_}zQ0&Wzlgsi;qqg=3k1=FR%)ms#P>hIZj{Jim|e>Wz4y|1-~oAtp52X_ySJ@a{F z8pY!pd8=ata|)OK)R^bs%e-gv@njYYV}}FMM*^=ISTA6xIitF8OW6Vbsf>~n80#52 z(!|#u^=I(*E4;?|v_+EDO5%CDhxqH5@+x{@dIQvZoV@Ba*)u@duWjQN%#4a*>H%(2q zManZsd5g!H0~Nl<`^+wz@;*gTe~DWM4f*#X5) literal 0 HcmV?d00001 diff --git a/res/drawable-hdpi/ic_menu_install.png b/res/drawable-hdpi/ic_menu_install.png new file mode 100644 index 0000000000000000000000000000000000000000..9f7d9820260a6868915b6af8ac0506aa3c92ea29 GIT binary patch literal 534 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA0wn)(8}a}trX+877l!}s{b%+Ad7K3vk;OpT z1B~5HX4`=T%L*LRfizez!?|}o;S3Cna-J@ZArXh)PIt^ZWFX)g|7L+#Xx|lC!P#*d z<_}zQ0&Wzlgsi;qqg=3k1=FR%)ms#P>hIZj{Jim|e>Wz4y|1-~oAtp52X_ySJ@a{F z8pY!pd8=ata|)OK)R^bs%e-gv@njYYV}}FMM*^=ISTA6xIitF8OW6Vbsf>~n80#52 z(!|#u^=I(*E4;?|v_+EDO5%CDhxqH5@+x{@dIQvZoV@Ba*)u@duWjQN%#4a*>H%(2q zManZsd5g!H0~Nl<`^+wz@;*gTe~DWM4f*#X5) literal 0 HcmV?d00001 diff --git a/res/drawable-hdpi/ic_menu_refresh.png b/res/drawable-hdpi/ic_menu_refresh.png new file mode 100644 index 0000000000000000000000000000000000000000..685873079f7b8758f0d380d34b7e8722f2c46883 GIT binary patch literal 922 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 CXx!rf literal 0 HcmV?d00001 diff --git a/res/drawable-hdpi/ic_menu_save.png b/res/drawable-hdpi/ic_menu_save.png new file mode 100644 index 0000000000000000000000000000000000000000..62d0b9a00b810234fecb3d45ba97bb1d7eb13726 GIT binary patch literal 2050 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 literal 0 HcmV?d00001 diff --git a/res/drawable-hdpi/icon.png b/res/drawable-hdpi/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3d009f6a963986d0b7711453e754f2aa2c071a2f GIT binary patch literal 8435 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 literal 0 HcmV?d00001 diff --git a/res/drawable-mdpi/icon.png b/res/drawable-mdpi/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e6b6b2c9ff2972bb865aa3bd78d502aefd9c1923 GIT binary patch literal 7687 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 B^Edzi literal 0 HcmV?d00001 diff --git a/res/layout/list_item.xml b/res/layout/list_item.xml new file mode 100644 index 0000000..fdf5a0b --- /dev/null +++ b/res/layout/list_item.xml @@ -0,0 +1,7 @@ + + + diff --git a/res/layout/romlist.xml b/res/layout/romlist.xml new file mode 100644 index 0000000..b6c15b6 --- /dev/null +++ b/res/layout/romlist.xml @@ -0,0 +1,10 @@ + + + + diff --git a/res/layout/romlist_entry.xml b/res/layout/romlist_entry.xml new file mode 100644 index 0000000..c6c0c81 --- /dev/null +++ b/res/layout/romlist_entry.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/res/menu/romdetail_options_menu.xml b/res/menu/romdetail_options_menu.xml new file mode 100644 index 0000000..512db77 --- /dev/null +++ b/res/menu/romdetail_options_menu.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/res/menu/romlist_options_menu.xml b/res/menu/romlist_options_menu.xml new file mode 100644 index 0000000..a563acd --- /dev/null +++ b/res/menu/romlist_options_menu.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/res/values/arrays.xml b/res/values/arrays.xml new file mode 100644 index 0000000..c560dda --- /dev/null +++ b/res/values/arrays.xml @@ -0,0 +1,12 @@ + + + + + Hourly + Daily + + + 3600 + 86400 + + diff --git a/res/values/strings.xml b/res/values/strings.xml new file mode 100644 index 0000000..00a9945 --- /dev/null +++ b/res/values/strings.xml @@ -0,0 +1,23 @@ + + + RomKeeper + + File Download Service + Manifest Checker Service + + Refresh + Settings + + Fetch + Install + + + Empty + + Repeating Scheduled + Repeating Unscheduled + + Alarm Service Started + Alarm Service Finished + Alarm Service Label + diff --git a/res/xml/preferences.xml b/res/xml/preferences.xml new file mode 100644 index 0000000..117f5c2 --- /dev/null +++ b/res/xml/preferences.xml @@ -0,0 +1,25 @@ + + + + + + + + + + diff --git a/src/tdm/romkeeper/BrowserFileDownloader.java b/src/tdm/romkeeper/BrowserFileDownloader.java new file mode 100644 index 0000000..243ee14 --- /dev/null +++ b/src/tdm/romkeeper/BrowserFileDownloader.java @@ -0,0 +1,93 @@ +package tdm.romkeeper; + +import android.content.Context; +import android.content.Intent; + +import android.net.Uri; + +import android.util.Log; + +import android.os.Handler; +import android.os.Message; +import android.os.Process; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.IOException; + +import java.net.URL; +import java.net.URLConnection; + +/* A base/helper class for implementing async HTTP file fetches. */ +class BrowserFileDownloader extends FileDownloader +{ + private Context mContext; + + BrowserFileDownloader(Context ctx, String url, String pathname, long size) { + super(url, pathname, size); + mContext = ctx; + } + BrowserFileDownloader(Context ctx, Handler handler, String url, String pathname, long size) { + super(handler, url, pathname, size); + mContext = ctx; + } + + public void run() { + Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); + + onStart(); + + Log.i("BrowserFileDownloader", "downloading "+getUrl()); + + Intent i = new Intent(Intent.ACTION_VIEW); + i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); // XXX: is this what we want? + i.setData(Uri.parse(getUrl())); + mContext.startActivity(i); + + String filename = getPathname(); + int idx = filename.lastIndexOf('/'); + if (idx > 0) { + filename = filename.substring(idx+1); + } + String dlpath = "/sdcard/download/" + filename; + + long startTime = System.currentTimeMillis(); + File f = new File(dlpath); + long lastTime = startTime; + long lastSize = 0; + while (lastSize < getSize()) { + try { + Thread.sleep(1000); + } + catch (InterruptedException e) { + // Ignore + } + long currentTime = System.currentTimeMillis(); + long currentSize = 0; + if (f.exists()) { + currentSize = f.length(); + } + if (currentSize > lastSize) { + updateBytesWritten((int)(currentSize - lastSize)); + } + else { + if (currentTime - lastTime > 60*1000) { + Log.e("FileDownloader", "Download failed or complete"); + onFinish(FAILURE); + return; + } + } + lastTime = currentTime; + lastSize = currentSize; + } + + File dest = new File("/sdcard/" + filename); + f.renameTo(dest); + + Log.i("FileDownloader", "Download complete"); + onFinish(SUCCESS); + } +} diff --git a/src/tdm/romkeeper/BrowserFilePatcher.java b/src/tdm/romkeeper/BrowserFilePatcher.java new file mode 100644 index 0000000..d4a8a0f --- /dev/null +++ b/src/tdm/romkeeper/BrowserFilePatcher.java @@ -0,0 +1,109 @@ +package tdm.romkeeper; + +import android.content.Context; +import android.content.Intent; + +import android.net.Uri; + +import android.util.Log; + +import android.os.Handler; +import android.os.Message; +import android.os.Process; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.IOException; + +import java.net.URL; +import java.net.URLConnection; + +class BrowserFilePatcher extends FileDownloader +{ + private Context mContext; + private String mDeltaPathname; + private long mDeltaSize; + private String mBasisPathname; + + BrowserFilePatcher(Context ctx, Handler handler, + String deltaUrl, String deltaPathname, long deltaSize, + String outPathname, long outSize, String basisPathname) { + super(handler, deltaUrl, outPathname, outSize); + mContext = ctx; + mDeltaPathname = deltaPathname; + mDeltaSize = deltaSize; + mBasisPathname = basisPathname; + } + + private String basename(String pathname) { + int idx = pathname.lastIndexOf('/'); + if (idx > 0) { + return pathname.substring(idx+1); + } + return pathname; + } + + public void run() { + Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); + + onStart(); + + Log.i("BrowserFilePatcher", "downloading "+getUrl()); + + Intent i = new Intent(Intent.ACTION_VIEW); + i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); // XXX: is this what we want? + i.setData(Uri.parse(getUrl())); + mContext.startActivity(i); + + String dlpath = "/sdcard/download/" + basename(mDeltaPathname); + + long lastTime = System.currentTimeMillis(); + long lastSize = 0; + File f = new File(dlpath); + while (lastSize < mDeltaSize) { + long currentTime = System.currentTimeMillis(); + long currentSize = 0; + if (f.exists()) { + currentSize = f.length(); + } + if (currentSize <= lastSize) { + if (currentTime - lastTime > 60*1000) { + Log.e("BrowserFilePatcher", "Download failed or stalled"); + onFinish(FAILURE); + return; + } + } + lastTime = currentTime; + lastSize = currentSize; + + // XXX: update progress...??? + + try { + Thread.sleep(1000); + } + catch (InterruptedException e) { + // Ignore + } + } + + try { + InputStream is = new FileInputStream(f); + OutputStream os = new FileOutputStream(getPathname()); + FilePatcher patcher = new FilePatcher(this, mBasisPathname, is, os); + patcher.patch(); + + f.delete(); + + Log.i("BrowserFilePatcher", "Download complete"); + onFinish(SUCCESS); + } + catch (Exception e) { + Log.e("BrowserFilePatcher", "Download failed or incomplete"); + onFinish(FAILURE); + } + } +} diff --git a/src/tdm/romkeeper/DbAdapter.java b/src/tdm/romkeeper/DbAdapter.java new file mode 100644 index 0000000..68ba271 --- /dev/null +++ b/src/tdm/romkeeper/DbAdapter.java @@ -0,0 +1,193 @@ +package tdm.romkeeper; + +import android.content.ContentValues; +import android.content.Context; + +import android.database.Cursor; +import android.database.SQLException; +import android.database.sqlite.SQLiteOpenHelper; +import android.database.sqlite.SQLiteDatabase; + +import java.io.File; + +import java.util.ArrayList; + +interface DbObserver +{ + void onContentInsert(String name); + void onContentUpdate(String name); + void onContentDelete(String name); +} + +class DbAdapter +{ + private static class DbHelper extends SQLiteOpenHelper + { + private static final String DB_NAME = "roms"; + private static final int DB_VERSION = 1; + + private static final String DB_CREATE = + "CREATE TABLE romlist (" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "name VARCHAR(64) NOT NULL UNIQUE, " + + "url VARCHAR(256) NOT NULL UNIQUE, " + + "filename VARCHAR(64) NOT NULL UNIQUE, " + + "size INTEGER, " + + "digest CHAR(32), " + + "basis VARCHAR(64), " + + "deltaurl VARCHAR(256), " + + "deltafilename VARCHAR(64), " + + "deltasize INTEGER, " + + "mtime INTEGER, " + + "verified INTEGER" + + ")"; + + DbHelper(Context ctx) { + super(ctx, DB_NAME, null, DB_VERSION); + } + + @Override + public void onCreate(SQLiteDatabase db) { + db.execSQL(DB_CREATE); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + db.execSQL("DROP TABLE IF EXISTS romlist"); + onCreate(db); + } + } + + private final Context mCtx; + private DbHelper mHelper; + private SQLiteDatabase mDb; + + ArrayList mObservers; + + void registerObserver(DbObserver o) { + mObservers.add(o); + } + void removeObserver(DbObserver o) { + mObservers.remove(o); + } + + private static DbAdapter sInstance; + static DbAdapter getInstance(Context ctx) { + if (sInstance == null) { + sInstance = new DbAdapter(ctx); + } + return sInstance; + } + + private DbAdapter(Context ctx) { + mCtx = ctx; + mHelper = new DbHelper(ctx); + mDb = mHelper.getWritableDatabase(); + mObservers = new ArrayList(); + } + + Cursor getRomCursor() { + Cursor c = mDb.query("romlist", + new String[] { "_id", "name" }, + null, null, null, null, null); + return c; + } + + void insert(Rom r) { + ContentValues values = new ContentValues(); + values.put("name", r.getName()); + values.put("url", r.getUrl()); + values.put("filename", r.getFilename()); + values.put("size", r.getSize()); + values.put("digest", r.getDigest()); + values.put("basis", r.getBasis()); + values.put("deltaurl", r.getDeltaUrl()); + values.put("deltafilename", r.getDeltaFilename()); + values.put("deltasize", r.getDeltaSize()); + values.put("mtime", r.getModTime()); + values.put("verified", (r.getVerified() ? 1 : 0)); + mDb.insert("romlist", null, values); + + for (DbObserver o : mObservers) { + o.onContentInsert(r.getName()); + } + } + + void update(Rom r) { + ContentValues values = new ContentValues(); + values.put("url", r.getUrl()); + values.put("filename", r.getFilename()); + values.put("size", r.getSize()); + values.put("digest", r.getDigest()); + values.put("basis", r.getBasis()); + values.put("deltaurl", r.getDeltaUrl()); + values.put("deltafilename", r.getDeltaFilename()); + values.put("deltasize", r.getDeltaSize()); + + mDb.update("romlist", + values, + "name=?", + new String[] { r.getName() }); + + for (DbObserver o : mObservers) { + o.onContentUpdate(r.getName()); + } + } + + void delete(Rom r) { + mDb.delete("romlist", + "name=?", + new String[] { r.getName() }); + + for (DbObserver o : mObservers) { + o.onContentDelete(r.getName()); + } + } + + void deleteAll() { + mDb.delete("romlist", null, null); + //XXX: no observer callback...? + } + + Rom get(String name) { + Rom r = null; + Cursor c = mDb.query("romlist", + new String[] { "url", "filename", "size", "digest", "basis", + "deltaurl", "deltafilename", "deltasize", + "mtime", "verified" }, + "name=?", + new String[] { name }, + null, null, null); + if (c != null) { + c.moveToFirst(); + int idx = 0; + r = new Rom(name); + r.setUrl(c.getString(idx++)); + r.setFilename(c.getString(idx++)); + r.setSize(c.getInt(idx++)); + r.setDigest(c.getString(idx++)); + r.setBasis(c.getString(idx++)); + r.setDeltaUrl(c.getString(idx++)); + r.setDeltaFilename(c.getString(idx++)); + r.setDeltaSize(c.getInt(idx++)); + long mtime = c.getLong(idx++); + boolean verified = (c.getInt(idx++) != 0); + r.setVerified(verified, mtime); + } + return r; + } + + void setVerified(String name, boolean val, long mtime) { + ContentValues values = new ContentValues(); + values.put("mtime", mtime); + values.put("verified", (val ? 1 : 0)); + mDb.update("romlist", + values, + "name=?", + new String[] { name }); + + for (DbObserver o : mObservers) { + o.onContentUpdate(name); + } + } +} diff --git a/src/tdm/romkeeper/FileDownloadProgressUpdater.java b/src/tdm/romkeeper/FileDownloadProgressUpdater.java new file mode 100644 index 0000000..148c743 --- /dev/null +++ b/src/tdm/romkeeper/FileDownloadProgressUpdater.java @@ -0,0 +1,72 @@ +package tdm.romkeeper; + +import android.content.Context; + +import android.util.Log; + +import android.os.Handler; +import android.os.Message; +import android.os.Process; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.IOException; + +import java.net.URL; +import java.net.URLConnection; + +class FileDownloadProgressUpdater implements StreamListener +{ + static final int MSG_START = 100; + static final int MSG_PROGRESS = 101; + static final int MSG_FINISH = 102; + + static final int SUCCESS = 0; + static final int FAILURE = 1; + + private Handler mHandler; + private long mSize; + private long mTotalWritten; + + FileDownloadProgressUpdater(Handler handler, long size) { + mHandler = handler; + mSize = size; + } + + private void sendStatus(int what, int arg1, int arg2) { + mHandler.sendMessage(Message.obtain(mHandler, what, arg1, arg2)); + } + private void sendStatus(int what, int arg1) { sendStatus(what, arg1, 0); } + private void sendStatus(int what) { sendStatus(what, 0, 0); } + + private int calcPercent(long n, long d) { + int pct = (100*((int)(n>>8)))/((int)(d>>8)); + return pct; + } + + long getSize() { return mSize; } + + public void updateBytesRead(int len) {} + + public void updateBytesWritten(int len) { + if (mSize > 0) { + int oldPct = calcPercent(mTotalWritten, mSize); + int newPct = calcPercent(mTotalWritten+len, mSize); + if (newPct > oldPct) { + sendStatus(MSG_PROGRESS, newPct); + } + } + mTotalWritten += len; + } + + void onStart() { + sendStatus(MSG_START); + } + + void onFinish(int status) { + sendStatus(MSG_FINISH, status); + } +} diff --git a/src/tdm/romkeeper/FileDownloadService.java b/src/tdm/romkeeper/FileDownloadService.java new file mode 100644 index 0000000..acf11ce --- /dev/null +++ b/src/tdm/romkeeper/FileDownloadService.java @@ -0,0 +1,348 @@ +package tdm.romkeeper; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; + +import android.content.Context; +import android.content.Intent; + +import android.database.sqlite.SQLiteDatabase; + +import android.util.Log; + +import android.os.Binder; +import android.os.Bundle; +import android.os.Handler; +import android.os.IBinder; +import android.os.Message; +import android.os.RemoteException; + +import android.widget.Toast; + +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.InputStream; + +import java.net.URL; +import java.net.URLConnection; + +import java.util.ArrayList; +import java.util.List; + +/* + * XXX: + * Currently we update the database directly with the download/verify + * status. This is not really good practice, and it is probably the + * only thing preventing this from being a general purpose class. + */ + +public class FileDownloadService extends Service +{ + private static final int DS_NONE = 0; + private static final int DS_VERIFY_EXISTING = 1; + private static final int DS_DOWNLOAD = 2; + private static final int DS_VERIFY_DOWNLOAD = 3; + private static final int DS_COMPLETE = 4; + + static final String EXTRA_NAME = "EXTRA_NAME"; + static final String EXTRA_FILENAME = "EXTRA_FILENAME"; + static final String EXTRA_SIZE = "EXTRA_SIZE"; + static final String EXTRA_DIGEST = "EXTRA_DIGEST"; + static final String EXTRA_BASIS = "EXTRA_BASIS"; + static final String EXTRA_DELTAURL = "EXTRA_DELTAURL"; + static final String EXTRA_DELTAFILENAME = "EXTRA_DELTAFILENAME"; + static final String EXTRA_DELTASIZE = "EXTRA_DELTASIZE"; + + static final int NOTIFICATION_ID = 1; /* XXX: need one id per file download */ + + class DownloadEntry + { + int mState; + int mProgress; /* Download: 0..79, Verify: 80..99 */ + String mUrl; + String mName; + String mPathname; + long mSize; + String mDigest; + String mBasis; + String mDeltaUrl; + String mDeltaPathname; + long mDeltaSize; + } + + class DownloadStatusHandler extends Handler + { + private FileDownloadService mOwner; + private DownloadEntry mEntry; + + DownloadStatusHandler(FileDownloadService owner, DownloadEntry e) { + mOwner = owner; + mEntry = e; + } + + public void handleMessage(Message msg) { + switch (msg.what) { + case FileDownloader.MSG_START: + break; + case FileDownloader.MSG_PROGRESS: + mOwner.onDownloadProgress(mEntry, msg.arg1); + break; + case FileDownloader.MSG_FINISH: + mOwner.onDownloadFinish(mEntry, msg.arg1); + break; + default: + Log.e("FileDownloadService", "Unknown msg.what=" + msg.what); + } + } + } + class VerifyStatusHandler extends Handler + { + private FileDownloadService mOwner; + private DownloadEntry mEntry; + + VerifyStatusHandler(FileDownloadService owner, DownloadEntry e) { + mOwner = owner; + mEntry = e; + } + + public void handleMessage(Message msg) { + switch (msg.what) { + case FileVerifier.MSG_START: + break; + case FileVerifier.MSG_PROGRESS: + mOwner.onVerifyProgress(mEntry, msg.arg1); + break; + case FileVerifier.MSG_FINISH: + mOwner.onVerifyFinish(mEntry, msg.arg1); + break; + default: + Log.e("FileDownloadService", "Unknown msg.what=" + msg.what); + } + } + } + + private DbAdapter mDbAdapter; + private NotificationManager mNM; + private Notification mNotification; + private PendingIntent mPendingIntent; + private List mDownloads; + + private void updateProgress() { + String status; + int count = mDownloads.size(); + if (count == 0) { + status = "Complete"; + } + else if (count == 1) { + DownloadEntry entry = mDownloads.get(0); + if (entry.mState == DS_VERIFY_EXISTING || entry.mState == DS_VERIFY_DOWNLOAD) { + status = "Verifying: " + entry.mProgress + "% complete"; + } + else { + status = "Downloading: " + entry.mProgress + "% complete"; + } + } + else { + int total = 0; + for (DownloadEntry entry : mDownloads) { + total += entry.mProgress; + } + int pct = total/count; + status = "Downloading " + count + " files: " + pct + "% complete"; + } + mNotification.setLatestEventInfo(this, "Download", status, mPendingIntent); + mNM.notify(NOTIFICATION_ID, mNotification); + } + + private boolean browserDownloadRequired(String location) { + try { + URL url = new URL(location); + String host = url.getHost().toLowerCase(); + if (host.indexOf("mediafire.com") != -1) { + return true; + } + } + catch (Exception e) { + // Ignore + } + return false; + } + + private void startFetch(DownloadEntry e) { + File f = new File(e.mPathname); + e.mState = DS_NONE; + e.mProgress = 0; + updateProgress(); + if (f.exists()) { + Log.i("FileDownloadService", "verify existing"); + e.mState = DS_VERIFY_EXISTING; + VerifyStatusHandler h = new VerifyStatusHandler(this, e); + FileVerifier fv = new FileVerifier(h, e.mPathname, e.mSize, e.mDigest); + fv.start(); + } + else { + e.mState = DS_DOWNLOAD; + DownloadStatusHandler h = new DownloadStatusHandler(this, e); + + /* + * TODO: figure out if mediafire is in the url. + * If so, launch a BrowserDownloader or BrowserPatcher. + * If not, launch a FileFetcher or FilePatcher. + * + * This should all be hidden behind the scenes, especially the + * patching. Or at least the FilePatcher could extend a new + * class StreamPatcher, or something. + */ + FileDownloader downloader; + if (e.mBasis != null && e.mDeltaUrl != null) { + Log.i("FileDownloadService", "patch"); + if (browserDownloadRequired(e.mDeltaUrl)) { + downloader = new BrowserFilePatcher(this, h, + e.mDeltaUrl, e.mDeltaPathname, e.mDeltaSize, + e.mPathname, e.mSize, e.mBasis); + } + else { + downloader = new HttpFilePatcher(h, + e.mDeltaUrl, e.mDeltaPathname, e.mDeltaSize, + e.mPathname, e.mSize, e.mBasis); + } + } + else { + Log.i("FileDownloadService", "download"); + if (browserDownloadRequired(e.mUrl)) { + downloader = new BrowserFileDownloader(this, h, + e.mUrl, e.mPathname, e.mSize); + } + else { + downloader = new HttpFileDownloader(h, + e.mUrl, e.mPathname, e.mSize); + } + } + downloader.start(); + } + } + + private void onDownloadProgress(DownloadEntry e, int pct) { + e.mProgress = (pct*80)/100; + updateProgress(); + } + + private void onDownloadFinish(DownloadEntry e, int status) { + Log.i("FileDownloadService", "onDownloadFinish: status="+status); + if (status == FileDownloader.SUCCESS) { + e.mState = DS_VERIFY_DOWNLOAD; + e.mProgress = 80; + updateProgress(); + VerifyStatusHandler h = new VerifyStatusHandler(this, e); + FileVerifier fv = new FileVerifier(h, e.mPathname, e.mSize, e.mDigest); + fv.start(); + return; + } + mDownloads.remove(e); + updateProgress(); + + mDbAdapter.setVerified(e.mName, false, 0); + + if (mDownloads.isEmpty()) { + stopSelf(); + } + } + + private void onVerifyProgress(DownloadEntry e, int pct) { + e.mProgress = 80 + (pct*20)/100; + updateProgress(); + } + + private void onVerifyFinish(DownloadEntry e, int status) { + Log.i("FileDownloadService", "onVerifyFinish: status="+status); + if (e.mState == DS_VERIFY_EXISTING && status != FileVerifier.SUCCESS) { + Log.i("FileDownloadService", "Verify failed, downloading..."); + File f = new File(e.mPathname); + f.delete(); + startFetch(e); + return; + } + + mDownloads.remove(e); + + long mtime = 0; + File f = new File(e.mPathname); + if (f.exists()) { + mtime = f.lastModified(); + } + + mDbAdapter.setVerified(e.mName, (status == 0), mtime); + + if (mDownloads.isEmpty()) { + stopSelf(); + } + } + + @Override + public void onCreate() { + Log.i("FileDownloadService", "onCreate"); + mDbAdapter = DbAdapter.getInstance(this); + mNM = (NotificationManager)getSystemService(NOTIFICATION_SERVICE); + mNotification = new Notification(R.drawable.icon, "Download", System.currentTimeMillis()); + mNotification.flags |= Notification.FLAG_AUTO_CANCEL; + mPendingIntent = PendingIntent.getActivity(this, 0, + new Intent(this, RomKeeperActivity.class), 0); + mDownloads = new ArrayList(); + super.onCreate(); + } + + @Override + public void onDestroy() { + Log.i("FileDownloadService", "onDestroy"); + } + + @Override + public IBinder onBind(Intent i) { + return null; + } + + @Override + public int onStartCommand(Intent i, int flags, int startId) { + Bundle extras = i.getExtras(); + if (extras == null) { + Log.e("FileDownloadService", "no extras in intent"); + return START_NOT_STICKY; + } + + DownloadEntry e = new DownloadEntry(); + e.mState = DS_NONE; + e.mProgress = 0; + e.mUrl = i.getData().toString(); + e.mName = extras.getString("EXTRA_NAME"); + e.mPathname = "/sdcard/" + extras.getString(EXTRA_FILENAME); + e.mSize = extras.getLong(EXTRA_SIZE); + e.mDigest = extras.getString(EXTRA_DIGEST); + + Log.i("FileDownloadService", "onStartCommand: url="+e.mUrl+", pathname="+e.mPathname); + + String val; + val = extras.getString(EXTRA_BASIS); + if (val != null && val.length() > 0) { + e.mBasis = "/sdcard/" + val; + Log.i("FileDownloadService", "onStartCommand: basis=" + e.mBasis); + } + val = extras.getString(EXTRA_DELTAURL); + if (val != null && val.length() > 0) { + e.mDeltaUrl = val; + Log.i("FileDownloadService", "onStartCommand: deltaurl=" + e.mDeltaUrl); + e.mDeltaPathname = "/sdcard/" + extras.getString(EXTRA_DELTAFILENAME); + e.mDeltaSize = extras.getLong(EXTRA_DELTASIZE); + } + + mDownloads.add(e); + + startFetch(e); + + return START_STICKY; + } +} diff --git a/src/tdm/romkeeper/FileDownloader.java b/src/tdm/romkeeper/FileDownloader.java new file mode 100644 index 0000000..37fd7b8 --- /dev/null +++ b/src/tdm/romkeeper/FileDownloader.java @@ -0,0 +1,92 @@ +package tdm.romkeeper; + +import android.content.Context; + +import android.util.Log; + +import android.os.Handler; +import android.os.Message; +import android.os.Process; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.IOException; + +import java.net.URL; +import java.net.URLConnection; + +/* + * Base class for all file download operations. File downloads may be + * initiated in a variety of ways: directly (eg. by URLConnection), + * indirectly (eg. by opening a browser), or whatever. + */ +abstract class FileDownloader extends Thread + implements StreamListener +{ + static final int MSG_START = 100; + static final int MSG_PROGRESS = 101; + static final int MSG_FINISH = 102; + + static final int SUCCESS = 0; + static final int FAILURE = 1; + + private Handler mHandler; + private String mUrl; + private String mPathname; + private long mSize; + private long mTotalWritten; + + FileDownloader(String url, String pathname, long size) { + mHandler = null; + mUrl = url; + mPathname = pathname; + mSize = size; + } + FileDownloader(Handler handler, String url, String pathname, long size) { + mHandler = handler; + mUrl = url; + mPathname = pathname; + mSize = size; + } + + private void sendStatus(int what, int arg1, int arg2) { + if (mHandler != null) { + mHandler.sendMessage(Message.obtain(mHandler, what, arg1, arg2)); + } + } + private void sendStatus(int what, int arg1) { sendStatus(what, arg1, 0); } + private void sendStatus(int what) { sendStatus(what, 0, 0); } + + private int calcPercent(long n, long d) { + int pct = (100*((int)(n>>8)))/((int)(d>>8)); + return pct; + } + + String getUrl() { return mUrl; } + String getPathname() { return mPathname; } + long getSize() { return mSize; } + + public void updateBytesRead(int len) {} + + public void updateBytesWritten(int len) { + if (mSize > 0) { + int oldPct = calcPercent(mTotalWritten, mSize); + int newPct = calcPercent(mTotalWritten+len, mSize); + if (newPct > oldPct) { + sendStatus(MSG_PROGRESS, newPct); + } + } + mTotalWritten += len; + } + + void onStart() { + sendStatus(MSG_START); + } + + void onFinish(int status) { + sendStatus(MSG_FINISH, status); + } +} diff --git a/src/tdm/romkeeper/FilePatcher.java b/src/tdm/romkeeper/FilePatcher.java new file mode 100644 index 0000000..7627a5f --- /dev/null +++ b/src/tdm/romkeeper/FilePatcher.java @@ -0,0 +1,122 @@ +package tdm.romkeeper; + +import android.content.Context; + +import android.util.Log; + +import android.os.Handler; +import android.os.Message; + +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.RandomAccessFile; +import java.io.EOFException; +import java.io.IOException; + +class FilePatcher +{ + private static final int MAX_BUFLEN = 64*1024; + + private static final int DELTA_MAGIC = 0x72730236; + + private static final byte OP_END = 0x00; + + private static final byte OP_LITERAL_N1 = 0x41; + private static final byte OP_LITERAL_N2 = 0x42; + private static final byte OP_LITERAL_N4 = 0x43; + + private static final byte OP_COPY_N2_N4 = 0x4b; + private static final byte OP_COPY_N4_N2 = 0x4e; + private static final byte OP_COPY_N4_N4 = 0x4f; + + private StreamListener mListener; + private String mBasisPathname; + private InputStream mDeltaStream; + private OutputStream mOutputStream; + private RandomAccessFile mBasis; + + FilePatcher(StreamListener listener, String basisPathname, InputStream deltas, OutputStream out) { + mListener = listener; + mBasisPathname = basisPathname; + mDeltaStream = deltas; + mOutputStream = out; + } + + private int readInt(int len) throws IOException { + int val = 0; + int b; + while (len-- != 0) { + b = mDeltaStream.read(); + if (b == -1) { + throw new EOFException(); + } + val = (val << 8) | b; + } + return val; + } + + private void readHeader() throws Exception { + if (readInt(4) != DELTA_MAGIC) { + throw new IOException("Bad delta header"); + } + } + + private void applyLiteral(int n) throws Exception { + byte[] buf = new byte[MAX_BUFLEN]; + int bufsz; + int total = readInt(n); + int len; + while (total > 0) { + len = mDeltaStream.read(buf, 0, Math.min(MAX_BUFLEN, total)); + if (len == -1) { + throw new IOException("Failed to read from deltas"); + } + mOutputStream.write(buf, 0, len); + mListener.updateBytesWritten(len); + total -= len; + } + } + private void applyCopy(int n1, int n2) throws Exception { + byte[] buf = new byte[MAX_BUFLEN]; + int bufsz; + int oldoff = readInt(n1); + int total = readInt(n2); + int len; + mBasis.seek(oldoff); + while (total > 0) { + len = mBasis.read(buf, 0, Math.min(MAX_BUFLEN, total)); + if (len == -1) { + throw new IOException("Failed to read from basis"); + } + mOutputStream.write(buf, 0, len); + mListener.updateBytesWritten(len); + total -= len; + } + } + + void patch() throws Exception { + mBasis = new RandomAccessFile(mBasisPathname, "rw"); + readHeader(); + boolean end = false; + while (!end) { + int cmd = mDeltaStream.read(); + switch (cmd) { + case OP_END: end = true; break; + case OP_LITERAL_N1: applyLiteral(1); break; + case OP_LITERAL_N2: applyLiteral(2); break; + case OP_LITERAL_N4: applyLiteral(4); break; + case OP_COPY_N2_N4: applyCopy(2, 4); break; + case OP_COPY_N4_N2: applyCopy(4, 2); break; + case OP_COPY_N4_N4: applyCopy(4, 4); break; + default: + throw new IOException("Bad delta command"); + } + } + mBasis.close(); + } +} diff --git a/src/tdm/romkeeper/FileVerifier.java b/src/tdm/romkeeper/FileVerifier.java new file mode 100644 index 0000000..215f022 --- /dev/null +++ b/src/tdm/romkeeper/FileVerifier.java @@ -0,0 +1,93 @@ +package tdm.romkeeper; + +import android.content.Context; + +import android.util.Log; + +import android.os.Handler; +import android.os.Message; +import android.os.Process; + +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.InputStream; + +import java.security.MessageDigest; + +class FileVerifier extends Thread +{ + static final int MSG_START = 100; + static final int MSG_PROGRESS = 101; + static final int MSG_FINISH = 102; + + static final int SUCCESS = 0; + static final int FAILURE = 1; + + private Handler mHandler; + private String mPathname; + private long mSize; + private String mDigest; + + private void sendStatus(int what, int arg1, int arg2) { + mHandler.sendMessage(Message.obtain(mHandler, what, arg1, arg2)); + } + private void sendStatus(int what, int arg1) { sendStatus(what, arg1, 0); } + private void sendStatus(int what) { sendStatus(what, 0, 0); } + + FileVerifier(Handler handler, String pathname, long size, String digest) { + mHandler = handler; + mPathname = pathname; + mSize = size; + mDigest = digest; + } + + public void run() { + try { + Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); + + sendStatus(MSG_START); + File f = new File(mPathname); + if (f.length() != mSize) { + throw new Exception("Incorrect length"); + } + + Log.i("FileVerifier", "verify " + mPathname); + FileInputStream is = new FileInputStream(f); + MessageDigest digester = MessageDigest.getInstance("MD5"); + byte[] buf = new byte[4096]; + int progress = 0; + long total = 0; + int len; + while ((len = is.read(buf)) > 0) { + digester.update(buf, 0, len); + total += len; + int p = (100*((int)(total>>8)))/((int)(mSize>>8)); + if (p > progress) { + progress = p; + sendStatus(MSG_PROGRESS, progress); + } + } + is.close(); + + byte[] calculatedDigest = digester.digest(); + StringBuffer sb = new StringBuffer(); + for (int i = 0; i < calculatedDigest.length; ++i) { + sb.append(String.format("%02x", calculatedDigest[i])); + } + Log.i("FileVerifier", "Expected digest=" + mDigest); + Log.i("FileVerifier", "Calculated digest=" + sb.toString()); + if (!sb.toString().equalsIgnoreCase(mDigest)) { + throw new Exception("Incorrect digest"); + } + sendStatus(MSG_FINISH, SUCCESS); + } + catch (Exception e) { + Log.e("FileVerifier", "exception: " + e.getMessage()); + e.printStackTrace(); + sendStatus(MSG_FINISH, FAILURE); + } + } +} diff --git a/src/tdm/romkeeper/HttpFileDownloader.java b/src/tdm/romkeeper/HttpFileDownloader.java new file mode 100644 index 0000000..507145a --- /dev/null +++ b/src/tdm/romkeeper/HttpFileDownloader.java @@ -0,0 +1,57 @@ +package tdm.romkeeper; + +import android.os.Handler; +import android.os.Process; + +import android.util.Log; + +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; + +import java.net.URL; +import java.net.URLConnection; + +class HttpFileDownloader extends FileDownloader +{ + private static final int MAX_BUFLEN = 64*1024; + + HttpFileDownloader(Handler handler, String url, String pathname, long size) { + super(handler, url, pathname, size); + } + + public void run() { + Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); + try { + onStart(); + + URL url = new URL(getUrl()); + URLConnection conn = url.openConnection(); + InputStream is = conn.getInputStream(); + int len; + len = conn.getContentLength(); + if (len > 0 && len != getSize()) { + throw new Exception("Bad content length"); + } + Log.i("FileDownloader", "length "+len+" bytes"); + OutputStream os = new FileOutputStream(getPathname()); + + byte[] buf = new byte[MAX_BUFLEN]; + while ((len = is.read(buf)) > 0) { + os.write(buf, 0, len); + updateBytesWritten(len); + } + + os.close(); + is.close(); + + onFinish(SUCCESS); + } + catch (Exception e) { + Log.e("FileDownloader", "exception: " + e.getMessage()); + e.printStackTrace(); + onFinish(FAILURE); + } + } +} diff --git a/src/tdm/romkeeper/HttpFilePatcher.java b/src/tdm/romkeeper/HttpFilePatcher.java new file mode 100644 index 0000000..9e09735 --- /dev/null +++ b/src/tdm/romkeeper/HttpFilePatcher.java @@ -0,0 +1,68 @@ +package tdm.romkeeper; + +import android.content.Context; + +import android.util.Log; + +import android.os.Handler; +import android.os.Message; + +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.RandomAccessFile; +import java.io.EOFException; +import java.io.IOException; + +import java.net.URL; +import java.net.URLConnection; + +class HttpFilePatcher extends FileDownloader +{ + private String mDeltaPathname; + private long mDeltaSize; + private String mBasisPathname; + private RandomAccessFile mBasis; + + HttpFilePatcher(Handler handler, + String deltaUrl, String deltaPathname, long deltaSize, + String outPathname, long outSize, String basisPathname) { + super(handler, deltaUrl, outPathname, outSize); + mDeltaPathname = deltaPathname; + mDeltaSize = deltaSize; + mBasisPathname = basisPathname; + } + + public void run() { + try { + onStart(); + + URL url = new URL(getUrl()); + URLConnection conn = url.openConnection(); + InputStream is = conn.getInputStream(); + int len = conn.getContentLength(); + if (len > 0 && len != mDeltaSize) { + throw new Exception("Bad content length"); + } + Log.i("FileDownloader", "length "+len+" bytes"); + OutputStream os = new FileOutputStream(getPathname()); + + FilePatcher patcher = new FilePatcher(this, mBasisPathname, is, os); + patcher.patch(); + + os.close(); + os.close(); + + onFinish(SUCCESS); + } + catch (Exception e) { + Log.e("FileDownloader", "exception: " + e.getMessage()); + e.printStackTrace(); + onFinish(FAILURE); + } + } +} diff --git a/src/tdm/romkeeper/ManifestCheckerService.java b/src/tdm/romkeeper/ManifestCheckerService.java new file mode 100644 index 0000000..c84ba24 --- /dev/null +++ b/src/tdm/romkeeper/ManifestCheckerService.java @@ -0,0 +1,190 @@ +package tdm.romkeeper; + +import android.app.AlarmManager; +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; + +import android.content.Context; +import android.content.Intent; + +import android.os.Binder; +import android.os.Handler; +import android.os.IBinder; +import android.os.Message; + +import android.util.Log; + +import android.widget.Toast; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; + +import java.security.MessageDigest; + +import java.util.Arrays; + +interface ManifestCheckerCallback +{ + void onManifestCheckComplete(boolean isUpdated); +} + +interface IManifestChecker +{ + void doCheck(ManifestCheckerCallback cb); +} + +public class ManifestCheckerService extends Service +{ + private File mCacheFile; + private ManifestCheckerCallback mCallback; + private ManifestFetcher mFetcher; + + public class ManifestCheckerBinder extends Binder + implements IManifestChecker + { + private ManifestCheckerService mService; + + ManifestCheckerBinder(ManifestCheckerService svc) { + mService = svc; + } + ManifestCheckerService getService() { + return ManifestCheckerService.this; + } + public void doCheck(ManifestCheckerCallback cb) { + mService.doCheck(cb); + } + } + private final ManifestCheckerBinder mBinder = new ManifestCheckerBinder(this); + + class ManifestFetcherHandler extends Handler + { + private ManifestCheckerService mService; + + ManifestFetcherHandler(ManifestCheckerService service) { + mService = service; + } + + public void handleMessage(Message msg) { + mService.onManifestCheckDone(); + } + } + + + @Override + public void onCreate() { + Log.i("ManifestCheckerService", "onCreate"); + String pathname = getCacheDir() + "/manifest.xml"; + mCacheFile = new File(pathname); + + Intent alarmIntent = new Intent(this, ManifestCheckerService.class); + PendingIntent alarmPending = PendingIntent.getService(this, 0, alarmIntent, 0); + + AlarmManager am = (AlarmManager)getSystemService(ALARM_SERVICE); + am.setInexactRepeating(AlarmManager.RTC, + System.currentTimeMillis(), + AlarmManager.INTERVAL_HOUR, + alarmPending); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + Log.i("ManifestCheckerService", "onStartCommand"); + + startCheck(); + + // If we get killed, after returning from here, restart + return START_STICKY; + } + + @Override + public IBinder onBind(Intent intent) { + return mBinder; + } + + @Override + public void onDestroy() { + Log.i("ManifestCheckerService", "onDestroy"); + } + + private void startCheck() { + if (mFetcher == null) { + Handler handler = new ManifestFetcherHandler(this); + mFetcher = new ManifestFetcher(this, handler); + mFetcher.start(); + } + } + + void doCheck(ManifestCheckerCallback cb) { + mCallback = cb; + startCheck(); + } + + boolean isManifestUpdated() { + if (!mCacheFile.exists()) { + Log.i("ManifestCheckerService", "updated: cache file does not exist"); + return true; + } + + try { + MessageDigest dataDigester = MessageDigest.getInstance("MD5"); + dataDigester.update(mFetcher.getData(), 0, mFetcher.getDataLen()); + byte[] dataDigest = dataDigester.digest(); + + MessageDigest fileDigester = MessageDigest.getInstance("MD5"); + FileInputStream is = new FileInputStream(mCacheFile); + byte[] buf = new byte[4096]; + int len; + while ((len = is.read(buf)) > 0) { + fileDigester.update(buf, 0, len); + } + is.close(); + byte[] fileDigest = fileDigester.digest(); + + if (Arrays.equals(dataDigest, fileDigest)) { + return false; + } + Log.i("ManifestCheckerService", "updated: digests differ"); + } + catch (Exception e) { + Log.e("ManifestFetcher", "caught exception: " + e.getMessage()); + e.printStackTrace(); + } + + return true; + } + + void writeManifest() { + try { + FileOutputStream os = new FileOutputStream(mCacheFile); + os.write(mFetcher.getData(), 0, mFetcher.getDataLen()); + os.close(); + } + catch (Exception e) { + Log.e("ManifestFetcher", "caught exception: " + e.getMessage()); + e.printStackTrace(); + } + } + + void onManifestCheckDone() { + boolean isUpdated = isManifestUpdated(); + if (isUpdated) { + writeManifest(); + + NotificationManager nm = (NotificationManager)getSystemService(NOTIFICATION_SERVICE); + Notification n = new Notification(R.drawable.icon, "Rom List Updated", System.currentTimeMillis()); + n.flags |= Notification.FLAG_AUTO_CANCEL; + PendingIntent i = PendingIntent.getActivity(this, 0, + new Intent(this, RomKeeperActivity.class), 0); + n.setLatestEventInfo(this, "RomKeeper", "Rom List Updated", i); + nm.notify(1, n); + } + if (mCallback != null) { + mCallback.onManifestCheckComplete(isUpdated); + mCallback = null; + } + mFetcher = null; + } +} diff --git a/src/tdm/romkeeper/ManifestFetcher.java b/src/tdm/romkeeper/ManifestFetcher.java new file mode 100644 index 0000000..fb1c6a2 --- /dev/null +++ b/src/tdm/romkeeper/ManifestFetcher.java @@ -0,0 +1,79 @@ +package tdm.romkeeper; + +import android.content.Context; +import android.content.SharedPreferences; + +import android.os.Handler; +import android.os.Message; +import android.os.Process; + +import android.util.Log; + +import java.io.File; +import java.io.InputStream; +import java.io.FileOutputStream; +import java.io.IOException; + +import org.apache.http.HttpResponse; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.DefaultHttpClient; + +class ManifestFetcher extends Thread +{ + static final String mDefaultManifestBaseUrl = "http://vmroms.com/~vmstorage/romkeeper"; + + String mManifestBaseUrl; + + Context mContext; + Handler mHandler; + byte[] mData; + int mDataLen; + + ManifestFetcher(Context ctx, Handler handler) { + mContext = ctx; + mHandler = handler; + + SharedPreferences sp = ctx.getSharedPreferences("prefs", Context.MODE_PRIVATE); + mManifestBaseUrl = sp.getString("manifesturl", mDefaultManifestBaseUrl); + } + + byte[] getData() { return mData; } + int getDataLen() { return mDataLen; } + + public void run() { + try { + Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); + + String manifestUrl = mManifestBaseUrl; + manifestUrl += "/" + android.os.Build.MODEL; + manifestUrl += "/manifest.xml"; + Log.i("ManifestFetcher", "manifestUrl=" + manifestUrl); + HttpClient client = new DefaultHttpClient(); + HttpGet request = new HttpGet(manifestUrl); + HttpResponse response = client.execute(request); + InputStream is = response.getEntity().getContent(); + mData = new byte[64*1024]; + mDataLen = 0; + int len = 0; + do { + len = is.read(mData, mDataLen, mData.length - mDataLen); + if (len > 0) { + mDataLen += len; + if (mDataLen == mData.length) { + throw new Exception("Manifest too large"); + } + } + } + while (len > 0); + is.close(); + Log.i("ManifestFetcher", "success"); + } + catch (Exception e) { + Log.e("ManifestFetcher", "caught exception: " + e.getMessage()); + e.printStackTrace(); + } + Message msg = Message.obtain(mHandler, 0, this); + mHandler.sendMessage(msg); + } +} diff --git a/src/tdm/romkeeper/ManifestReader.java b/src/tdm/romkeeper/ManifestReader.java new file mode 100644 index 0000000..dc08a5f --- /dev/null +++ b/src/tdm/romkeeper/ManifestReader.java @@ -0,0 +1,77 @@ +package tdm.romkeeper; + +import java.io.File; +import java.io.InputStream; +import java.io.FileOutputStream; +import java.io.IOException; + +import java.util.ArrayList; +import java.util.List; + +import android.util.Log; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +// For testing +import org.xml.sax.InputSource; +import java.io.StringReader; + +class ManifestReader +{ + ManifestReader() {} + + public ArrayList readManifest(File cacheDir) { + File cacheFile = new File(cacheDir, "manifest.xml"); + ArrayList roms = new ArrayList(); + try { + DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); + DocumentBuilder db = dbf.newDocumentBuilder(); + Document dom = null; + + dom = db.parse(cacheFile); + Log.i("ManifestReader", "preparing to traverse"); + Element root = dom.getDocumentElement(); + Log.i("ManifestReader", "root: " + root.getTagName()); + if (!root.getTagName().equals("romlist")) { + throw new Exception("Document not a romlist"); + } + NodeList nodes = root.getChildNodes(); + for (int i = 0; i < nodes.getLength(); ++i) { + Node node = nodes.item(i); + if (node instanceof Element) { + Element e = (Element)node; + if (e.getTagName().equals("rom")) { + Log.i("ManifestReader", "Found rom"); + String name = e.getAttribute("name"); + Rom r = new Rom(name); + r.setUrl(e.getAttribute("url")); + r.setFilename(e.getAttribute("filename")); + r.setSize(e.getAttribute("size")); + r.setDigest(e.getAttribute("digest")); + r.setBasis(e.getAttribute("basis")); + r.setDeltaUrl(e.getAttribute("delta-url")); + r.setDeltaFilename(e.getAttribute("delta-filename")); + r.setDeltaSize(e.getAttribute("delta-size")); + roms.add(r); + } + } + } + Log.i("ManifestReader", "success: " + roms.size() + " roms"); + } + catch (Exception e) { + Log.e("ManifestReader", "readManifest caught exception: " + e.getMessage()); + e.printStackTrace(); + } + + return roms; + } +} diff --git a/src/tdm/romkeeper/Rom.java b/src/tdm/romkeeper/Rom.java new file mode 100644 index 0000000..47b7f9f --- /dev/null +++ b/src/tdm/romkeeper/Rom.java @@ -0,0 +1,112 @@ +package tdm.romkeeper; + +import android.content.Context; + +import android.os.PowerManager; + +import java.io.BufferedReader; +import java.io.File; +import java.io.InputStreamReader; + +import java.util.HashMap; +import java.util.Map; + +import android.util.Log; + +class Rom +{ + private String mName; + private String mUrl; + private String mFilename; + private long mSize; + private String mDigest; + private String mBasis; + private String mDeltaUrl; + private String mDeltaFilename; + private long mDeltaSize; + private boolean mVerified; + private long mModTime; + + private int toInt(String s) { + int i = 0; + try { + i = Integer.parseInt(s); + } + catch (Exception e) { + // Ignore it + } + return i; + } + + Rom(String name) { + mName = name; + mSize = 0; + mVerified = false; + mModTime = 0; + } + + void setUrl(String val) { mUrl = val; } + void setFilename(String val) { mFilename = val; } + void setSize(long val) { mSize = val; } + void setDigest(String val) { mDigest = val; } + void setBasis(String val) { mBasis = val; } + void setDeltaUrl(String val) { mDeltaUrl = val; } + void setDeltaFilename(String val) { mDeltaFilename = val; } + void setDeltaSize(long val) { mDeltaSize = val; } + void setVerified(boolean val, long mtime) { mVerified = val; mModTime = mtime; } + + void setSize(String val) { setSize(toInt(val)); } + void setDeltaSize(String val) { setDeltaSize(toInt(val)); } + + String getName() { return mName; } + String getUrl() { return mUrl; } + String getFilename() { return mFilename; } + long getSize() { return mSize; } + String getDigest() { return mDigest; } + String getBasis() { return mBasis; } + String getDeltaUrl() { return mDeltaUrl; } + String getDeltaFilename() { return mDeltaFilename; } + long getDeltaSize() { return mDeltaSize; } + boolean getVerified() { return mVerified; } + long getModTime() { return mModTime; } + + boolean exists() { + File f = new File("/sdcard/" + mFilename); + return f.exists(); + } + + public String toString() { + return mName; + } + + void install(Context ctx) { + if (!mVerified) { + Log.e("RomKeeper", "not verified"); + return; + } + + String cmd = "mkdir -p /cache/recovery " + + "&& echo '--update_package=" + + "/sdcard/" + mFilename + "' > /cache/recovery/command " + + "&& reboot recovery"; + String[] argv = { "su", "-c", cmd }; + String res; + BufferedReader br = null; + try { + Process proc = Runtime.getRuntime().exec(argv); + br = new BufferedReader(new InputStreamReader(proc.getInputStream())); + res = br.readLine(); + + PowerManager pm = (PowerManager)ctx.getSystemService(Context.POWER_SERVICE); + pm.reboot("recovery"); + } + catch (Exception e) { + Log.e("Rom", "install failed: " + e.getMessage()); + e.printStackTrace(); + } + finally { + try { br.close(); } catch (Exception e) {} + br = null; + } + } +} diff --git a/src/tdm/romkeeper/RomDataProvider.java.hide b/src/tdm/romkeeper/RomDataProvider.java.hide new file mode 100644 index 0000000..43ca87f --- /dev/null +++ b/src/tdm/romkeeper/RomDataProvider.java.hide @@ -0,0 +1,210 @@ +package tdm.romkeeper; + +import android.content.ContentValues; +import android.content.Context; + +import android.database.Cursor; +import android.database.SQLException; +import android.database.sqlite.SQLiteOpenHelper; +import android.database.sqlite.SQLiteDatabase; + +import java.io.File; + +public class RomDataProvider +{ + private static final string DB_NAME = "roms.db"; + private static final int DB_VERSION = 1; + + private DbHelper mHelper; + private SQLiteDatabase mDb; + + private static class DbHelper extends SQLiteOpenHelper + { + DbHelper(Context ctx) { + super(ctx, DB_NAME, null, DB_VERSION); + } + + @Override + public void onCreate(SQLiteDatabase db) { + db.execSQL( + "CREATE TABLE romlist (" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "name VARCHAR(64) NOT NULL UNIQUE, " + + "url VARCHAR(256) NOT NULL UNIQUE, " + + "filename VARCHAR(64) NOT NULL UNIQUE, " + + "size INTEGER, " + + "digest CHAR(32), " + + "basis VARCHAR(64), " + + "delta VARCHAR(256), " + + "mtime INTEGER, " + + "verified INTEGER" + + ")"); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + db.execSQL("DROP TABLE IF EXISTS romlist"); + onCreate(db); + } + } + + DbAdapter(Context ctx) { + mHelper = new DbHelper(ctx); + } + + @Override + public boolean onCreate() { + mDb = mHelper.openDatabase(getContext(), DB_NAME, null, DB_VERSION); + return (mDb != null); + } + + public Cursor query(Uri uri, String[] projection, + String selection, String[] selectionArgs, String sort) { + SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); + qb.setTables("romlist"); + switch (URL_MATCHER.match(url)) { + case NAME: + qb.setProjectionMap(NAMES_LIST_PROJECTION_MAP); + break; + case NAME_ID: + qb.appendWhere("_id=" + uri.getPathSegments().get(1)); + break; + default: + throw new IllegalArgumentException("Unknown URI " + uri); + } + + String orderBy; + if (TextUtils.isEmpty(sort)) { + orderBy = "_id DESC"; + } + else { + orderBy = sort; + } + + Cursor c = qb.query(mDb, projection, selection, selectionArgs, null, null, orderBy); + c.setNotificationUri(getContext().getContentResolver(), uri); + return c; + } + + @Override + public String getType(Uri uri) { + switch (URL_MATCHER.match(uri)) { + case NAME: + return "vnd.android.cursor.dir/tdm.romkeeper.string"; + case NAME_ID: + return "vnd.android.cursor.item/tdm.romkeeper.string"; + default: + throw new IllegalArgumentException("Unknown URI " + uri); + } + } + + @Override + public Uri insert(Uri uri, ContentValues initialValues) { + long rowid; + ContentValues values; + if (initialValues != null) { + values = new ContentValues(initialValues); + } + else { + values = new ContentValues(); + } + + if (URI_MATCHER.match(uri) != NAMES) { + throw new IllegalArgumentException("Unknown URI " + uri); + } + + Resources r = Resources.getSystem(); + + if (!values.containsKey(roms)) { + values.put("roms", ""); + } + values.put(SimpleString.Strings._SYNC_VERSION, + Long.toString(System.currentTimeMillis())); + + rowid = mDb.insert("roms", "romlist", values); + if (rowid > 0) { + Uri uri = Uri.withAppendedPath(SimpleString.Strings.CONTENT_URI, + Long.toString(rowid)); + getContext().getContentResolver().notifyChange(uri, null); + } + } + + @Override + public int delete(Uri uri, String where, String[] whereArgs) { + int count; + int rowid = 0; + switch (URI_MATCHER.match(uri)) { + case NAMES: + count = mDb.delete("roms", where, whereArgs); + break; + case NAME_ID: + String segment = uri.getPathSegments().get(1); + rowid = Long.parseLong(segment); + count = mDb.delete("roms", "_id=" + + segment + + (!TextUtils.isEmpty(where) ? " AND (" + where + + ')' : ""), whereArgs); + break; + default: + throw new IllegalArgumentException("Unknown URI " + uri); + } + } + + @Override + public int update(Uri uri, ContentValues values, String where, String[] whereArgs) { + int count; + values.put(SimpleString.Strings._SYNC_VERSION, + Long.toString(System.currentTimeMillis())); + switch (URL_MATCHER.match(uri)) { + case NAMES: + count = mDb.update("roms", values, where, whereArgs); + break; + case NAME_ID: + String segment = uri.getPathSegments().get(1); + count = mDb.update("roms", values, "_id=" + + segment + + (!TextUtils.isEmpty(where) ? " AND (" + where + + ')' : ""), whereArgs); + break; + default: + throw new IllegalArgumentException("Unknown URI " + uri); + } + getContext().getContentResolver().notifyChange(url, null); + return count; + } + + /* Rom extensions */ + + Rom get(String name) { + Rom r = null; + Cursor c = mDb.query("romlist", + new String[] { "url", "filename", "size", "digest", "basis", "delta" }, + "name="+name, null, null, null, null); + if (c != null) { + c.moveToFirst(); + int idx = 0; + r = new Rom(name); + r.setUrl(c.getString(idx++)); + r.setFilename(c.getString(idx++)); + r.setSize(c.getInt(idx++)); + r.setDigest(c.getString(idx++)); + r.setBasis(c.getString(idx++)); + r.setDelta(c.getString(idx++)); + } + return r; + } + + void setVerified(String name, boolean val) { + File f = new File(name); + ContentValues values = new ContentValues(); + if (val) { + values.put("mtime", f.lastModified()); + values.put("verified", 1); + } + else { + values.put("mtime", 0); + values.put("verified", 0); + } + mDb.update("romlist", values, "name="+name, null); + } +} diff --git a/src/tdm/romkeeper/RomKeeperActivity.java b/src/tdm/romkeeper/RomKeeperActivity.java new file mode 100644 index 0000000..54936d1 --- /dev/null +++ b/src/tdm/romkeeper/RomKeeperActivity.java @@ -0,0 +1,316 @@ +package tdm.romkeeper; + +import android.app.Activity; +import android.app.AlarmManager; +import android.app.ListActivity; +import android.app.PendingIntent; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; + +import android.database.sqlite.SQLiteDatabase; +import android.database.Cursor; + +import android.net.Uri; + +import android.os.Bundle; +import android.os.Environment; +import android.os.Handler; +import android.os.IBinder; +import android.os.Message; +import android.os.Messenger; +import android.os.Parcel; +import android.os.Parcelable; + +import android.util.Log; + +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MenuItem; +import android.view.MenuInflater; +import android.view.View; + +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemClickListener; +import android.widget.ArrayAdapter; +import android.widget.ListAdapter; +import android.widget.ListView; +import android.widget.SimpleCursorAdapter; +import android.widget.TextView; +import android.widget.Toast; + +import android.widget.RelativeLayout; + +import java.util.ArrayList; + +import android.content.BroadcastReceiver; + +public class RomKeeperActivity extends Activity + implements AdapterView.OnItemClickListener, + ManifestCheckerCallback, + DbObserver +{ + static final String TAG = "RomKeeper"; + + Rom mCurrentRom; + private boolean mShowingDetail; + + private ManifestCheckerService mManifestCheckerService; + private ServiceConnection mManifestCheckerConnection = new ServiceConnection() { + public void onServiceConnected(ComponentName className, IBinder service) { + mManifestCheckerService = ((ManifestCheckerService.ManifestCheckerBinder)service).getService(); + } + public void onServiceDisconnected(ComponentName className) { + mManifestCheckerService = null; + } + }; + + private ListView mRomListView; + private ArrayAdapter mRomListAdapter; + + private ListView mRomDetailView; + private ArrayAdapter mRomDetailAdapter; + + private DbAdapter mDb; + + /** Called when the activity is first created. */ + @Override + public void onCreate(Bundle state) { + super.onCreate(state); + + bindService(new Intent(RomKeeperActivity.this, ManifestCheckerService.class), + mManifestCheckerConnection, Context.BIND_AUTO_CREATE); + + /* + * This does not work, returns NULL. WTF?!? + * mRomListView = (ListView)findViewById(R.id.romlist_view); + */ + mRomListView = new ListView(this); + mRomListAdapter = new ArrayAdapter(this, + R.layout.list_item, new ArrayList()); + mRomListView.setAdapter(mRomListAdapter); + mRomListView.setOnItemClickListener(this); + + mRomDetailView = new ListView(this); + mRomDetailAdapter = new ArrayAdapter(this, + R.layout.list_item, new ArrayList()); + mRomDetailView.setAdapter(mRomDetailAdapter); + + /* XXX: should not do this in the UI thread */ + mDb = DbAdapter.getInstance(this); + + Cursor c = mDb.getRomCursor(); + if (c.moveToFirst()) { + while (!c.isAfterLast()) { + String name = c.getString(1); + Log.i("RomKeeperActivity", "onCreate: found rom " + name); + Rom r = mDb.get(name); + mRomListAdapter.add(r); + c.moveToNext(); + } + } + c.close(); + + mDb.registerObserver(this); + + showRomList(); + } + + protected void onResume() { + Log.e(TAG, "onResume"); + super.onResume(); + } + + protected void onSaveInstanceState(Bundle state) { + Log.i(TAG, "onSaveInstanceState"); + } + + protected void onPause() { + Log.e(TAG, "onPause"); + super.onPause(); + // ...? + } + + protected void onStop() { + Log.e(TAG, "onStop"); + super.onStop(); + } + + protected void onDestroy() { + unbindService(mManifestCheckerConnection); + + super.onDestroy(); + } + + void showRomList() { + mShowingDetail = false; + mCurrentRom = null; + + setContentView(mRomListView); + } + + void showRomDetail(Rom r) { + mShowingDetail = true; + mCurrentRom = r; + + refreshRomDetail(); + setContentView(mRomDetailView); + } + + public boolean onCreateOptionsMenu(Menu menu) { + Log.i(TAG, "onCreateOptionsMenu"); + return super.onCreateOptionsMenu(menu); + } + + public boolean onPrepareOptionsMenu(Menu menu) { + Log.i(TAG, "onPrepareOptionsMenu"); + menu.clear(); + MenuInflater inflater = getMenuInflater(); + if (mShowingDetail) { + inflater.inflate(R.menu.romdetail_options_menu, menu); + } + else { + inflater.inflate(R.menu.romlist_options_menu, menu); + } + return super.onPrepareOptionsMenu(menu); + } + + private void doCheckManifest() { + mManifestCheckerService.doCheck(this); + } + + public void onManifestCheckComplete(boolean isUpdated) { + Log.i("RomKeeperActivity", "onManifestCheckComplete"); + if (isUpdated) { + ManifestReader reader = new ManifestReader(); + ArrayList newList = reader.readManifest(getCacheDir()); + + /* XXX: should not do this in the UI thread */ + mDb.deleteAll(); + mRomListAdapter.clear(); + mRomListAdapter.notifyDataSetChanged(); + for (Rom r : newList) { + mDb.insert(r); + } + } + } + + private void refreshRomDetail() { + mRomDetailAdapter.clear(); + Rom r = mCurrentRom; + + String status = "not present"; + if (r.exists()) { + status = "present"; + } + if (r.getVerified()) { + status = "verified"; + } + + mRomDetailAdapter.add("name: " + r.getName()); + mRomDetailAdapter.add("filename: " + r.getFilename()); + mRomDetailAdapter.add("size: " + r.getSize()); + mRomDetailAdapter.add("digest: " + r.getDigest()); + mRomDetailAdapter.add("status: " + status); + mRomDetailAdapter.notifyDataSetChanged(); + } + + private void fetchRom() { + if (mCurrentRom == null) { + Log.e(TAG, "fetchRom: no current rom"); + return; + } + + Intent i = new Intent(this, FileDownloadService.class); + i.setData(Uri.parse(mCurrentRom.getUrl())); + i.putExtra(FileDownloadService.EXTRA_NAME, mCurrentRom.getName()); + i.putExtra(FileDownloadService.EXTRA_FILENAME, mCurrentRom.getFilename()); + i.putExtra(FileDownloadService.EXTRA_SIZE, mCurrentRom.getSize()); + i.putExtra(FileDownloadService.EXTRA_DIGEST, mCurrentRom.getDigest()); + i.putExtra(FileDownloadService.EXTRA_BASIS, mCurrentRom.getBasis()); + i.putExtra(FileDownloadService.EXTRA_DELTAURL, mCurrentRom.getDeltaUrl()); + i.putExtra(FileDownloadService.EXTRA_DELTAFILENAME, mCurrentRom.getDeltaFilename()); + i.putExtra(FileDownloadService.EXTRA_DELTASIZE, mCurrentRom.getDeltaSize()); + startService(i); + } + + void onFetchRomUpdate() { + refreshRomDetail(); //XXX: inefficient + } + + void onFetchRomDone() { + Toast.makeText(this, "Fetch complete", Toast.LENGTH_SHORT).show(); + } + + private void installRom() { + if (mCurrentRom == null) { + Log.e(TAG, "installRom: no current rom"); + return; + } + mCurrentRom.install(this); + } + + private void showSettings() { + startActivity(new Intent(this, RomKeeperPreferenceActivity.class)); + } + + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.menu_refresh: + doCheckManifest(); + break; + case R.id.menu_fetch: + fetchRom(); + break; + case R.id.menu_install: + installRom(); + break; + case R.id.menu_settings: + showSettings(); + break; + } + return super.onOptionsItemSelected(item); + } + + public void onItemClick(AdapterView parent, View view, int position, long id) { + Log.i(TAG, "onItemClick: pos=" + position + ", id=" + id); + if (mShowingDetail) { + // XXX: do anything useful here? + return; + } + Rom r = mRomListAdapter.getItem(position); + showRomDetail(r); + } + + public void onBackPressed() { + Log.i("RomKeeperActivity", "onBackPressed"); + if (mShowingDetail) { + Log.i("RomKeeperActivity", ".. showing rom list"); + showRomList(); + return; + } + Log.i("RomKeeperActivity", ".. calling super"); + super.onBackPressed(); + } + + public void onContentInsert(String name) { + Rom r = mDb.get(name); + mRomListAdapter.add(r); + mRomListAdapter.notifyDataSetChanged(); + } + public void onContentUpdate(String name) { + if (mShowingDetail && mCurrentRom != null && mCurrentRom.getName().equals(name)) { + mCurrentRom = mDb.get(name); + refreshRomDetail(); + } + } + public void onContentDelete(String name) { + Rom r = mDb.get(name); + mRomListAdapter.remove(r); + if (mShowingDetail && mCurrentRom != null && mCurrentRom.getName().equals(name)) { + showRomList(); + } + } +} diff --git a/src/tdm/romkeeper/RomKeeperPreferenceActivity.java b/src/tdm/romkeeper/RomKeeperPreferenceActivity.java new file mode 100644 index 0000000..58e4ff7 --- /dev/null +++ b/src/tdm/romkeeper/RomKeeperPreferenceActivity.java @@ -0,0 +1,14 @@ +package tdm.romkeeper; + +import android.preference.PreferenceActivity; + +import android.os.Bundle; + +public class RomKeeperPreferenceActivity extends PreferenceActivity +{ + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + addPreferencesFromResource(R.xml.preferences); + } +} diff --git a/src/tdm/romkeeper/StartupReceiver.java b/src/tdm/romkeeper/StartupReceiver.java new file mode 100644 index 0000000..b904e60 --- /dev/null +++ b/src/tdm/romkeeper/StartupReceiver.java @@ -0,0 +1,25 @@ +package tdm.romkeeper; + +import android.app.AlarmManager; +import android.app.PendingIntent; + +import android.util.Log; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; + +public class StartupReceiver extends BroadcastReceiver +{ + @Override + public void onReceive(Context ctx, Intent intent) { + Log.i("StartupReceiver", "onReceive called"); + SharedPreferences sp = ctx.getSharedPreferences("prefs", Context.MODE_PRIVATE); + boolean autocheck = sp.getBoolean("autocheck", false); + if (autocheck) { + Intent i = new Intent(ctx, ManifestCheckerService.class); + ctx.startService(i); + } + } +} diff --git a/src/tdm/romkeeper/StreamListener.java b/src/tdm/romkeeper/StreamListener.java new file mode 100644 index 0000000..6d006db --- /dev/null +++ b/src/tdm/romkeeper/StreamListener.java @@ -0,0 +1,7 @@ +package tdm.romkeeper; + +interface StreamListener +{ + void updateBytesRead(int len); + void updateBytesWritten(int len); +}