├── notes ├── 0.1.1.markdown ├── 0.1.3.markdown ├── 0.1.4.markdown ├── 0.1.2.markdown ├── 0.1.markdown └── about.markdown ├── src ├── main │ ├── original-assets │ │ ├── octocat.xcf │ │ ├── send-flow.xcf │ │ ├── gist-it-logo.png │ │ ├── gist-it-logo.xcf │ │ └── gist-it-logo_128.png │ ├── res │ │ ├── drawable │ │ │ ├── octocat_big.png │ │ │ ├── private_gist.png │ │ │ ├── octocat_bg.xml │ │ │ └── drop_shadow_down.xml │ │ ├── drawable-hdpi │ │ │ └── gist_it_logo.png │ │ ├── drawable-mdpi │ │ │ └── gist_it_logo.png │ │ ├── layout │ │ │ ├── login.xml │ │ │ ├── gist_row.xml │ │ │ └── upload_gist.xml │ │ ├── values │ │ │ ├── colors.xml │ │ │ ├── keys.xml │ │ │ ├── acra.xml │ │ │ └── strings.xml │ │ ├── xml │ │ │ └── authenticator.xml │ │ ├── menu │ │ │ └── menu.xml │ │ └── layout-land │ │ │ └── gist_row.xml │ ├── scala │ │ └── com │ │ │ └── zegoggles │ │ │ └── gist │ │ │ ├── User.scala │ │ │ ├── Logger.scala │ │ │ ├── JsonModel.scala │ │ │ ├── Implicits.scala │ │ │ ├── AuthenticatorService.scala │ │ │ ├── App.scala │ │ │ ├── Gist.scala │ │ │ ├── Utils.scala │ │ │ ├── LoggingWebViewClient.scala │ │ │ ├── Login.scala │ │ │ ├── GistList.scala │ │ │ ├── Api.scala │ │ │ └── UploadGist.scala │ └── AndroidManifest.xml └── test │ └── scala │ └── com │ └── zegoggles │ └── gist │ ├── ApiSpec.scala │ ├── UtilsSpec.scala │ ├── UserSpec.scala │ ├── JsonModelSpec.scala │ └── GistSpec.scala ├── tests └── src │ └── main │ ├── res │ └── values │ │ └── strings.xml │ └── AndroidManifest.xml ├── NOTICE ├── .gitignore ├── project ├── plugins.sbt └── build.scala ├── LICENSE └── README.md /notes/0.1.1.markdown: -------------------------------------------------------------------------------- 1 | * Fix for Android 2.1 2 | -------------------------------------------------------------------------------- /notes/0.1.3.markdown: -------------------------------------------------------------------------------- 1 | * Fix for Android 2.1 2 | -------------------------------------------------------------------------------- /notes/0.1.4.markdown: -------------------------------------------------------------------------------- 1 | * Fix for Honeycomb / ICS 2 | -------------------------------------------------------------------------------- /notes/0.1.2.markdown: -------------------------------------------------------------------------------- 1 | * Accidentally released with wrong keys 2 | * Changed app logo (not allowed to use octocat) 3 | -------------------------------------------------------------------------------- /src/main/original-assets/octocat.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jberkel/gist-it/HEAD/src/main/original-assets/octocat.xcf -------------------------------------------------------------------------------- /src/main/original-assets/send-flow.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jberkel/gist-it/HEAD/src/main/original-assets/send-flow.xcf -------------------------------------------------------------------------------- /src/main/res/drawable/octocat_big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jberkel/gist-it/HEAD/src/main/res/drawable/octocat_big.png -------------------------------------------------------------------------------- /src/main/res/drawable/private_gist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jberkel/gist-it/HEAD/src/main/res/drawable/private_gist.png -------------------------------------------------------------------------------- /tests/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | gist-it tests 3 | 4 | -------------------------------------------------------------------------------- /src/main/original-assets/gist-it-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jberkel/gist-it/HEAD/src/main/original-assets/gist-it-logo.png -------------------------------------------------------------------------------- /src/main/original-assets/gist-it-logo.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jberkel/gist-it/HEAD/src/main/original-assets/gist-it-logo.xcf -------------------------------------------------------------------------------- /notes/0.1.markdown: -------------------------------------------------------------------------------- 1 | * Initial release: send files to gist, edit existing gists 2 | 3 | * Load/upload gists from other apps via intents 4 | -------------------------------------------------------------------------------- /src/main/res/drawable-hdpi/gist_it_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jberkel/gist-it/HEAD/src/main/res/drawable-hdpi/gist_it_logo.png -------------------------------------------------------------------------------- /src/main/res/drawable-mdpi/gist_it_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jberkel/gist-it/HEAD/src/main/res/drawable-mdpi/gist_it_logo.png -------------------------------------------------------------------------------- /src/main/original-assets/gist-it-logo_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jberkel/gist-it/HEAD/src/main/original-assets/gist-it-logo_128.png -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | gist-it 2 | Copyright 2011 Jan Berkel 3 | 4 | acra - Application Crash Report for Android 5 | Copyright 2010 Emmanuel Astier & Kevin Gaudin 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | lib_managed 3 | src_managed 4 | project/boot 5 | project/plugins/project 6 | gen 7 | .idea 8 | *.iml 9 | *.swp 10 | tmp 11 | tags 12 | keys_market.xml 13 | -------------------------------------------------------------------------------- /notes/about.markdown: -------------------------------------------------------------------------------- 1 | [gist-it][1] is an open source Android [gist][2] API client written in Scala 2 | ([Android Market link][3]). 3 | 4 | [1]: https://github.com/jberkel/gist-it 5 | [2]: https://gist.github.com/ 6 | [3]: https://market.android.com/details?id=com.zegoggles.gist 7 | -------------------------------------------------------------------------------- /src/main/res/drawable/octocat_bg.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | -------------------------------------------------------------------------------- /src/main/res/layout/login.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | #AAE2E2E2 5 | #00ffffff 6 | #fffeeb 7 | #e9e9e9 8 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | resolvers ++= Seq( 2 | Resolver.file(System.getProperty("user.home") + "/.ivy2/local"), 3 | Resolver.url("scalasbt snapshots", new 4 | URL("http://scalasbt.artifactoryonline.com/scalasbt/sbt-plugin-snapshots"))(Resolver.ivyStylePatterns) 5 | ) 6 | 7 | addSbtPlugin("org.scala-sbt" % "sbt-android-plugin" % "0.6.1-SNAPSHOT") 8 | -------------------------------------------------------------------------------- /src/main/res/values/keys.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | e9a53e8a136ac429a4e9 6 | 69f3a4728277be9e4669e53afe3a2add1e2786e7 7 | http://zegoggl.es/oauth/gist-it 8 | 9 | -------------------------------------------------------------------------------- /src/main/res/xml/authenticator.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | -------------------------------------------------------------------------------- /src/main/res/drawable/drop_shadow_down.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | -------------------------------------------------------------------------------- /src/main/scala/com/zegoggles/gist/User.scala: -------------------------------------------------------------------------------- 1 | package com.zegoggles.gist 2 | 3 | import org.json.JSONObject 4 | 5 | object User extends JsonModel[User] { 6 | def apply(j: JSONObject):Option[User] = { 7 | val root = if (j.has("user")) j.getJSONObject("user") else j 8 | Some(User(root.getInt("id"), 9 | root.getString("name"), 10 | root.getString("login"), 11 | root.getString("email"))) 12 | } 13 | } 14 | case class User(id: Int, name:String, login:String, email:String) 15 | -------------------------------------------------------------------------------- /src/main/scala/com/zegoggles/gist/Logger.scala: -------------------------------------------------------------------------------- 1 | package com.zegoggles.gist 2 | 3 | import android.util.Log 4 | 5 | trait Logger { 6 | def log(msg: String) { Log.d(App.TAG, msg) } 7 | def warn(msg: String, e:Exception*) { 8 | if (e.isEmpty) 9 | Log.w(App.TAG, msg) 10 | else 11 | Log.w(App.TAG, msg, e.headOption.get) 12 | } 13 | } 14 | 15 | trait StdoutLogger extends Logger { 16 | override def log(msg: String) { 17 | System.err.println(msg) 18 | } 19 | 20 | override def warn(msg: String, e:Exception*) { 21 | System.err.println(msg) 22 | } 23 | } -------------------------------------------------------------------------------- /src/main/res/menu/menu.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 11 | 12 | 15 | 16 | -------------------------------------------------------------------------------- /src/main/res/layout/gist_row.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /src/main/res/layout-land/gist_row.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /tests/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/main/res/values/acra.xml: -------------------------------------------------------------------------------- 1 | 2 | Epic fail 3 | gist-it has crashed 4 | Please click here to help fix the issue. 5 | 6 | 7 | An unexpected error occurred forcing the application to stop. 8 | Please help us fix this by sending us a crash report. 9 | 10 | 11 | 12 | gist-it has crashed 13 | 14 | Comment (optional): 15 | 16 | Cheers! 17 | 18 | -------------------------------------------------------------------------------- /src/test/scala/com/zegoggles/gist/ApiSpec.scala: -------------------------------------------------------------------------------- 1 | package com.zegoggles.gist 2 | 3 | import org.specs2.mutable._ 4 | class ApiSpec extends Specification { 5 | "the API" should { 6 | 7 | "parse the token response" in { 8 | val token = Api.parseTokenResponse("access_token=807e750b891b3fc47b0c951b4c11c0b610195b73&token_type=bearer") 9 | token must beSome 10 | token.get.access must be equalTo "807e750b891b3fc47b0c951b4c11c0b610195b73" 11 | } 12 | 13 | "return none if it cannot be parsed" in { 14 | Api.parseTokenResponse("foo=bar&token_type=bearer") must beNone 15 | Api.parseTokenResponse("") must beNone 16 | } 17 | 18 | "generate JSON from a Scala Map structure" in { 19 | val input = Map("foo"->"bar", "bool"->true, "int"->10, "baz"->Map("buu"->"hello"),"double"->10.44d) 20 | 21 | """{"baz":{"buu":"hello"},"int":10,"foo":"bar","bool":true,"double":10.44}""" must 22 | be equalTo Api.map2Json(input).toString 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/test/scala/com/zegoggles/gist/UtilsSpec.scala: -------------------------------------------------------------------------------- 1 | package com.zegoggles.gist 2 | 3 | import org.specs2.mutable.Specification 4 | 5 | class UtilsSpec extends Specification { 6 | "human readable size" should { 7 | "return human readable values" in { 8 | "323 bytes" must be equalTo Utils.readableSize(323) 9 | "2 kb" must be equalTo Utils.readableSize(2048) 10 | "1 mb" must be equalTo Utils.readableSize(1024 * 1024 + 20) 11 | "3 gb" must be equalTo Utils.readableSize(1024 * 1024 * 1024 * 3L) 12 | } 13 | } 14 | 15 | "human readable time" should { 16 | "return human readable values" in { 17 | "just now" must be equalTo Utils.readableTime(5) 18 | "50 seconds ago" must be equalTo Utils.readableTime(50) 19 | "3 minutes ago" must be equalTo Utils.readableTime(190) 20 | "1 hour ago" must be equalTo Utils.readableTime(3800) 21 | "4 days ago" must be equalTo Utils.readableTime(86400 * 4 + 3000) 22 | "2 months ago" must be equalTo Utils.readableTime(86400 * 62) 23 | "1 year ago" must be equalTo Utils.readableTime(86400 * 30 * 12) 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2011 Jan Berkel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | 'Software'), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /src/main/scala/com/zegoggles/gist/JsonModel.scala: -------------------------------------------------------------------------------- 1 | package com.zegoggles.gist 2 | 3 | import scala.Some 4 | import org.json.{JSONArray, JSONObject, JSONException} 5 | import java.text.SimpleDateFormat 6 | import java.util.TimeZone 7 | 8 | trait JsonModel[T] { 9 | lazy val iso8601 = { 10 | val format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'") 11 | format.setTimeZone(TimeZone.getTimeZone("UTC")) 12 | format 13 | } 14 | def getDate(s: String ) = iso8601.parse(s) 15 | 16 | def apply(j: JSONObject): Option[T] 17 | def apply(s: String):Option[T] = try { 18 | apply(new JSONObject(s)) 19 | } catch { 20 | case e:JSONException => None 21 | } 22 | 23 | def list(s: String) = JSONArrayWrapper(s, apply(_:JSONObject)) 24 | def list(l: JSONArray) = JSONArrayWrapper(l, apply(_:JSONObject)) 25 | } 26 | 27 | object JSONArrayWrapper { 28 | def apply[T](l: JSONArray, transform: JSONObject => Option[T]):Option[JSONArrayWrapper[T]] = 29 | Some(new JSONArrayWrapper[T](l, transform)) 30 | 31 | def apply[T](s: String, transform: JSONObject => Option[T]):Option[JSONArrayWrapper[T]] = 32 | try { 33 | apply(new JSONArray(s), transform) 34 | } catch { 35 | case e:JSONException => None 36 | } 37 | } 38 | 39 | class JSONArrayWrapper[+T](val list: JSONArray, val transform: JSONObject => Option[T]) extends IndexedSeq[T] { 40 | def apply(idx: Int) = transform(list.getJSONObject(idx)).get 41 | def length = list.length() 42 | } 43 | -------------------------------------------------------------------------------- /src/test/scala/com/zegoggles/gist/UserSpec.scala: -------------------------------------------------------------------------------- 1 | package com.zegoggles.gist 2 | 3 | import org.specs2.mutable._ 4 | 5 | class UserSpec extends Specification { 6 | "A user" should { 7 | val user = """ 8 | { 9 | "user": { 10 | "gravatar_id": "b8dbb1987e8e5318584865f880036796", 11 | "company": "GitHub", 12 | "name": "Chris Wanstrath", 13 | "created_at": "2007/10/19 22:24:19 -0700", 14 | "location": "San Francisco, CA", 15 | "public_repo_count": 98, 16 | "public_gist_count": 270, 17 | "blog": "http://chriswanstrath.com/", 18 | "following_count": 196, 19 | "id": 2, 20 | "type": "User", 21 | "permission": null, 22 | "followers_count": 1692, 23 | "login": "defunkt", 24 | "email": "chris@wanstrath.com" 25 | } 26 | } 27 | """ 28 | 29 | "be parseable from JSON" in { 30 | val u = User(user).get 31 | u.name must be equalTo "Chris Wanstrath" 32 | u.id must be equalTo 2 33 | u.login must be equalTo "defunkt" 34 | u.email must be equalTo "chris@wanstrath.com" 35 | } 36 | 37 | "return None if not parseable" in { 38 | User("bla") must beNone 39 | } 40 | 41 | "be pattern matchable" in { 42 | User(user) match { 43 | case Some(User(2, _, login, _)) => login must be equalTo "defunkt" 44 | case _ => error("default case") 45 | } 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /src/main/scala/com/zegoggles/gist/Implicits.scala: -------------------------------------------------------------------------------- 1 | package com.zegoggles.gist 2 | 3 | import org.apache.http.HttpEntity 4 | import io.Source 5 | import android.view.View.OnClickListener 6 | import android.view.{KeyEvent, View} 7 | import android.widget.TextView.OnEditorActionListener 8 | import org.json.JSONObject 9 | import android.content.DialogInterface 10 | import android.widget.{CheckBox, TextView} 11 | 12 | object Implicits { 13 | implicit def funToRunnable(f: => Unit) = new Runnable { def run() { f }} 14 | implicit def funToRunnable2(f: () => Unit) = new Runnable { def run() { f() }} 15 | 16 | implicit def textViewToString(tv: TextView):String = tv.getText.toString 17 | implicit def mapToJSON(map: Map[String,Any]):JSONObject = Api.map2Json(map) 18 | implicit def jsonToString(json: JSONObject):String = json.toString 19 | 20 | implicit def funToDialogOnClickListener[F](f: (DialogInterface, Int) => F) 21 | = new DialogInterface.OnClickListener { 22 | def onClick(dialog: DialogInterface, which: Int) { 23 | f(dialog, which) 24 | } 25 | } 26 | 27 | implicit def funToDialogOnClickListener[F](f: () => F) 28 | = new DialogInterface.OnClickListener { 29 | def onClick(dialog: DialogInterface, which: Int) { 30 | f() 31 | } 32 | } 33 | 34 | implicit def funToOnClickListener[F](f: View => F) = new OnClickListener { 35 | def onClick(v: View) { f(v) } 36 | } 37 | 38 | implicit def funToOnEditorActionListener(f: (View,Int,KeyEvent) => Boolean) = new OnEditorActionListener { 39 | def onEditorAction(v: TextView, actionId: Int, event: KeyEvent) = { f(v,actionId,event) } 40 | } 41 | 42 | implicit def entityToString(e: HttpEntity): String = 43 | Source.fromInputStream(e.getContent).getLines().mkString 44 | } -------------------------------------------------------------------------------- /src/main/scala/com/zegoggles/gist/AuthenticatorService.scala: -------------------------------------------------------------------------------- 1 | package com.zegoggles.gist 2 | 3 | import android.accounts._ 4 | import android.app.Service 5 | import android.os.Bundle 6 | import java.lang.String 7 | import android.content.{Context, Intent} 8 | 9 | class AuthenticatorService extends Service { 10 | lazy val authenticator = new GithubAuthenticator(this) 11 | 12 | def onBind(intent: Intent) = { 13 | if (intent.getAction == AccountManager.ACTION_AUTHENTICATOR_INTENT) { 14 | authenticator.getIBinder 15 | } else { 16 | null 17 | } 18 | } 19 | 20 | class GithubAuthenticator(val context: Context) extends AbstractAccountAuthenticator(context) { 21 | def addAccount(resp: AccountAuthenticatorResponse, acctype: String, tokenType: String, features: Array[String], options: Bundle) = { 22 | val reply = new Bundle() 23 | val intent = (new Intent(context, classOf[Login])) 24 | .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) 25 | .putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, resp) 26 | reply.putParcelable(AccountManager.KEY_INTENT, intent) 27 | reply 28 | } 29 | 30 | def hasFeatures(p1: AccountAuthenticatorResponse, p2: Account, p3: Array[String]) = null 31 | def updateCredentials(p1: AccountAuthenticatorResponse, p2: Account, p3: String, p4: Bundle) = null 32 | def getAuthTokenLabel(p1: String) = "" 33 | def getAuthToken(p1: AccountAuthenticatorResponse, p2: Account, p3: String, p4: Bundle) = null 34 | def confirmCredentials(p1: AccountAuthenticatorResponse, p2: Account, p3: Bundle) = null 35 | def editProperties(p1: AccountAuthenticatorResponse, p2: String) = null 36 | } 37 | } 38 | 39 | -------------------------------------------------------------------------------- /src/main/scala/com/zegoggles/gist/App.scala: -------------------------------------------------------------------------------- 1 | package com.zegoggles.gist 2 | 3 | import android.accounts.{Account, OnAccountsUpdateListener, AccountManager} 4 | import org.acra.annotation.ReportsCrashes 5 | import org.acra.{ReportingInteractionMode, ACRA} 6 | import android.os.StrictMode.ThreadPolicy 7 | import android.os.{Build, StrictMode} 8 | 9 | @ReportsCrashes(formUri = "https://bugsense.appspot.com/api/acra?api_key=02946e8e", formKey = "", 10 | mode = ReportingInteractionMode.NOTIFICATION, 11 | resNotifTickerText = R.string.crash_notif_ticker_text, 12 | resNotifTitle = R.string.crash_notif_title, 13 | resNotifText = R.string.crash_notif_text, 14 | resDialogText = R.string.crash_dialog_text, 15 | resDialogTitle = R.string.crash_dialog_title, 16 | resDialogCommentPrompt = R.string.crash_dialog_comment_prompt, 17 | resDialogOkToast = R.string.crash_dialog_ok_toast) 18 | class App extends android.app.Application with Logger with ApiHolder { 19 | var preloadedList:Option[Seq[Gist]] = None 20 | 21 | override def onCreate() { 22 | ACRA.init(this) 23 | 24 | // reload current token and clear list when accounts get changed 25 | AccountManager.get(this).addOnAccountsUpdatedListener(new OnAccountsUpdateListener { 26 | def onAccountsUpdated(accounts: Array[Account]) { 27 | api.token = token 28 | preloadedList = None 29 | } 30 | }, /*handler*/ null, /*updateImmediately*/ false) 31 | 32 | 33 | // emulate honeycomb+ network crash behaviour in Gingerbread 34 | if (Build.VERSION.SDK_INT >= 9 && Build.VERSION.SDK_INT < 11) { 35 | StrictMode.setThreadPolicy(new ThreadPolicy.Builder().detectNetwork().penaltyDeath().build()) 36 | } 37 | 38 | super.onCreate() 39 | } 40 | } 41 | 42 | object App { 43 | val TAG = "gist-it" 44 | } 45 | -------------------------------------------------------------------------------- /project/build.scala: -------------------------------------------------------------------------------- 1 | import sbt._ 2 | import Keys._ 3 | import AndroidKeys._ 4 | import Github._ 5 | 6 | object General { 7 | val settings = Defaults.defaultSettings ++ Seq ( 8 | version := "0.1.4", 9 | versionCode := 5, 10 | organization := "com.zegoggles", 11 | scalaVersion := "2.8.1" // 2.9.1 fail with DEXOPT install errors 12 | ) 13 | 14 | val androidSettings = 15 | settings ++ 16 | Seq ( 17 | platformName := "android-10" 18 | ) 19 | 20 | val androidProjectSettings = 21 | androidSettings ++ 22 | AndroidProject.androidSettings 23 | 24 | val androidFullProjectSettings = 25 | androidProjectSettings ++ 26 | TypedResources.settings ++ 27 | AndroidMarketPublish.settings ++ 28 | AndroidManifestGenerator.settings ++ 29 | Github.settings 30 | } 31 | 32 | object AndroidBuild extends Build { 33 | // MainProject 34 | lazy val app = Project ( 35 | "gist-it", 36 | file("."), 37 | settings = General.androidFullProjectSettings ++ Seq ( 38 | keyalias in Android := "jberkel", 39 | libraryDependencies ++= Seq( 40 | "org.acra" % "acra" % "4.2.3" 41 | //"com.github.jbrechtel" %% "robospecs" % "0.1-SNAPSHOT" % "test" 42 | ), 43 | compileOrder := CompileOrder.JavaThenScala, 44 | useProguard in Android := true, 45 | githubRepo in Android := "gist-it", 46 | cachePasswords in Android := true, 47 | resolvers ++= Seq( 48 | MavenRepository("acra release repository", "http://acra.googlecode.com/svn/repository/releases"), 49 | MavenRepository("robospecs snapshots", "http://jbrechtel.github.com/repo/snapshots"), 50 | MavenRepository("scala tools snapshots", "http://scala-tools.org/repo-snapshots") 51 | ) 52 | ) ++ AndroidInstall.settings 53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /src/test/scala/com/zegoggles/gist/JsonModelSpec.scala: -------------------------------------------------------------------------------- 1 | package com.zegoggles.gist 2 | 3 | import org.specs2.mutable.Specification 4 | import org.json.JSONObject 5 | 6 | class JsonModelSpec extends Specification { 7 | "a valid json user list" should { 8 | "return a list of users" in { 9 | val l = """ 10 | [ 11 | { 12 | "user": { 13 | "gravatar_id": "b8dbb1987e8e5318584865f880036796", 14 | "company": "GitHub", 15 | "name": "Chris Wanstrath", 16 | "created_at": "2007/10/19 22:24:19 -0700", 17 | "location": "San Francisco, CA", 18 | "public_repo_count": 98, 19 | "public_gist_count": 270, 20 | "blog": "http://chriswanstrath.com/", 21 | "following_count": 196, 22 | "id": 2, 23 | "type": "User", 24 | "permission": null, 25 | "followers_count": 1692, 26 | "login": "defunkt", 27 | "email": "chris@wanstrath.com" 28 | } 29 | }, 30 | { 31 | "user": { 32 | "gravatar_id": "12345", 33 | "company": "GitHub", 34 | "name": "Foo Bar", 35 | "created_at": "2007/10/19 22:24:19 -0700", 36 | "location": "San Foo, CA", 37 | "public_repo_count": 98, 38 | "public_gist_count": 270, 39 | "blog": "http://chriswanstrath.com/", 40 | "following_count": 196, 41 | "id": 3, 42 | "type": "User", 43 | "permission": null, 44 | "followers_count": 1692, 45 | "login": "defunkt", 46 | "email": "chris@wanstrath.com" 47 | } 48 | } 49 | ] 50 | """ 51 | val list = JSONArrayWrapper(l, User(_:JSONObject)).get 52 | list.size must be equalTo 2 53 | } 54 | } 55 | 56 | "an invalid list" should { 57 | "return None" in { 58 | JSONArrayWrapper("""invalid""", User(_:JSONObject)) must beNone 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /src/main/scala/com/zegoggles/gist/Gist.scala: -------------------------------------------------------------------------------- 1 | package com.zegoggles.gist 2 | 3 | import java.io.IOException 4 | import scala.Some 5 | import java.net.URL 6 | import org.json.JSONObject 7 | import android.os.Bundle 8 | import android.net.Uri 9 | 10 | object Gist extends JsonModel[Gist] { 11 | def apply(j: JSONObject) = { 12 | val files = j.getJSONObject("files").names() 13 | val file = j.getJSONObject("files").getJSONObject(files.getString(0)) 14 | val history = j.optJSONArray("history") 15 | val last_modified = if (history != null && history.length() > 0) 16 | getDate(history.getJSONObject(0).getString("committed_at")) 17 | else 18 | getDate(j.getString("created_at")) 19 | 20 | Some(Gist(j.getString("id"), 21 | if (j.isNull("description")) null else j.getString("description"), 22 | file.getString("filename"), 23 | file.getLong("size"), 24 | j.getBoolean("public"), 25 | j.getString("url"), 26 | file.getString("raw_url"), 27 | file.optString("content"), 28 | last_modified.getTime / 1000L)) 29 | } 30 | } 31 | 32 | case class Gist(id: String, description: String, 33 | filename: String, size: Long, 34 | public: Boolean, url: String, raw_url: String, 35 | content: String, last_modified: Long) { 36 | 37 | override def toString = "Gist %d".format(id) 38 | def asHtml = {filename} ({size_in_words}, {last_modified_ago}) 39 | lazy val public_url = "https://gist.github.com/" + url.substring(url.lastIndexOf("/")+1) 40 | lazy val public_uri = Uri.parse(public_url) 41 | lazy val uri = Uri.parse(raw_url) 42 | 43 | def raw_content:Option[String] = try { Some(load)} catch { case e:IOException => None } 44 | def load = io.Source.fromURL(new URL(raw_url)).mkString 45 | 46 | def size_in_words = Utils.readableSize(size) 47 | def last_modified_ago = Utils.readableTime(System.currentTimeMillis() / 1000L - last_modified) 48 | def color = if (public) R.color.public_gist else R.color.private_gist 49 | 50 | def asBundle = { 51 | val b = new Bundle() 52 | b.putString("id", id) 53 | b.putString("filename", filename) 54 | b.putString("description", description) 55 | b.putString("raw_url", raw_url) 56 | b.putString("content", content) 57 | b.putBoolean("public", public) 58 | b 59 | } 60 | } 61 | 62 | -------------------------------------------------------------------------------- /src/main/scala/com/zegoggles/gist/Utils.scala: -------------------------------------------------------------------------------- 1 | package com.zegoggles.gist 2 | 3 | import android.widget.TextView 4 | import android.text.method.LinkMovementMethod 5 | import android.view.View 6 | import android.text.style.ClickableSpan 7 | import android.text.{SpannableString, Spanned, Spannable} 8 | 9 | object Utils { 10 | /** 11 | * Adapted from the {@link android.text.util.Linkify} class. Changes the 12 | * first instance of {@code link} into a clickable link attached to the given function. 13 | */ 14 | def clickify(v: TextView, clickableText: String, listener: => Unit):Boolean = { 15 | val text = v.getText 16 | val string = text.toString 17 | val span = new ClickableSpan { 18 | def onClick(widget: View) { 19 | listener 20 | } 21 | } 22 | val start = string.indexOf(clickableText) 23 | if (start != -1) { 24 | val end = start + clickableText.length() 25 | text match { 26 | case spannable:Spannable => spannable.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) 27 | case other => 28 | val s = SpannableString.valueOf(other) 29 | s.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) 30 | v.setText(s) 31 | } 32 | val m = v.getMovementMethod 33 | if (m == null || !(m.isInstanceOf[LinkMovementMethod])) { 34 | v.setMovementMethod(LinkMovementMethod.getInstance()) 35 | } 36 | true 37 | } else false 38 | } 39 | 40 | def readableSize(b: Long) = b match { 41 | case c if c > 0 && c < 1024 => b+" bytes" 42 | case c if c > 1024 && c < 1024*1024 => (b/1024).round+" kb" 43 | case c if c > 1024*1024 && c < 1024*1024*1024 => (b/1024/1024).round+" mb" 44 | case c => (b/1024/1024/1024).round+" gb" 45 | } 46 | 47 | def readableTime(t: Long) = { 48 | def format(s: Long, divisor:Int, name:String) = { 49 | val value = (s/divisor).round 50 | value+" "+((if (value == 1) name else name+"s")+ " ago") 51 | } 52 | t match { 53 | case s if s < 10 => "just now" 54 | case s if s < 60 => format(s, 1, "second") 55 | case s if s < 3600 => format(s, 60, "minute") 56 | case s if s < 3600*24 => format(s, 3600, "hour") 57 | case s if s < 86400*30 => format(s, 86400, "day") 58 | case s if s < 86400*30*12 => format(s, 86400*30, "month") 59 | case s => format(s, 86400*30*12, "year") 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [gist-it][] 2 | 3 | ![icon][] 4 | 5 | Open source Android gist API client written in Scala. 6 | 7 | The Android app uses the new [github api][] to provide a "send to gist" 8 | feature for most applications which have a "Send" or "Share" menu. 9 | 10 | Check the following screenshot to get an idea of the flow (this example uses the 11 | [ColorNote Notepad][] app) 12 | 13 | ![flow][] 14 | 15 | By default gists are created anonymously - you can add your github account 16 | using Android's "Accounts & Sync" settings or follow the instructions in the 17 | gist app itself. 18 | 19 | With an associated account you also have the ability to edit existing gists - 20 | Use "Load gist" from the menu, make changes and upload it again. 21 | 22 | ## Usage from other apps 23 | 24 | If your are developing an Android app and want to make use of the gist api you 25 | can do so with intents. At the moment there are two actions exposed: 26 | 27 | ### picking/loading a gist 28 | 29 | Intent intent = new Intent("com.zegoggles.gist.PICK"); 30 | intent.putExtra("load_gist", false); // load gist content, defaults to true 31 | startActivityForResult(intent, 0) 32 | 33 | ### uploading a gist 34 | 35 | startActivityForResult(new Intent("com.zegoggles.gist.UPLOAD") 36 | .putExtra(Intent.EXTRA_TEXT, "text123") 37 | .putExtra("public", false) 38 | .putExtra("description", "testing gist upload via intent"), 0); 39 | 40 | ## Building from source 41 | 42 | You need [sbt][] (simple-build-tool, >= 0.11.2 ) in order to 43 | build the project, 44 | 45 | $ export ANDROID_HOME=/path/to/sdk # or ANDROID_SDK_{HOME,ROOT} 46 | $ sbt android:package-debug 47 | 48 | To run tests: 49 | 50 | $ sbt test 51 | 52 | Pull requests welcome, especially the design needs some love (hint, hint). 53 | 54 | ## Credits / License 55 | 56 | See LICENSE. Post it graphic by [christianalm][]. 57 | 58 | [![][FlattrButton]][FlattrLink] 59 | 60 | [gist-it]: https://market.android.com/details?id=com.zegoggles.gist 61 | [gist]: https://github.com/blog/118-here-s-the-gist-of-it 62 | [github api]: http://developer.github.com/v3/gists/ 63 | [ColorNote Notepad]: https://market.android.com/details?id=com.socialnmobile.dictapps.notepad.color.note 64 | [sbt]: http://code.google.com/p/simple-build-tool/ 65 | [sbt-android-plugin]: https://github.com/jberkel/android-plugin 66 | [flow]: https://github.com/downloads/jberkel/gist-it/send_flow.png 67 | [icon]: https://github.com/downloads/jberkel/gist-it/gist-it-logo_128.png 68 | [christianalm]: http://graphicriver.net/user/cristianalm 69 | [FlattrLink]: https://flattr.com/thing/304054/gist-it 70 | [FlattrButton]: http://api.flattr.com/button/button-static-50x60.png 71 | -------------------------------------------------------------------------------- /src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | gist-it 3 | gist-it 4 | com.zegoggles.gist 5 | Loading 6 | Authenticating 7 | Token exchange failed 8 | Loading gists 9 | Gist it 10 | Uploading 11 | Public 12 | Gist uploaded 13 | Gist description… 14 | name this file… 15 | Make this gist visible to everybody 16 | Content 17 | Empty 18 | The gist is empty. Are you sure you want to upload it? 19 | 20 | Error accessing content (permission denied) 21 | Uploading failed" 22 | Loading gist failed: %s 23 | Getting gists failed" 24 | 25 | Error while connecting 26 | The URL of the newly created gist will be available in the clipboard after a successful upload. 27 | 28 | 29 | There is currently no registered gist account so the content will be uploaded anonymously. 30 | %s to post authenticated gists. 31 | 32 | 33 | 34 | Add an account 35 | 36 | 37 | Gist is a simple way to share snippets and pastes with others. 38 | All gists are git repositories, so they are automatically versioned, forkable and usable 39 | as a git repository. 40 | See gist.github.com for more information. 41 | 42 | 43 | Replace gist 44 | Refresh 45 | 46 | Fork me 47 | Clear 48 | Load gist 49 | Loading gist 50 | https://github.com/jberkel/gist-it 51 | Gist copied to clipboard 52 | 53 | -------------------------------------------------------------------------------- /src/main/scala/com/zegoggles/gist/LoggingWebViewClient.scala: -------------------------------------------------------------------------------- 1 | package com.zegoggles.gist 2 | 3 | import android.graphics.Bitmap 4 | import java.lang.String 5 | import android.webkit._ 6 | import android.os.Message 7 | import android.net.http.SslError 8 | import android.view.KeyEvent 9 | 10 | class LoggingWebViewClient extends WebViewClient with Logger { 11 | override def shouldOverrideUrlLoading(view: WebView, url: String): Boolean = { 12 | log("shouldOverrideUrlLoading(" + url + ")") 13 | super.shouldOverrideUrlLoading(view, url) 14 | } 15 | 16 | override def onPageStarted(view: WebView, url: String, favicon: Bitmap) { 17 | log("onPageStarted(" + url + ")") 18 | super.onPageStarted(view, url, favicon) 19 | } 20 | 21 | override def onPageFinished(view: WebView, url: String) { 22 | log("onPageFinished(" + url + ")") 23 | super.onPageFinished(view, url) 24 | } 25 | 26 | override def onLoadResource(view: WebView, url: String) { 27 | log("onLoadResource(" + url + ")") 28 | super.onLoadResource(view, url) 29 | } 30 | 31 | override def onTooManyRedirects(view: WebView, cancelMsg: Message, continueMsg: Message) { 32 | log("onTooManyRedirects(" + cancelMsg + "," + continueMsg + ")") 33 | super.onTooManyRedirects(view, cancelMsg, continueMsg) 34 | } 35 | 36 | override def onReceivedError(view: WebView, errorCode: Int, description: String, failingUrl: String) { 37 | log("onReceivedError(" + errorCode + "," + description + "," + failingUrl + ")") 38 | super.onReceivedError(view, errorCode, description, failingUrl) 39 | } 40 | 41 | override def onFormResubmission(view: WebView, dontResend: Message, resend: Message) { 42 | log("onFormResubmission(" + dontResend + "," + resend + ")") 43 | super.onFormResubmission(view, dontResend, resend) 44 | } 45 | 46 | override def doUpdateVisitedHistory(view: WebView, url: String, isReload: Boolean) { 47 | log("doUpdateVisitedHistory(" + url + "," + isReload + ")") 48 | super.doUpdateVisitedHistory(view, url, isReload) 49 | } 50 | 51 | override def onReceivedSslError(view: WebView, handler: SslErrorHandler, error: SslError) { 52 | log("onReceivedSslError(" + handler + "," + error + ")") 53 | super.onReceivedSslError(view, handler, error) 54 | } 55 | 56 | override def onReceivedHttpAuthRequest(view: WebView, handler: HttpAuthHandler, host: String, realm: String) { 57 | log("onReceivedHttpAuthRequest(" + handler + "," + host + "," + realm + ")") 58 | super.onReceivedHttpAuthRequest(view, handler, host, realm) 59 | } 60 | 61 | override def shouldOverrideKeyEvent(view: WebView, event: KeyEvent): Boolean = { 62 | log("shouldOverrideKeyEvent(" + event + ")") 63 | super.shouldOverrideKeyEvent(view, event) 64 | } 65 | 66 | override def onUnhandledKeyEvent(view: WebView, event: KeyEvent) { 67 | log("onUnhandledKeyEvent(" + event + ")") 68 | super.onUnhandledKeyEvent(view, event) 69 | } 70 | 71 | override def onScaleChanged(view: WebView, oldScale: Float, newScale: Float) { 72 | log("onScaleChanged(" + oldScale + "," + newScale + ")") 73 | super.onScaleChanged(view, oldScale, newScale) 74 | } 75 | } 76 | 77 | 78 | -------------------------------------------------------------------------------- /src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 50 | 51 | 52 | 53 | 54 | 57 | 58 | 59 | 60 | 61 | 62 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /src/test/scala/com/zegoggles/gist/GistSpec.scala: -------------------------------------------------------------------------------- 1 | package com.zegoggles.gist 2 | 3 | import org.specs2.mutable.Specification 4 | 5 | class GistSpec extends Specification { 6 | "A valid gist" should { 7 | val valid_gist = """ 8 | { 9 | "url": "https://api.github.com/gists/1", 10 | "id": "1", 11 | "description": "description of gist", 12 | "public": true, 13 | "user": { 14 | "login": "octocat", 15 | "id": 1, 16 | "gravatar_url": "https://github.com/images/error/octocat_happy.gif", 17 | "url": "https://api.github.com/users/octocat" 18 | }, 19 | "files": { 20 | "ring.erl": { 21 | "size": 932, 22 | "filename": "ring.erl", 23 | "raw_url": "https://gist.github.com/raw/365370/8c4d2d43d178df44f4c03a7f2ac0ff512853564e/ring.erl", 24 | "content": "contents of gist" 25 | } 26 | }, 27 | "comments": 0, 28 | "git_pull_url": "git://gist.github.com/1.git", 29 | "git_push_url": "git@gist.github.com:1.git", 30 | "created_at": "2010-04-14T02:15:15Z", 31 | "forks": [ 32 | { 33 | "user": { 34 | "login": "octocat", 35 | "id": 1, 36 | "gravatar_url": "https://github.com/images/error/octocat_happy.gif", 37 | "url": "https://api.github.com/users/octocat" 38 | }, 39 | "url": "https://api.github.com/gists/5", 40 | "created_at": "2011-04-14T16:00:49Z" 41 | } 42 | ], 43 | "history": [ 44 | { 45 | "url": "https://api.github.com/gists/1/57a7f021a713b1c5a6a199b54cc514735d2d462f", 46 | "version": "57a7f021a713b1c5a6a199b54cc514735d2d462f", 47 | "user": { 48 | "login": "octocat", 49 | "id": 1, 50 | "gravatar_url": "https://github.com/images/error/octocat_happy.gif", 51 | "url": "https://api.github.com/users/octocat" 52 | }, 53 | "change_status": { 54 | "deletions": 0, 55 | "additions": 180, 56 | "total": 180 57 | }, 58 | "committed_at": "2010-04-14T02:15:15Z" 59 | } 60 | ] 61 | } 62 | """ 63 | 64 | lazy val gist = Gist(valid_gist).get 65 | 66 | "be parseable from JSON" in { 67 | gist.id must be equalTo "1" 68 | gist.description must be equalTo "description of gist" 69 | gist.public must be equalTo true 70 | gist.url must be equalTo "https://api.github.com/gists/1" 71 | gist.size must be equalTo 932 72 | gist.filename must be equalTo "ring.erl" 73 | gist.raw_url must be equalTo "https://gist.github.com/raw/365370/8c4d2d43d178df44f4c03a7f2ac0ff512853564e/ring.erl" 74 | gist.content must be equalTo "contents of gist" 75 | } 76 | 77 | 78 | "have the last modified timestamp" in { 79 | gist.last_modified must be equalTo 1271211315 80 | } 81 | 82 | "show the last edit in a nice form" in { 83 | gist.last_modified_ago must be equalTo "1 year ago" 84 | } 85 | 86 | "have a public url" in { 87 | gist.public_url must be equalTo "https://gist.github.com/1" 88 | } 89 | 90 | "be pattern matchable" in { 91 | Gist(valid_gist) match { 92 | case Some(Gist("1", _, _, size, _, _, _, _, _)) => size must be equalTo 932 93 | case _ => error("default case") 94 | } 95 | } 96 | 97 | "have a method to access the backing content" in { 98 | gist.raw_content.get.size must be equalTo 932 99 | } 100 | 101 | "be parseable without content" in { 102 | val short_gist = """ 103 | { 104 | "url": "https://api.github.com/gists/1", 105 | "id": "1", 106 | "description": null, 107 | "public": true, 108 | "user": { 109 | "login": "octocat", 110 | "id": 1, 111 | "gravatar_url": "https://github.com/images/error/octocat_happy.gif", 112 | "url": "https://api.github.com/users/octocat" 113 | }, 114 | "files": { 115 | "ring.erl": { 116 | "size": 932, 117 | "filename": "ring.erl", 118 | "raw_url": "https://gist.github.com/raw/365370/8c4d2d43d178df44f4c03a7f2ac0ff512853564e/ring.erl", 119 | } 120 | }, 121 | "comments": 0, 122 | "git_pull_url": "git://gist.github.com/1.git", 123 | "git_push_url": "git@gist.github.com:1.git", 124 | "created_at": "2010-04-14T02:15:15Z" 125 | } 126 | """ 127 | val gist = Gist(short_gist).get 128 | gist.id must be equalTo "1" 129 | gist.last_modified must be equalTo 1271211315 130 | gist.description must beNull 131 | } 132 | } 133 | 134 | "An invalid gist" should { 135 | "should return none" in { 136 | Gist("""invalid""") must beNone 137 | } 138 | } 139 | } -------------------------------------------------------------------------------- /src/main/scala/com/zegoggles/gist/Login.scala: -------------------------------------------------------------------------------- 1 | package com.zegoggles.gist 2 | 3 | import android.graphics.Bitmap 4 | import android.accounts.{Account, AccountManager, AccountAuthenticatorActivity} 5 | import android.webkit._ 6 | import java.lang.String 7 | import android.os.{Handler, Bundle} 8 | import actors.Futures 9 | import Implicits._ 10 | import android.webkit.WebSettings.ZoomDensity 11 | import android.app.{AlertDialog, ProgressDialog} 12 | import android.net.Uri 13 | import android.text.TextUtils 14 | import org.apache.http.HttpStatus 15 | import java.io.IOException 16 | import android.widget.Toast 17 | 18 | class Login extends AccountAuthenticatorActivity with Logger with ApiActivity with TypedActivity { 19 | val handler = new Handler() 20 | lazy val view = findView(TR.webview) 21 | 22 | override def onCreate(savedInstanceState: Bundle) { 23 | super.onCreate(savedInstanceState) 24 | setContentView(R.layout.login) 25 | 26 | view.getSettings.setDefaultZoom(ZoomDensity.FAR) 27 | view.getSettings.setJavaScriptEnabled(true) 28 | view.getSettings.setBlockNetworkImage(false) 29 | view.getSettings.setLoadsImagesAutomatically(true) 30 | 31 | val progress = ProgressDialog.show(this, null, getString(R.string.loading_login), false) 32 | view.setWebViewClient(new WebViewClient() { 33 | 34 | override def onPageStarted(view: WebView, url: String, favicon: Bitmap) { 35 | super.onPageStarted(view, url, favicon) 36 | progress.show() 37 | if (android.os.Build.VERSION.SDK_INT <= 7) { 38 | // in 2.1 shouldOverrideUrlLoading doesn't get called 39 | if (shouldOverrideUrlLoading(view, url)) { 40 | view.stopLoading() 41 | } 42 | } 43 | } 44 | 45 | override def shouldOverrideUrlLoading(view: WebView, url: String) = { 46 | super.shouldOverrideUrlLoading(view, url) 47 | if (url.startsWith(api.redirect_uri)) { 48 | Uri.parse(url).getQueryParameter("code") match { 49 | case code:String => exchangeToken(code) 50 | case _ => warn("no code found in redirect uri") 51 | } 52 | true 53 | } else false 54 | } 55 | 56 | override def onReceivedError(view: WebView, errorCode: Int, description: String, failingUrl: String) { 57 | super.onReceivedError(view, errorCode, description, failingUrl) 58 | showConnectionError(if (TextUtils.isEmpty(description)) None else Some(description)) 59 | } 60 | 61 | 62 | override def onPageFinished(view: WebView, url: String) { 63 | super.onPageFinished(view, url) 64 | view.loadUrl("javascript:document.getElementById('login_field').focus();") 65 | try { progress.dismiss() } catch { case e:IllegalArgumentException => warn("", e) } 66 | } 67 | }) 68 | 69 | if (isConnected) { 70 | removeAllCookies() 71 | view.loadUrl(api.authorizeUrl) 72 | } else showConnectionError(None) 73 | } 74 | 75 | def exchangeToken(code: String) { 76 | val progress = ProgressDialog.show(this, null, getString(R.string.loading_token), false) 77 | Futures.future { 78 | try { 79 | val token = api.exchangeToken(code).getOrElse(throw new IOException("could not get token")) 80 | val resp = api.get(Request("/user", "access_token"->token.access)) 81 | resp.getStatusLine.getStatusCode match { 82 | case HttpStatus.SC_OK => 83 | User(resp.getEntity).map { user => 84 | handler.post { 85 | setAccountAuthenticatorResult( 86 | addAccount(user.login, token, 87 | "id" -> user.id.toString, 88 | "name" -> user.name, 89 | "email" -> user.email)) 90 | } 91 | } 92 | case c => 93 | log("invalid status ("+c+") "+resp.getStatusLine) 94 | Toast.makeText(this, R.string.loading_token_failed, Toast.LENGTH_LONG).show() 95 | } 96 | } 97 | catch { 98 | case e:IOException => warn("error", e) 99 | Toast.makeText(this, R.string.loading_token_failed, Toast.LENGTH_LONG).show() 100 | } 101 | finally { handler.post { progress.dismiss(); finish() } } 102 | } 103 | } 104 | 105 | def addAccount(name: String, token: Token, data: (String, String)*): Bundle = { 106 | val account = new Account(name, accountType) 107 | val am = AccountManager.get(this) 108 | am.addAccountExplicitly(account, token.access, null) 109 | am.setAuthToken(account, "access", token.access) 110 | for ((k, v) <- data) am.setUserData(account, k, v) 111 | val b = new Bundle() 112 | b.putString(AccountManager.KEY_ACCOUNT_NAME, name) 113 | b.putString(AccountManager.KEY_ACCOUNT_TYPE, accountType) 114 | b 115 | } 116 | 117 | def showConnectionError(message: Option[String]) { 118 | var error = getString(R.string.login_error_no_connection_message) 119 | message.map(m => error += " (" + m + ")") 120 | new AlertDialog.Builder(this) 121 | .setMessage(error) 122 | .setIcon(android.R.drawable.ic_dialog_alert) 123 | .setPositiveButton(android.R.string.ok, () => finish()) 124 | .show() 125 | } 126 | 127 | def removeAllCookies() { 128 | CookieSyncManager.createInstance(this) 129 | CookieManager.getInstance().removeAllCookie() 130 | } 131 | 132 | override def onDestroy() { 133 | view.stopLoading() 134 | super.onDestroy() 135 | } 136 | } 137 | 138 | -------------------------------------------------------------------------------- /src/main/scala/com/zegoggles/gist/GistList.scala: -------------------------------------------------------------------------------- 1 | package com.zegoggles.gist 2 | 3 | import android.os.Bundle 4 | import org.apache.http.HttpStatus 5 | import com.zegoggles.gist.Implicits._ 6 | import android.app.{Activity, ProgressDialog, ListActivity} 7 | import android.content.{Intent, Context} 8 | import android.text.Html 9 | import actors.Futures 10 | import java.io.IOException 11 | import scala.Left 12 | import android.widget._ 13 | import android.view._ 14 | 15 | class GistList extends ListActivity with ApiActivity with Logger { 16 | override def onCreate(bundle: Bundle) { 17 | super.onCreate(bundle) 18 | 19 | if (getLastNonConfigurationInstance != null) { 20 | setListAdapter(getLastNonConfigurationInstance.asInstanceOf[ListAdapter]) 21 | } else { 22 | setListAdapter(new GistAdapter()) 23 | 24 | app.preloadedList match { 25 | case Some(gists) => getListAdapter.setGists(gists) 26 | case None => loadGists(pathFromIntent(getIntent)) 27 | } 28 | } 29 | } 30 | 31 | def loadGist(gist: Gist)(whenDone: Either[IOException,String] => Unit) = Futures.future { 32 | val result = try { 33 | Right(gist.load) 34 | } catch { 35 | case e: IOException => Left(e) 36 | } 37 | runOnUiThread(whenDone(result)) 38 | } 39 | 40 | def loadGists(path: String) { 41 | val pd = ProgressDialog.show(this, null, getString(R.string.loading_gists), true) 42 | executeAsync(api.get(_), 43 | Request(path), 44 | HttpStatus.SC_OK, Some(pd))(resp => Gist.list(resp.getEntity).map(onGistLoaded(_))) { 45 | error => error match { 46 | case Left(e) => warn("error getting gists", e) 47 | case Right(r) => warn("unexpected status code: "+r.getStatusLine) 48 | } 49 | Toast.makeText(this, R.string.list_failed, Toast.LENGTH_LONG).show() 50 | finish() 51 | } 52 | } 53 | 54 | def onGistLoaded(g: Seq[Gist]) { 55 | app.preloadedList = Some(g) 56 | getListAdapter.setGists(g) 57 | } 58 | 59 | override def onListItemClick(l: ListView, v: View, position: Int, id: Long) { 60 | val gist = getListAdapter.getItem(position) 61 | val extras = gist.asBundle 62 | 63 | def done() { 64 | setResult(Activity.RESULT_OK, new Intent().putExtras(extras).setData(gist.uri)) 65 | finish() 66 | } 67 | 68 | if (shouldLoadBody(getIntent)) { 69 | val progress = ProgressDialog.show(this, null, getString(R.string.loading_gist), true) 70 | loadGist(gist) { r => 71 | progress.dismiss() 72 | r match { 73 | case Right(body) => extras.putString("body", body) 74 | case Left(exception) => 75 | warn("error fetching content", exception) 76 | Toast.makeText(this, 77 | getString(R.string.loading_gist_failed, exception.getMessage), 78 | Toast.LENGTH_SHORT).show() 79 | } 80 | done() 81 | } 82 | } else done() 83 | } 84 | 85 | override def onCreateOptionsMenu(menu: Menu) = { 86 | menu.add(Menu.NONE, 1, Menu.NONE, R.string.refresh).setIcon(android.R.drawable.ic_menu_rotate) 87 | super.onCreateOptionsMenu(menu) 88 | } 89 | 90 | override def onOptionsItemSelected(item: MenuItem) = item.getItemId match { 91 | case 1 => loadGists(pathFromIntent(getIntent)); true 92 | case _ => false 93 | } 94 | 95 | def pathFromIntent(intent: Intent): String = 96 | if (intent.getData != null) intent.getData.getPath else "/gists" 97 | 98 | def shouldLoadBody(intent: Intent) = intent.getBooleanExtra("load_gist", true) 99 | 100 | override def onRetainNonConfigurationInstance() = getListAdapter 101 | override def getListAdapter:GistAdapter = super.getListAdapter.asInstanceOf[GistAdapter] 102 | } 103 | 104 | class GistAdapter extends BaseAdapter { 105 | var gists: IndexedSeq[Gist] = Vector.empty 106 | 107 | def findView[T](v: View, tr: TypedResource[T]) = v.findViewById(tr.id).asInstanceOf[T] 108 | def getView(position: Int, convertView: View, parent: ViewGroup) = { 109 | val view = if (convertView == null) 110 | parent.getContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE) 111 | .asInstanceOf[LayoutInflater].inflate(R.layout.gist_row, parent, false) 112 | else convertView 113 | 114 | val gist = getItem(position) 115 | val description = findView(view, TR.gist_description) 116 | description.setText(Html.fromHtml(gist.asHtml.toString())) 117 | view.setBackgroundColor(view.getResources.getColor(gist.color)) 118 | view 119 | } 120 | 121 | def getItemId(position: Int) = getItem(position).id.hashCode() 122 | def getItem(position: Int):Gist = gists(position) 123 | def getCount = gists.size 124 | override def hasStableIds = true 125 | 126 | def setGists(l: Traversable[Gist]) { 127 | gists = Vector[Gist](l.toSeq:_*) 128 | notifyDataSetChanged() 129 | } 130 | } 131 | 132 | package examples { 133 | // how to start from a console: 134 | // am start -a android.intent.action.MAIN -n com.zegoggles.gist/.examples.Pick 135 | class Pick extends Activity { 136 | override def onCreate(bundle: Bundle) { 137 | super.onCreate(bundle) 138 | 139 | // this is how you would call the upload activity from another app 140 | startActivityForResult(new Intent(Intents.PICK_GIST), 0) 141 | 142 | // here's how you would fetch only starred gists 143 | //startActivityForResult(new Intent(Intents.PICK_GIST) 144 | // .setData(Uri.parse("gist://github.com/gists/starred")), 0) 145 | } 146 | 147 | override def onActivityResult(requestCode: Int, resultCode: Int, data: Intent) { 148 | if (resultCode == Activity.RESULT_OK && data.getData != null) { 149 | Toast.makeText(this, "Picked "+data.getData,Toast.LENGTH_SHORT).show() 150 | } else { 151 | Toast.makeText(this, "Canceled",Toast.LENGTH_SHORT).show() 152 | } 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/main/scala/com/zegoggles/gist/Api.scala: -------------------------------------------------------------------------------- 1 | package com.zegoggles.gist 2 | 3 | import org.apache.http.impl.client.DefaultHttpClient 4 | import org.apache.http.client.methods._ 5 | import collection.mutable.ListBuffer 6 | import org.apache.http.client.utils.URLEncodedUtils 7 | import java.net.URI 8 | import Implicits._ 9 | import android.accounts.{AccountManager, Account} 10 | import java.lang.Boolean 11 | import android.net.http.AndroidHttpClient 12 | import org.apache.http.client.HttpClient 13 | import android.content.Context 14 | import android.content.pm.PackageManager 15 | import org.apache.http.params.HttpConnectionParams 16 | import org.json.JSONObject 17 | import org.apache.http.message.{BasicHeader, BasicNameValuePair} 18 | import java.io.IOException 19 | import actors.Futures 20 | import android.app.{Dialog, Activity} 21 | import android.net.ConnectivityManager 22 | import org.apache.http._ 23 | import entity.{BufferedHttpEntity, StringEntity} 24 | 25 | object Request { 26 | def apply(s: String, p: (String, String)*) = { 27 | val request = new Request(s) 28 | for ((k, v) <- p) request.add(k, v) 29 | request 30 | } 31 | implicit def string2Request(s: String): Request = new Request(s) 32 | } 33 | 34 | class Request(val url: String) { 35 | import collection.JavaConversions._ 36 | 37 | lazy val params: ListBuffer[NameValuePair] = ListBuffer() 38 | var body: Option[String] = None 39 | 40 | def add(key: String, value: Any): Request = { 41 | params += new BasicNameValuePair(key, value.toString) 42 | this 43 | } 44 | 45 | def add(params: Map[String, Any]): Request = { 46 | for ((k, v) <- params) add(k, v) 47 | this 48 | } 49 | 50 | def body(b: String): Request = { body = Some(b); this } 51 | def body(j: JSONObject):Request = body(j.toString) 52 | def queryString = URLEncodedUtils.format(params, "UTF-8") 53 | 54 | def toURI = URI.create(url + (if (params.isEmpty) "" else "?" + queryString)) 55 | 56 | def toHTTPRequest[T <: HttpRequestBase](r: Class[T]): T = { 57 | val req = r.newInstance 58 | req.setURI(req match { 59 | case e: HttpEntityEnclosingRequestBase => 60 | e.setEntity(new StringEntity(body.getOrElse { 61 | e.setHeader("Content-Type", "application/x-www-form-urlencoded") 62 | queryString 63 | })) 64 | URI.create(url) 65 | case _ => toURI 66 | }) 67 | req 68 | } 69 | } 70 | 71 | case class Token(access: String) 72 | object Api { 73 | type Success = HttpResponse 74 | type Error = Either[Exception,HttpResponse] 75 | 76 | def map2Json(m: Map[String, Any]): JSONObject = { 77 | val obj: JSONObject = new JSONObject() 78 | for ((k, v) <- m) { 79 | obj.put(k, v match { 80 | case m: Map[String,Any] => map2Json(m) 81 | case b: Boolean => b 82 | case i: Int => i 83 | case l: Long => l 84 | case d: Double => d 85 | case o: Any => o 86 | }) 87 | } 88 | obj 89 | } 90 | 91 | def parseTokenResponse(s: String): Option[Token] = { 92 | //"access_token=807e750b891b3fc47b0c951b4c11c0b610195b73&token_type=bearer" 93 | val token = for ( 94 | fields <- s.split('&').map(_.split('=')) 95 | if (fields(0) == "access_token") 96 | ) yield (fields(1)) 97 | token.headOption.map(Token(_)) 98 | } 99 | } 100 | 101 | class Api(val client_id: String, val client_secret: String, val redirect_uri: String, var token: Option[Token]) extends StdoutLogger { 102 | lazy val client = makeClient 103 | val baseHost = new HttpHost("api.github.com", -1, "https") 104 | val authorizeUrl = "https://github.com/login/oauth/authorize?client_id=" + 105 | client_id + "&scope=user,gist&redirect_uri=" + redirect_uri 106 | 107 | def get(req: Request) = execute(req, classOf[HttpGet]) 108 | def put(req: Request) = execute(req, classOf[HttpPut]) 109 | def post(req: Request) = execute(req, classOf[HttpPost]) 110 | def patch(req: Request) = execute(req, classOf[HttpPatch]) 111 | 112 | def execute[T <: HttpRequestBase](request: Request, reqType: Class[T]) = (hostFor(request) match { 113 | case Some(host) => client.execute(host, _:HttpUriRequest) 114 | case None => client.execute(_:HttpUriRequest) 115 | })(withAuthHeader(request.toHTTPRequest(reqType))) 116 | 117 | def hostFor(r: Request):Option[HttpHost] = 118 | if (r.url.startsWith("http://")||r.url.startsWith("https://")) None else Some(baseHost) 119 | 120 | def exchangeToken(code: String): Option[Token] = { 121 | val resp = post(Request("https://github.com/login/oauth/access_token", 122 | "client_id" -> client_id, 123 | "client_secret" -> client_secret, 124 | "code" -> code, 125 | "redirect_uri" -> redirect_uri)) 126 | 127 | resp.getStatusLine.getStatusCode match { 128 | case HttpStatus.SC_OK => 129 | token = Api.parseTokenResponse(resp.getEntity) 130 | token 131 | case _ => log("Invalid status code " + resp.getStatusLine); None 132 | } 133 | } 134 | 135 | def withAuthHeader(req:HttpUriRequest) = { 136 | token.map(t => req.addHeader(new BasicHeader("Authorization", "token "+t.access))) 137 | req 138 | } 139 | def makeClient:HttpClient = new DefaultHttpClient() 140 | } 141 | 142 | trait ApiActivity extends Activity with TokenHolder { 143 | def app = getApplication.asInstanceOf[App] 144 | def api = app.api 145 | 146 | def executeAsync(call: Request => HttpResponse, req: Request, expected: Int, progress: Option[Dialog]) 147 | (success: HttpResponse => Any) 148 | (error: Api.Error => Any) = { 149 | 150 | def onUiThread(f: => Unit) { 151 | runOnUiThread(new Runnable() { def run() { progress.map(_.dismiss()); f } } ) 152 | } 153 | progress.map(_.show()) 154 | Futures.future { 155 | try { 156 | val resp = call(req) 157 | resp.setEntity(new BufferedHttpEntity(resp.getEntity)) 158 | resp.getEntity.consumeContent() 159 | 160 | resp.getStatusLine.getStatusCode match { 161 | case code if code == expected => onUiThread { success(resp)} 162 | case other => onUiThread { error(Right(resp))} 163 | } 164 | } catch { 165 | case e: IOException => onUiThread { error(Left(e)) } 166 | } 167 | } 168 | } 169 | 170 | def isConnected = { 171 | val manager = getSystemService(Context.CONNECTIVITY_SERVICE).asInstanceOf[ConnectivityManager] 172 | val info = manager.getActiveNetworkInfo 173 | info != null && info.isConnectedOrConnecting 174 | } 175 | } 176 | 177 | trait TokenHolder extends Context { 178 | lazy val accountType = getString(R.string.account_type) 179 | def account: Option[Account] = AccountManager.get(this).getAccountsByType(accountType).headOption 180 | def token: Option[Token] = account.map(a => Token(AccountManager.get(this).getPassword(a))) 181 | 182 | def addAccount(a:Activity) = 183 | AccountManager.get(this).addAccount(accountType, "access_token", null, null, a, null, null) 184 | } 185 | 186 | trait ApiHolder extends TokenHolder { 187 | lazy val info = getPackageManager.getPackageInfo(getClass.getPackage.getName, PackageManager.GET_META_DATA) 188 | lazy val userAgent = getPackageManager.getApplicationLabel(info.applicationInfo)+" ("+info.versionName+")" 189 | val timeout = 10 * 1000 190 | 191 | lazy val api = new Api( 192 | getString(R.string.client_id), 193 | getString(R.string.client_secret), 194 | getString(R.string.redirect_uri), 195 | token) { 196 | override def makeClient = { 197 | if (android.os.Build.VERSION.SDK_INT >= 8) { 198 | val client = AndroidHttpClient.newInstance(userAgent, ApiHolder.this) 199 | HttpConnectionParams.setConnectionTimeout(client.getParams, timeout) 200 | HttpConnectionParams.setSoTimeout(client.getParams, timeout) 201 | client 202 | } else super.makeClient 203 | } 204 | } 205 | } 206 | 207 | /** @see PATCH Method for HTTP: http://tools.ietf.org/html/rfc5789 */ 208 | class HttpPatch extends HttpEntityEnclosingRequestBase { 209 | def getMethod = "PATCH" 210 | } 211 | -------------------------------------------------------------------------------- /src/main/res/layout/upload_gist.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 13 | 14 | 17 | 18 | 25 | 26 | 34 | 35 | 43 | 44 | 53 | 54 | 55 | 60 | 61 | 62 | 63 | 78 | 79 | 94 | 95 | 96 | 112 | 113 | 121 | 122 | 132 | 133 | 143 | 144 | 145 | 146 | 147 | 152 | 158 | 159 |