commit 05d40a1363a606a94a5f7bfaaad6f16cdd19fcdd Author: Frank Radzio <frank@radzio-sh.de> Date: Fri, 24 Oct 2014 17:55:39 +0200 first commit Diffstat:
154 files changed, 5570 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore @@ -0,0 +1,6 @@ +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build diff --git a/.idea/.name b/.idea/.name @@ -0,0 +1 @@ +RPICMS +\ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project version="4"> + <component name="CompilerConfiguration"> + <option name="DEFAULT_COMPILER" value="Javac" /> + <resourceExtensions /> + <wildcardResourcePatterns> + <entry name="!?*.java" /> + <entry name="!?*.form" /> + <entry name="!?*.class" /> + <entry name="!?*.groovy" /> + <entry name="!?*.scala" /> + <entry name="!?*.flex" /> + <entry name="!?*.kt" /> + <entry name="!?*.clj" /> + </wildcardResourcePatterns> + <annotationProcessing> + <profile default="true" name="Default" enabled="false"> + <processorPath useClasspath="true" /> + </profile> + </annotationProcessing> + </component> +</project> + diff --git a/.idea/copyright/profiles_settings.xml b/.idea/copyright/profiles_settings.xml @@ -0,0 +1,3 @@ +<component name="CopyrightManager"> + <settings default="" /> +</component> +\ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project version="4"> + <component name="Encoding" useUTFGuessing="true" native2AsciiForPropertiesFiles="false" /> +</project> + diff --git a/.idea/gradle.xml b/.idea/gradle.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project version="4"> + <component name="GradleSettings"> + <option name="linkedExternalProjectsSettings"> + <GradleProjectSettings> + <option name="distributionType" value="DEFAULT_WRAPPED" /> + <option name="externalProjectPath" value="$PROJECT_DIR$" /> + <option name="modules"> + <set> + <option value="$PROJECT_DIR$" /> + <option value="$PROJECT_DIR$/mobile" /> + <option value="$PROJECT_DIR$/tv" /> + </set> + </option> + </GradleProjectSettings> + </option> + </component> +</project> + diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,11 @@ +<component name="InspectionProjectProfileManager"> + <profile version="1.0" is_locked="false"> + <option name="myName" value="Project Default" /> + <option name="myLocal" value="false" /> + <inspection_tool class="AndroidLintNewerVersionAvailable" enabled="true" level="WARNING" enabled_by_default="true" /> + <inspection_tool class="LoggerInitializedWithForeignClass" enabled="false" level="WARNING" enabled_by_default="false"> + <option name="loggerClassName" value="org.apache.log4j.Logger,org.slf4j.LoggerFactory,org.apache.commons.logging.LogFactory,java.util.logging.Logger" /> + <option name="loggerFactoryMethodName" value="getLogger,getLogger,getLog,getLogger" /> + </inspection_tool> + </profile> +</component> +\ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,7 @@ +<component name="InspectionProjectProfileManager"> + <settings> + <option name="PROJECT_PROFILE" value="Project Default" /> + <option name="USE_PROJECT_PROFILE" value="true" /> + <version value="1.0" /> + </settings> +</component> +\ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project version="4"> + <component name="EntryPointsManager"> + <entry_points version="2.0" /> + </component> + <component name="ProjectRootManager" version="2" languageLevel="JDK_1_6" assert-keyword="true" jdk-15="true" project-jdk-name="JDK" project-jdk-type="JavaSDK"> + <output url="file://$PROJECT_DIR$/build/classes" /> + </component> +</project> + diff --git a/.idea/modules.xml b/.idea/modules.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project version="4"> + <component name="ProjectModuleManager"> + <modules> + <module fileurl="file://$PROJECT_DIR$/RPICMS.iml" filepath="$PROJECT_DIR$/RPICMS.iml" /> + <module fileurl="file://$PROJECT_DIR$/mobile/mobile.iml" filepath="$PROJECT_DIR$/mobile/mobile.iml" /> + <module fileurl="file://$PROJECT_DIR$/tv/tv.iml" filepath="$PROJECT_DIR$/tv/tv.iml" /> + </modules> + </component> +</project> + diff --git a/.idea/scopes/scope_settings.xml b/.idea/scopes/scope_settings.xml @@ -0,0 +1,5 @@ +<component name="DependencyValidationManager"> + <state> + <option name="SKIP_IMPORT_STATEMENTS" value="false" /> + </state> +</component> +\ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project version="4"> + <component name="VcsDirectoryMappings"> + <mapping directory="" vcs="" /> + </component> +</project> + diff --git a/RPICMS.iml b/RPICMS.iml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8"?> +<module external.linked.project.path="$MODULE_DIR$" external.root.project.path="$MODULE_DIR$" external.system.id="GRADLE" external.system.module.group="" external.system.module.version="unspecified" type="JAVA_MODULE" version="4"> + <component name="FacetManager"> + <facet type="java-gradle" name="Java-Gradle"> + <configuration> + <option name="BUILD_FOLDER_PATH" value="$MODULE_DIR$/build" /> + </configuration> + </facet> + </component> + <component name="NewModuleRootManager" inherit-compiler-output="true"> + <exclude-output /> + <content url="file://$MODULE_DIR$"> + <excludeFolder url="file://$MODULE_DIR$/.gradle" /> + </content> + <orderEntry type="inheritedJdk" /> + <orderEntry type="sourceFolder" forTests="false" /> + </component> +</module> + diff --git a/build.gradle b/build.gradle @@ -0,0 +1,19 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + repositories { + jcenter() + } + dependencies { + classpath 'com.android.tools.build:gradle:0.12.2' + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +allprojects { + repositories { + jcenter() + } +} diff --git a/gradle.properties b/gradle.properties @@ -0,0 +1,18 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Settings specified in this file will override any Gradle settings +# configured through the IDE. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx10248m -XX:MaxPermSize=256m +# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +\ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar Binary files differ. diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Wed Apr 10 15:27:10 PDT 2013 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=http\://services.gradle.org/distributions/gradle-1.12-all.zip diff --git a/gradlew b/gradlew @@ -0,0 +1,164 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# For Cygwin, ensure paths are in UNIX format before anything is touched. +if $cygwin ; then + [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"` +fi + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >&- +APP_HOME="`pwd -P`" +cd "$SAVED" >&- + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/gradlew.bat b/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/mobile/.gitignore b/mobile/.gitignore @@ -0,0 +1 @@ +/build diff --git a/mobile/build.gradle b/mobile/build.gradle @@ -0,0 +1,25 @@ +apply plugin: 'com.android.application' + +android { + compileSdkVersion 18 + buildToolsVersion '19.1.0' + + defaultConfig { + applicationId "de.nordgedanken.rpicms" + minSdkVersion 18 + targetSdkVersion 20 + versionCode 1 + versionName "1.0" + } + buildTypes { + release { + runProguard false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +dependencies { + compile fileTree(dir: 'libs', include: ['*.jar']) + compile 'com.android.support:support-v4:20.0.0' +} diff --git a/mobile/mobile-debug.apk b/mobile/mobile-debug.apk Binary files differ. diff --git a/mobile/mobile-release.apk b/mobile/mobile-release.apk Binary files differ. diff --git a/mobile/mobile.iml b/mobile/mobile.iml @@ -0,0 +1,86 @@ +<?xml version="1.0" encoding="UTF-8"?> +<module external.linked.project.path="$MODULE_DIR$" external.root.project.path="$MODULE_DIR$/.." external.system.id="GRADLE" external.system.module.group="RPICMS" external.system.module.version="unspecified" type="JAVA_MODULE" version="4"> + <component name="FacetManager"> + <facet type="android-gradle" name="Android-Gradle"> + <configuration> + <option name="GRADLE_PROJECT_PATH" value=":mobile" /> + </configuration> + </facet> + <facet type="android" name="Android"> + <configuration> + <option name="SELECTED_BUILD_VARIANT" value="debug" /> + <option name="ASSEMBLE_TASK_NAME" value="assembleDebug" /> + <option name="COMPILE_JAVA_TASK_NAME" value="compileDebugJava" /> + <option name="ASSEMBLE_TEST_TASK_NAME" value="assembleDebugTest" /> + <option name="SOURCE_GEN_TASK_NAME" value="generateDebugSources" /> + <option name="ALLOW_USER_CONFIGURATION" value="false" /> + <option name="MANIFEST_FILE_RELATIVE_PATH" value="/src/main/AndroidManifest.xml" /> + <option name="RES_FOLDER_RELATIVE_PATH" value="/src/main/res" /> + <option name="RES_FOLDERS_RELATIVE_PATH" value="file://$MODULE_DIR$/src/main/res" /> + <option name="ASSETS_FOLDER_RELATIVE_PATH" value="/src/main/assets" /> + </configuration> + </facet> + </component> + <component name="NewModuleRootManager" inherit-compiler-output="false"> + <output url="file://$MODULE_DIR$/build/intermediates/classes/debug" /> + <exclude-output /> + <content url="file://$MODULE_DIR$"> + <sourceFolder url="file://$MODULE_DIR$/build/generated/source/r/debug" isTestSource="false" generated="true" /> + <sourceFolder url="file://$MODULE_DIR$/build/generated/source/aidl/debug" isTestSource="false" generated="true" /> + <sourceFolder url="file://$MODULE_DIR$/build/generated/source/buildConfig/debug" isTestSource="false" generated="true" /> + <sourceFolder url="file://$MODULE_DIR$/build/generated/source/rs/debug" isTestSource="false" generated="true" /> + <sourceFolder url="file://$MODULE_DIR$/build/generated/res/rs/debug" type="java-resource" /> + <sourceFolder url="file://$MODULE_DIR$/build/generated/source/r/test/debug" isTestSource="true" generated="true" /> + <sourceFolder url="file://$MODULE_DIR$/build/generated/source/aidl/test/debug" isTestSource="true" generated="true" /> + <sourceFolder url="file://$MODULE_DIR$/build/generated/source/buildConfig/test/debug" isTestSource="true" generated="true" /> + <sourceFolder url="file://$MODULE_DIR$/build/generated/source/rs/test/debug" isTestSource="true" generated="true" /> + <sourceFolder url="file://$MODULE_DIR$/build/generated/res/rs/test/debug" type="java-test-resource" /> + <sourceFolder url="file://$MODULE_DIR$/src/debug/res" type="java-resource" /> + <sourceFolder url="file://$MODULE_DIR$/src/debug/resources" type="java-resource" /> + <sourceFolder url="file://$MODULE_DIR$/src/debug/aidl" isTestSource="false" /> + <sourceFolder url="file://$MODULE_DIR$/src/debug/assets" isTestSource="false" /> + <sourceFolder url="file://$MODULE_DIR$/src/debug/java" isTestSource="false" /> + <sourceFolder url="file://$MODULE_DIR$/src/debug/jni" isTestSource="false" /> + <sourceFolder url="file://$MODULE_DIR$/src/debug/rs" isTestSource="false" /> + <sourceFolder url="file://$MODULE_DIR$/src/main/res" type="java-resource" /> + <sourceFolder url="file://$MODULE_DIR$/src/main/resources" type="java-resource" /> + <sourceFolder url="file://$MODULE_DIR$/src/main/aidl" isTestSource="false" /> + <sourceFolder url="file://$MODULE_DIR$/src/main/assets" isTestSource="false" /> + <sourceFolder url="file://$MODULE_DIR$/src/main/java" isTestSource="false" /> + <sourceFolder url="file://$MODULE_DIR$/src/main/jni" isTestSource="false" /> + <sourceFolder url="file://$MODULE_DIR$/src/main/rs" isTestSource="false" /> + <sourceFolder url="file://$MODULE_DIR$/src/androidTest/res" type="java-test-resource" /> + <sourceFolder url="file://$MODULE_DIR$/src/androidTest/resources" type="java-test-resource" /> + <sourceFolder url="file://$MODULE_DIR$/src/androidTest/aidl" isTestSource="true" /> + <sourceFolder url="file://$MODULE_DIR$/src/androidTest/assets" isTestSource="true" /> + <sourceFolder url="file://$MODULE_DIR$/src/androidTest/java" isTestSource="true" /> + <sourceFolder url="file://$MODULE_DIR$/src/androidTest/jni" isTestSource="true" /> + <sourceFolder url="file://$MODULE_DIR$/src/androidTest/rs" isTestSource="true" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/assets" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/bundles" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/classes" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/coverage-instrumented-classes" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/dependency-cache" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/dex" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/dex-cache" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/incremental" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/jacoco" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/javaResources" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/libs" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/lint" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/manifests" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/ndk" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/pre-dexed" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/proguard" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/res" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/rs" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/symbols" /> + <excludeFolder url="file://$MODULE_DIR$/build/outputs" /> + </content> + <orderEntry type="jdk" jdkName="Android API 18 Platform" jdkType="Android SDK" /> + <orderEntry type="sourceFolder" forTests="false" /> + <orderEntry type="library" exported="" name="support-annotations-20.0.0" level="project" /> + <orderEntry type="library" exported="" name="support-v4-20.0.0" level="project" /> + </component> +</module> + diff --git a/mobile/proguard-rules.pro b/mobile/proguard-rules.pro @@ -0,0 +1,17 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /home/frank/Arbeitsfläche/android-studio/sdk/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# 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/mobile/src/androidTest/java/de/nordgedanken/rpicms/ApplicationTest.java b/mobile/src/androidTest/java/de/nordgedanken/rpicms/ApplicationTest.java @@ -0,0 +1,13 @@ +package de.nordgedanken.rpicms; + +import android.app.Application; +import android.test.ApplicationTestCase; + +/** + * <a href="http://d.android.com/tools/testing/testing_android.html">Testing Fundamentals</a> + */ +public class ApplicationTest extends ApplicationTestCase<Application> { + public ApplicationTest() { + super(Application.class); + } +} +\ No newline at end of file diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml @@ -0,0 +1,76 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="de.nordgedanken.rpicms" + android:versionCode="1" + android:versionName="1.0"> + <uses-sdk + android:minSdkVersion="7" + android:targetSdkVersion="18" /> + <!-- Required for fetching feed data. --> + <uses-permission android:name="android.permission.INTERNET"/> + <!-- Required to register a SyncStatusObserver to display a "syncing..." progress indicator. --> + <uses-permission android:name="android.permission.READ_SYNC_STATS"/> + <!-- Required to enable our SyncAdapter after it's created. --> + <uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS"/> + <!-- Required because we're manually creating a new account. --> + <uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS"/> + <application + android:allowBackup="true" + android:icon="@drawable/ic_launcher" + android:label="@string/app_name" + android:theme="@style/AppTheme" > + <activity + android:name=".Start" + android:label="@string/app_name" > + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + + <category android:name="android.intent.category.LAUNCHER" /> + </intent-filter> + </activity> + <!--<activity + android:name=".EntryListActivity" + android:label="@string/app_name" > + <! This intent filter places this activity in the system's app launcher. + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + <category android:name="android.intent.category.LAUNCHER" /> + </intent-filter> + </activity>--> + <provider + android:name="de.nordgedanken.rpicms.basicsyncadapter.provider.FeedProvider" + android:authorities="de.nordgedanken.rpicms.basicsyncadapter" + android:exported="false" /> + + <!-- This service implements our SyncAdapter. It needs to be exported, so that the system + sync framework can access it. --> + <service android:name="de.nordgedanken.rpicms.basicsyncadapter.SyncService" + android:exported="true"> + <!-- This intent filter is required. It allows the system to launch our sync service + as needed. --> + <intent-filter> + <action android:name="android.content.SyncAdapter" /> + </intent-filter> + <!-- This points to a required XML file which describes our SyncAdapter. --> + <meta-data android:name="android.content.SyncAdapter" + android:resource="@xml/syncadapter" /> + </service> + + <!-- This implements the account we'll use as an attachment point for our SyncAdapter. Since + our SyncAdapter doesn't need to authenticate the current user (it just fetches a public RSS + feed), this account's implementation is largely empty. + + It's also possible to attach a SyncAdapter to an existing account provided by another + package. In that case, this element could be omitted here. --> + <service android:name="de.nordgedanken.rpicms.common.accounts.GenericAccountService"> + <!-- Required filter used by the system to launch our account service. --> + <intent-filter> + <action android:name="android.accounts.AccountAuthenticator" /> + </intent-filter> + <!-- This points to an XMLf ile which describes our account service. --> + <meta-data android:name="android.accounts.AccountAuthenticator" + android:resource="@xml/authenticator" /> + </service> + </application> + +</manifest> diff --git a/mobile/src/main/java/de/nordgedanken/rpicms/EntryListActivity.java b/mobile/src/main/java/de/nordgedanken/rpicms/EntryListActivity.java @@ -0,0 +1,19 @@ +package de.nordgedanken.rpicms; + +/** + * Created by frank on 14.10.14. + */ +import android.os.Bundle; +import android.support.v4.app.FragmentActivity; + +/** + * Activity for holding EntryListFragment. + */ +public class EntryListActivity extends FragmentActivity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_entry_list); + } +} diff --git a/mobile/src/main/java/de/nordgedanken/rpicms/EntryListFragment.java b/mobile/src/main/java/de/nordgedanken/rpicms/EntryListFragment.java @@ -0,0 +1,345 @@ +package de.nordgedanken.rpicms; + +/** + * Created by frank on 14.10.14. + */ +import android.accounts.Account; +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.ContentResolver; +import android.content.Intent; +import android.content.SyncStatusObserver; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.support.v4.app.ListFragment; +import android.support.v4.app.LoaderManager; +import android.support.v4.content.CursorLoader; +import android.support.v4.content.Loader; +import android.support.v4.widget.SimpleCursorAdapter; +import android.text.format.Time; +import android.util.Log; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.widget.ListView; +import android.widget.TextView; + +import de.nordgedanken.rpicms.common.accounts.GenericAccountService; +import de.nordgedanken.rpicms.basicsyncadapter.provider.FeedContract; +import de.nordgedanken.rpicms.basicsyncadapter.*; +import de.nordgedanken.rpicms.*; + +/** + * List fragment containing a list of Atom entry objects (articles) stored in the local database. + * + * <p>Database access is mediated by a content provider, specified in + * {@link de.nordgedanken.rpicms.basicsyncadapter.provider.FeedProvider}. This content + * provider is + * automatically populated by {@link SyncService}. + * + * <p>Selecting an item from the displayed list displays the article in the default browser. + * + * <p>If the content provider doesn't return any data, then the first sync hasn't run yet. This sync + * adapter assumes data exists in the provider once a sync has run. If your app doesn't work like + * this, you should add a flag that notes if a sync has run, so you can differentiate between "no + * available data" and "no initial sync", and display this in the UI. + * + * <p>The ActionBar displays a "Refresh" button. When the user clicks "Refresh", the sync adapter + * runs immediately. An indeterminate ProgressBar element is displayed, showing that the sync is + * occurring. + */ +public class EntryListFragment extends ListFragment + implements LoaderManager.LoaderCallbacks<Cursor> { + + private static final String TAG = "EntryListFragment"; + + /** + * Cursor adapter for controlling ListView results. + */ + private SimpleCursorAdapter mAdapter; + + /** + * Handle to a SyncObserver. The ProgressBar element is visible until the SyncObserver reports + * that the sync is complete. + * + * <p>This allows us to delete our SyncObserver once the application is no longer in the + * foreground. + */ + private Object mSyncObserverHandle; + + /** + * Options menu used to populate ActionBar. + */ + private Menu mOptionsMenu; + + /** + * Projection for querying the content provider. + */ + private static final String[] PROJECTION = new String[]{ + FeedContract.Entry._ID, + FeedContract.Entry.COLUMN_NAME_TITLE, + FeedContract.Entry.COLUMN_NAME_LINK, + FeedContract.Entry.COLUMN_NAME_PUBLISHED + }; + + // Column indexes. The index of a column in the Cursor is the same as its relative position in + // the projection. + /** Column index for _ID */ + private static final int COLUMN_ID = 0; + /** Column index for title */ + private static final int COLUMN_TITLE = 1; + /** Column index for link */ + private static final int COLUMN_URL_STRING = 2; + /** Column index for published */ + private static final int COLUMN_PUBLISHED = 3; + + /** + * List of Cursor columns to read from when preparing an adapter to populate the ListView. + */ + private static final String[] FROM_COLUMNS = new String[]{ + FeedContract.Entry.COLUMN_NAME_TITLE, + FeedContract.Entry.COLUMN_NAME_PUBLISHED + }; + + /** + * List of Views which will be populated by Cursor data. + */ + private static final int[] TO_FIELDS = new int[]{ + android.R.id.text1, + android.R.id.text2}; + + /** + * Mandatory empty constructor for the fragment manager to instantiate the + * fragment (e.g. upon screen orientation changes). + */ + public EntryListFragment() {} + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + } + + /** + * Create SyncAccount at launch, if needed. + * + * <p>This will create a new account with the system for our application, register our + * {@link SyncService} with it, and establish a sync schedule. + */ + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + + // Create account, if needed + SyncUtils.CreateSyncAccount(activity); + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + mAdapter = new SimpleCursorAdapter( + getActivity(), // Current context + android.R.layout.simple_list_item_activated_2, // Layout for individual rows + null, // Cursor + FROM_COLUMNS, // Cursor columns to use + TO_FIELDS, // Layout fields to use + 0 // No flags + ); + mAdapter.setViewBinder(new SimpleCursorAdapter.ViewBinder() { + @Override + public boolean setViewValue(View view, Cursor cursor, int i) { + if (i == COLUMN_PUBLISHED) { + // Convert timestamp to human-readable date + Time t = new Time(); + t.set(cursor.getLong(i)); + ((TextView) view).setText(t.format("%Y-%m-%d %H:%M")); + return true; + } else { + // Let SimpleCursorAdapter handle other fields automatically + return false; + } + } + }); + setListAdapter(mAdapter); + setEmptyText(getText(R.string.loading)); + getLoaderManager().initLoader(0, null, this); + } + + @Override + public void onResume() { + super.onResume(); + mSyncStatusObserver.onStatusChanged(0); + + // Watch for sync state changes + final int mask = ContentResolver.SYNC_OBSERVER_TYPE_PENDING | + ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE; + mSyncObserverHandle = ContentResolver.addStatusChangeListener(mask, mSyncStatusObserver); + } + + @Override + public void onPause() { + super.onPause(); + if (mSyncObserverHandle != null) { + ContentResolver.removeStatusChangeListener(mSyncObserverHandle); + mSyncObserverHandle = null; + } + } + + /** + * Query the content provider for data. + * + * <p>Loaders do queries in a background thread. They also provide a ContentObserver that is + * triggered when data in the content provider changes. When the sync adapter updates the + * content provider, the ContentObserver responds by resetting the loader and then reloading + * it. + */ + @Override + public Loader<Cursor> onCreateLoader(int i, Bundle bundle) { + // We only have one loader, so we can ignore the value of i. + // (It'll be '0', as set in onCreate().) + return new CursorLoader(getActivity(), // Context + FeedContract.Entry.CONTENT_URI, // URI + PROJECTION, // Projection + null, // Selection + null, // Selection args + FeedContract.Entry.COLUMN_NAME_PUBLISHED + " desc"); // Sort + } + + /** + * Move the Cursor returned by the query into the ListView adapter. This refreshes the existing + * UI with the data in the Cursor. + */ + @Override + public void onLoadFinished(Loader<Cursor> cursorLoader, Cursor cursor) { + mAdapter.changeCursor(cursor); + } + + /** + * Called when the ContentObserver defined for the content provider detects that data has + * changed. The ContentObserver resets the loader, and then re-runs the loader. In the adapter, + * set the Cursor value to null. This removes the reference to the Cursor, allowing it to be + * garbage-collected. + */ + @Override + public void onLoaderReset(Loader<Cursor> cursorLoader) { + mAdapter.changeCursor(null); + } + + /** + * Create the ActionBar. + */ + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + mOptionsMenu = menu; + inflater.inflate(R.menu.main, menu); + } + + /** + * Respond to user gestures on the ActionBar. + */ + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + // If the user clicks the "Refresh" button. + case R.id.menu_refresh: + SyncUtils.TriggerRefresh(); + return true; + } + return super.onOptionsItemSelected(item); + } + + /** + * Load an article in the default browser when selected by the user. + */ + @Override + public void onListItemClick(ListView listView, View view, int position, long id) { + super.onListItemClick(listView, view, position, id); + + // Get a URI for the selected item, then start an Activity that displays the URI. Any + // Activity that filters for ACTION_VIEW and a URI can accept this. In most cases, this will + // be a browser. + + // Get the item at the selected position, in the form of a Cursor. + Cursor c = (Cursor) mAdapter.getItem(position); + // Get the link to the article represented by the item. + String articleUrlString = c.getString(COLUMN_URL_STRING); + if (articleUrlString == null) { + Log.e(TAG, "Attempt to launch entry with null link"); + return; + } + + Log.i(TAG, "Opening URL: " + articleUrlString); + // Get a Uri object for the URL string + Uri articleURL = Uri.parse(articleUrlString); + Intent i = new Intent(Intent.ACTION_VIEW, articleURL); + startActivity(i); + } + + /** + * Set the state of the Refresh button. If a sync is active, turn on the ProgressBar widget. + * Otherwise, turn it off. + * + * @param refreshing True if an active sync is occuring, false otherwise + */ + @TargetApi(Build.VERSION_CODES.HONEYCOMB) + public void setRefreshActionButtonState(boolean refreshing) { + if (mOptionsMenu == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB) { + return; + } + + final MenuItem refreshItem = mOptionsMenu.findItem(R.id.menu_refresh); + if (refreshItem != null) { + if (refreshing) { + refreshItem.setActionView(R.layout.actionbar_indeterminate_progress); + } else { + refreshItem.setActionView(null); + } + } + } + + /** + * Crfate a new anonymous SyncStatusObserver. It's attached to the app's ContentResolver in + * onResume(), and removed in onPause(). If status changes, it sets the state of the Refresh + * button. If a sync is active or pending, the Refresh button is replaced by an indeterminate + * ProgressBar; otherwise, the button itself is displayed. + */ + private SyncStatusObserver mSyncStatusObserver = new SyncStatusObserver() { + /** Callback invoked with the sync adapter status changes. */ + @Override + public void onStatusChanged(int which) { + getActivity().runOnUiThread(new Runnable() { + /** + * The SyncAdapter runs on a background thread. To update the UI, onStatusChanged() + * runs on the UI thread. + */ + @Override + public void run() { + // Create a handle to the account that was created by + // SyncService.CreateSyncAccount(). This will be used to query the system to + // see how the sync status has changed. + Account account = GenericAccountService.GetAccount(SyncUtils.ACCOUNT_TYPE); + if (account == null) { + // GetAccount() returned an invalid value. This shouldn't happen, but + // we'll set the status to "not refreshing". + setRefreshActionButtonState(false); + return; + } + + // Test the ContentResolver to see if the sync adapter is active or pending. + // Set the state of the refresh button accordingly. + boolean syncActive = ContentResolver.isSyncActive( + account, FeedContract.CONTENT_AUTHORITY); + boolean syncPending = ContentResolver.isSyncPending( + account, FeedContract.CONTENT_AUTHORITY); + setRefreshActionButtonState(syncActive || syncPending); + } + }); + } + }; + +} diff --git a/mobile/src/main/java/de/nordgedanken/rpicms/NavigationDrawerFragment.java b/mobile/src/main/java/de/nordgedanken/rpicms/NavigationDrawerFragment.java @@ -0,0 +1,292 @@ +package de.nordgedanken.rpicms; + + +import android.app.Activity; +import android.app.ActionBar; +import android.app.Fragment; +import android.net.http.AndroidHttpClient; +import android.provider.Settings; +import android.support.v4.app.ActionBarDrawerToggle; +import android.support.v4.view.GravityCompat; +import android.support.v4.widget.DrawerLayout; +import android.content.SharedPreferences; +import android.content.res.Configuration; +import android.os.Bundle; +import android.preference.PreferenceManager; +import android.test.mock.MockApplication; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.ListView; +import android.widget.Toast; + +import static android.provider.Settings.System.VOLUME_ALARM; + +/** + * Fragment used for managing interactions for and presentation of a navigation drawer. + * See the <a href="https://developer.android.com/design/patterns/navigation-drawer.html#Interaction"> + * design guidelines</a> for a complete explanation of the behaviors implemented here. + */ +public class NavigationDrawerFragment extends Fragment { + + /** + * Remember the position of the selected item. + */ + private static final String STATE_SELECTED_POSITION = "selected_navigation_drawer_position"; + + /** + * Per the design guidelines, you should show the drawer on launch until the user manually + * expands it. This shared preference tracks this. + */ + private static final String PREF_USER_LEARNED_DRAWER = "navigation_drawer_learned"; + + /** + * A pointer to the current callbacks instance (the Activity). + */ + private NavigationDrawerCallbacks mCallbacks; + + /** + * Helper component that ties the action bar to the navigation drawer. + */ + private ActionBarDrawerToggle mDrawerToggle; + + private DrawerLayout mDrawerLayout; + private ListView mDrawerListView; + private View mFragmentContainerView; + + private int mCurrentSelectedPosition = 0; + private boolean mFromSavedInstanceState; + private boolean mUserLearnedDrawer; + + public NavigationDrawerFragment() { + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Read in the flag indicating whether or not the user has demonstrated awareness of the + // drawer. See PREF_USER_LEARNED_DRAWER for details. + SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(getActivity()); + mUserLearnedDrawer = sp.getBoolean(PREF_USER_LEARNED_DRAWER, false); + + if (savedInstanceState != null) { + mCurrentSelectedPosition = savedInstanceState.getInt(STATE_SELECTED_POSITION); + mFromSavedInstanceState = true; + } + + // Select either the default item (0) or the last selected item. + selectItem(mCurrentSelectedPosition); + } + + @Override + public void onActivityCreated (Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + // Indicate that this fragment would like to influence the set of actions in the action bar. + setHasOptionsMenu(true); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + mDrawerListView = (ListView) inflater.inflate( + R.layout.fragment_navigation_drawer, container, false); + mDrawerListView.setOnItemClickListener(new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + selectItem(position); + } + }); + mDrawerListView.setAdapter(new ArrayAdapter<String>( + getActionBar().getThemedContext(), + android.R.layout.simple_list_item_activated_1, + android.R.id.text1, + new String[]{ + getString(R.string.title_section1), + getString(R.string.title_section2), + getString(R.string.title_section3), + })); + mDrawerListView.setItemChecked(mCurrentSelectedPosition, true); + return mDrawerListView; + } + + public boolean isDrawerOpen() { + return mDrawerLayout != null && mDrawerLayout.isDrawerOpen(mFragmentContainerView); + } + + /** + * Users of this fragment must call this method to set up the navigation drawer interactions. + * + * @param fragmentId The android:id of this fragment in its activity's layout. + * @param drawerLayout The DrawerLayout containing this fragment's UI. + */ + public void setUp(int fragmentId, DrawerLayout drawerLayout) { + mFragmentContainerView = getActivity().findViewById(fragmentId); + mDrawerLayout = drawerLayout; + + // set a custom shadow that overlays the main content when the drawer opens + mDrawerLayout.setDrawerShadow(R.drawable.drawer_shadow, GravityCompat.START); + // set up the drawer's list view with items and click listener + + ActionBar actionBar = getActionBar(); + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setHomeButtonEnabled(true); + + // ActionBarDrawerToggle ties together the the proper interactions + // between the navigation drawer and the action bar app icon. + mDrawerToggle = new ActionBarDrawerToggle( + getActivity(), /* host Activity */ + mDrawerLayout, /* DrawerLayout object */ + R.drawable.ic_drawer, /* nav drawer image to replace 'Up' caret */ + R.string.navigation_drawer_open, /* "open drawer" description for accessibility */ + R.string.navigation_drawer_close /* "close drawer" description for accessibility */ + ) { + @Override + public void onDrawerClosed(View drawerView) { + super.onDrawerClosed(drawerView); + if (!isAdded()) { + return; + } + + getActivity().invalidateOptionsMenu(); // calls onPrepareOptionsMenu() + } + + @Override + public void onDrawerOpened(View drawerView) { + super.onDrawerOpened(drawerView); + if (!isAdded()) { + return; + } + + if (!mUserLearnedDrawer) { + // The user manually opened the drawer; store this flag to prevent auto-showing + // the navigation drawer automatically in the future. + mUserLearnedDrawer = true; + SharedPreferences sp = PreferenceManager + .getDefaultSharedPreferences(getActivity()); + sp.edit().putBoolean(PREF_USER_LEARNED_DRAWER, true).apply(); + } + + getActivity().invalidateOptionsMenu(); // calls onPrepareOptionsMenu() + } + }; + + // If the user hasn't 'learned' about the drawer, open it to introduce them to the drawer, + // per the navigation drawer design guidelines. + if (!mUserLearnedDrawer && !mFromSavedInstanceState) { + mDrawerLayout.openDrawer(mFragmentContainerView); + } + + // Defer code dependent on restoration of previous instance state. + mDrawerLayout.post(new Runnable() { + @Override + public void run() { + mDrawerToggle.syncState(); + } + }); + + mDrawerLayout.setDrawerListener(mDrawerToggle); + } + + private void selectItem(int position) { + mCurrentSelectedPosition = position; + if (mDrawerListView != null) { + mDrawerListView.setItemChecked(position, true); + } + if (mDrawerLayout != null) { + mDrawerLayout.closeDrawer(mFragmentContainerView); + } + if (mCallbacks != null) { + mCallbacks.onNavigationDrawerItemSelected(position); + } + } + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + try { + mCallbacks = (NavigationDrawerCallbacks) activity; + } catch (ClassCastException e) { + throw new ClassCastException("Activity must implement NavigationDrawerCallbacks."); + } + } + + @Override + public void onDetach() { + super.onDetach(); + mCallbacks = null; + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putInt(STATE_SELECTED_POSITION, mCurrentSelectedPosition); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + // Forward the new configuration the drawer toggle component. + mDrawerToggle.onConfigurationChanged(newConfig); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + // If the drawer is open, show the global app actions in the action bar. See also + // showGlobalContextActionBar, which controls the top-left area of the action bar. + if (mDrawerLayout != null && isDrawerOpen()) { + inflater.inflate(R.menu.global, menu); + showGlobalContextActionBar(); + } + super.onCreateOptionsMenu(menu, inflater); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (mDrawerToggle.onOptionsItemSelected(item)) { + return true; + } + + if (item.getItemId() == R.id.action_sync) { + Toast.makeText(getActivity(), "Syncing...", Toast.LENGTH_SHORT).show(); + + return true; + } + if (item.getItemId() == R.id.action_synctab) { + Toast.makeText(getActivity(), "Syncing...", Toast.LENGTH_SHORT).show(); + return true; + } + + return super.onOptionsItemSelected(item); + } + + /** + * Per the navigation drawer design guidelines, updates the action bar to show the global app + * 'context', rather than just what's in the current screen. + */ + private void showGlobalContextActionBar() { + ActionBar actionBar = getActionBar(); + actionBar.setDisplayShowTitleEnabled(true); + actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD); + actionBar.setTitle(R.string.app_name); + } + + private ActionBar getActionBar() { + return getActivity().getActionBar(); + } + + /** + * Callbacks interface that all activities using this fragment must implement. + */ + public static interface NavigationDrawerCallbacks { + /** + * Called when an item in the navigation drawer is selected. + */ + void onNavigationDrawerItemSelected(int position); + } +} diff --git a/mobile/src/main/java/de/nordgedanken/rpicms/Start.java b/mobile/src/main/java/de/nordgedanken/rpicms/Start.java @@ -0,0 +1,146 @@ +package de.nordgedanken.rpicms; + +import android.app.Activity; + +import android.app.ActionBar; +import android.app.Fragment; +import android.app.FragmentManager; +import android.content.Context; +import android.os.Build; +import android.os.Bundle; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.support.v4.widget.DrawerLayout; +import android.widget.ArrayAdapter; +import android.widget.TextView; + + +public class Start extends Activity + implements NavigationDrawerFragment.NavigationDrawerCallbacks { + + /** + * Fragment managing the behaviors, interactions and presentation of the navigation drawer. + */ + private NavigationDrawerFragment mNavigationDrawerFragment; + + /** + * Used to store the last screen title. For use in {@link #restoreActionBar()}. + */ + private CharSequence mTitle; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_start); + + mNavigationDrawerFragment = (NavigationDrawerFragment) + getFragmentManager().findFragmentById(R.id.navigation_drawer); + mTitle = getTitle(); + + // Set up the drawer. + mNavigationDrawerFragment.setUp( + R.id.navigation_drawer, + (DrawerLayout) findViewById(R.id.drawer_layout)); + } + + @Override + public void onNavigationDrawerItemSelected(int position) { + // update the main content by replacing fragments + FragmentManager fragmentManager = getFragmentManager(); + fragmentManager.beginTransaction() + .replace(R.id.container, PlaceholderFragment.newInstance(position + 1)) + .commit(); + } + + public void onSectionAttached(int number) { + switch (number) { + case 1: + mTitle = getString(R.string.title_section1); + break; + case 2: + mTitle = getString(R.string.title_section2); + break; + case 3: + mTitle = getString(R.string.title_section3); + break; + } + } + + public void restoreActionBar() { + ActionBar actionBar = getActionBar(); + actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD); + actionBar.setDisplayShowTitleEnabled(true); + actionBar.setTitle(mTitle); + } + + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + if (!mNavigationDrawerFragment.isDrawerOpen()) { + // Only show items in the action bar relevant to this screen + // if the drawer is not showing. Otherwise, let the drawer + // decide what to show in the action bar. + getMenuInflater().inflate(R.menu.start, menu); + restoreActionBar(); + return true; + } + return super.onCreateOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + // Handle action bar item clicks here. The action bar will + // automatically handle clicks on the Home/Up button, so long + // as you specify a parent activity in AndroidManifest.xml. + int id = item.getItemId(); + if (id == R.id.action_settings) { + return true; + } + return super.onOptionsItemSelected(item); + } + + /** + * A placeholder fragment containing a simple view. + */ + public static class PlaceholderFragment extends Fragment { + /** + * The fragment argument representing the section number for this + * fragment. + */ + private static final String ARG_SECTION_NUMBER = "section_number"; + + /** + * Returns a new instance of this fragment for the given section + * number. + */ + public static PlaceholderFragment newInstance(int sectionNumber) { + PlaceholderFragment fragment = new PlaceholderFragment(); + Bundle args = new Bundle(); + args.putInt(ARG_SECTION_NUMBER, sectionNumber); + fragment.setArguments(args); + return fragment; + } + + public PlaceholderFragment() { + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + View rootView = inflater.inflate(R.layout.fragment_start, container, false); + return rootView; + } + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + ((Start) activity).onSectionAttached( + getArguments().getInt(ARG_SECTION_NUMBER)); + } + } + +} diff --git a/mobile/src/main/java/de/nordgedanken/rpicms/basicsyncadapter/SyncAdapter.java b/mobile/src/main/java/de/nordgedanken/rpicms/basicsyncadapter/SyncAdapter.java @@ -0,0 +1,297 @@ +package de.nordgedanken.rpicms.basicsyncadapter; + +/** + * Created by frank on 13.10.14. + */ +import android.accounts.Account; +import android.annotation.TargetApi; +import android.content.AbstractThreadedSyncAdapter; +import android.content.ContentProviderClient; +import android.content.ContentProviderOperation; +import android.content.ContentResolver; +import android.content.Context; +import android.content.OperationApplicationException; +import android.content.SyncResult; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.RemoteException; +import android.util.Log; + +import de.nordgedanken.rpicms.basicsyncadapter.net.FeedParser; +import de.nordgedanken.rpicms.basicsyncadapter.provider.FeedContract; + +import org.xmlpull.v1.XmlPullParserException; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; +import java.text.ParseException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +class SyncAdapter extends AbstractThreadedSyncAdapter { + + + + /** + * Define a sync adapter for the app. + * + * <p>This class is instantiated in {@link SyncService}, which also binds SyncAdapter to the system. + * SyncAdapter should only be initialized in SyncService, never anywhere else. + * + * <p>The system calls onPerformSync() via an RPC call through the IBinder object supplied by + * SyncService. + */ + + public static final String TAG = "SyncAdapter"; + + /** + * URL to fetch content from during a sync. + * + * <p>This points to the Android Developers Blog. (Side note: We highly recommend reading the + * Android Developer Blog to stay up to date on the latest Android platform developments!) + */ + private static final String FEED_URL = "http://nordgedanken.de/feed/atom"; + + /** + * Network connection timeout, in milliseconds. + */ + private static final int NET_CONNECT_TIMEOUT_MILLIS = 15000; // 15 seconds + + /** + * Network read timeout, in milliseconds. + */ + private static final int NET_READ_TIMEOUT_MILLIS = 10000; // 10 seconds + + /** + * Content resolver, for performing database operations. + */ + private final ContentResolver mContentResolver; + + /** + * Project used when querying content provider. Returns all known fields. + */ + private static final String[] PROJECTION = new String[] { + FeedContract.Entry._ID, + FeedContract.Entry.COLUMN_NAME_ENTRY_ID, + FeedContract.Entry.COLUMN_NAME_TITLE, + FeedContract.Entry.COLUMN_NAME_LINK, + FeedContract.Entry.COLUMN_NAME_PUBLISHED}; + + // Constants representing column positions from PROJECTION. + public static final int COLUMN_ID = 0; + public static final int COLUMN_ENTRY_ID = 1; + public static final int COLUMN_TITLE = 2; + public static final int COLUMN_LINK = 3; + public static final int COLUMN_PUBLISHED = 4; + + /** + * Constructor. Obtains handle to content resolver for later use. + */ + public SyncAdapter(Context context, boolean autoInitialize) { + super(context, autoInitialize); + mContentResolver = context.getContentResolver(); + } + + /** + * Constructor. Obtains handle to content resolver for later use. + */ + @TargetApi(Build.VERSION_CODES.HONEYCOMB) + public SyncAdapter(Context context, boolean autoInitialize, boolean allowParallelSyncs) { + super(context, autoInitialize, allowParallelSyncs); + mContentResolver = context.getContentResolver(); + } + + /** + * Called by the Android system in response to a request to run the sync adapter. The work + * required to read data from the network, parse it, and store it in the content provider is + * done here. Extending AbstractThreadedSyncAdapter ensures that all methods within SyncAdapter + * run on a background thread. For this reason, blocking I/O and other long-running tasks can be + * run <em>in situ</em>, and you don't have to set up a separate thread for them. + . + * + * <p>This is where we actually perform any work required to perform a sync. + * {@link android.content.AbstractThreadedSyncAdapter} guarantees that this will be called on a non-UI thread, + * so it is safe to peform blocking I/O here. + * + * <p>The syncResult argument allows you to pass information back to the method that triggered + * the sync. + */ + @Override + public void onPerformSync(Account account, Bundle extras, String authority, + ContentProviderClient provider, SyncResult syncResult) { + Log.i(TAG, "Beginning network synchronization"); + try { + final URL location = new URL(FEED_URL); + InputStream stream = null; + + try { + Log.i(TAG, "Streaming data from network: " + location); + stream = downloadUrl(location); + updateLocalFeedData(stream, syncResult); + // Makes sure that the InputStream is closed after the app is + // finished using it. + } finally { + if (stream != null) { + stream.close(); + } + } + } catch (MalformedURLException e) { + Log.e(TAG, "Feed URL is malformed", e); + syncResult.stats.numParseExceptions++; + return; + } catch (IOException e) { + Log.e(TAG, "Error reading from network: " + e.toString()); + syncResult.stats.numIoExceptions++; + return; + } catch (XmlPullParserException e) { + Log.e(TAG, "Error parsing feed: " + e.toString()); + syncResult.stats.numParseExceptions++; + return; + } catch (ParseException e) { + Log.e(TAG, "Error parsing feed: " + e.toString()); + syncResult.stats.numParseExceptions++; + return; + } catch (RemoteException e) { + Log.e(TAG, "Error updating database: " + e.toString()); + syncResult.databaseError = true; + return; + } catch (OperationApplicationException e) { + Log.e(TAG, "Error updating database: " + e.toString()); + syncResult.databaseError = true; + return; + } + Log.i(TAG, "Network synchronization complete"); + } + + /** + * Read XML from an input stream, storing it into the content provider. + * + * <p>This is where incoming data is persisted, committing the results of a sync. In order to + * minimize (expensive) disk operations, we compare incoming data with what's already in our + * database, and compute a merge. Only changes (insert/update/delete) will result in a database + * write. + * + * <p>As an additional optimization, we use a batch operation to perform all database writes at + * once. + * + * <p>Merge strategy: + * 1. Get cursor to all items in feed<br/> + * 2. For each item, check if it's in the incoming data.<br/> + * a. YES: Remove from "incoming" list. Check if data has mutated, if so, perform + * database UPDATE.<br/> + * b. NO: Schedule DELETE from database.<br/> + * (At this point, incoming database only contains missing items.)<br/> + * 3. For any items remaining in incoming list, ADD to database. + */ + public void updateLocalFeedData(final InputStream stream, final SyncResult syncResult) + throws IOException, XmlPullParserException, RemoteException, + OperationApplicationException, ParseException { + final FeedParser feedParser = new FeedParser(); + final ContentResolver contentResolver = getContext().getContentResolver(); + + Log.i(TAG, "Parsing stream as Atom feed"); + final List<FeedParser.Entry> entries = feedParser.parse(stream); + Log.i(TAG, "Parsing complete. Found " + entries.size() + " entries"); + + + ArrayList<ContentProviderOperation> batch = new ArrayList<ContentProviderOperation>(); + + // Build hash table of incoming entries + HashMap<String, FeedParser.Entry> entryMap = new HashMap<String, FeedParser.Entry>(); + for (FeedParser.Entry e : entries) { + entryMap.put(e.id, e); + } + + // Get list of all items + Log.i(TAG, "Fetching local entries for merge"); + Uri uri = FeedContract.Entry.CONTENT_URI; // Get all entries + Cursor c = contentResolver.query(uri, PROJECTION, null, null, null); + assert c != null; + Log.i(TAG, "Found " + c.getCount() + " local entries. Computing merge solution..."); + + // Find stale data + int id; + String entryId; + String title; + String link; + long published; + while (c.moveToNext()) { + syncResult.stats.numEntries++; + id = c.getInt(COLUMN_ID); + entryId = c.getString(COLUMN_ENTRY_ID); + title = c.getString(COLUMN_TITLE); + link = c.getString(COLUMN_LINK); + published = c.getLong(COLUMN_PUBLISHED); + FeedParser.Entry match = entryMap.get(entryId); + if (match != null) { + // Entry exists. Remove from entry map to prevent insert later. + entryMap.remove(entryId); + // Check to see if the entry needs to be updated + Uri existingUri = FeedContract.Entry.CONTENT_URI.buildUpon() + .appendPath(Integer.toString(id)).build(); + if ((match.title != null && !match.title.equals(title)) || + (match.link != null && !match.link.equals(link)) || + (match.published != published)) { + // Update existing record + Log.i(TAG, "Scheduling update: " + existingUri); + batch.add(ContentProviderOperation.newUpdate(existingUri) + .withValue(FeedContract.Entry.COLUMN_NAME_TITLE, title) + .withValue(FeedContract.Entry.COLUMN_NAME_LINK, link) + .withValue(FeedContract.Entry.COLUMN_NAME_PUBLISHED, published) + .build()); + syncResult.stats.numUpdates++; + } else { + Log.i(TAG, "No action: " + existingUri); + } + } else { + // Entry doesn't exist. Remove it from the database. + Uri deleteUri = FeedContract.Entry.CONTENT_URI.buildUpon() + .appendPath(Integer.toString(id)).build(); + Log.i(TAG, "Scheduling delete: " + deleteUri); + batch.add(ContentProviderOperation.newDelete(deleteUri).build()); + syncResult.stats.numDeletes++; + } + } + c.close(); + + // Add new items + for (FeedParser.Entry e : entryMap.values()) { + Log.i(TAG, "Scheduling insert: entry_id=" + e.id); + batch.add(ContentProviderOperation.newInsert(FeedContract.Entry.CONTENT_URI) + .withValue(FeedContract.Entry.COLUMN_NAME_ENTRY_ID, e.id) + .withValue(FeedContract.Entry.COLUMN_NAME_TITLE, e.title) + .withValue(FeedContract.Entry.COLUMN_NAME_LINK, e.link) + .withValue(FeedContract.Entry.COLUMN_NAME_PUBLISHED, e.published) + .build()); + syncResult.stats.numInserts++; + } + Log.i(TAG, "Merge solution ready. Applying batch update"); + mContentResolver.applyBatch(FeedContract.CONTENT_AUTHORITY, batch); + mContentResolver.notifyChange( + FeedContract.Entry.CONTENT_URI, // URI where data was modified + null, // No local observer + false); // IMPORTANT: Do not sync to network + // This sample doesn't support uploads, but if *your* code does, make sure you set + // syncToNetwork=false in the line above to prevent duplicate syncs. + } + + /** + * Given a string representation of a URL, sets up a connection and gets an input stream. + */ + private InputStream downloadUrl(final URL url) throws IOException { + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setReadTimeout(NET_READ_TIMEOUT_MILLIS /* milliseconds */); + conn.setConnectTimeout(NET_CONNECT_TIMEOUT_MILLIS /* milliseconds */); + conn.setRequestMethod("GET"); + conn.setDoInput(true); + // Starts the query + conn.connect(); + return conn.getInputStream(); + } + +} diff --git a/mobile/src/main/java/de/nordgedanken/rpicms/basicsyncadapter/SyncService.java b/mobile/src/main/java/de/nordgedanken/rpicms/basicsyncadapter/SyncService.java @@ -0,0 +1,66 @@ +package de.nordgedanken.rpicms.basicsyncadapter; + +/** + * Created by frank on 13.10.14. + */ +import android.app.Service; +import android.content.Intent; +import android.os.IBinder; +import android.util.Log; +public class SyncService extends Service { + + + /** Service to handle sync requests. + * + * <p>This service is invoked in response to Intents with action android.content.SyncAdapter, and + * returns a Binder connection to SyncAdapter. + * + * <p>For performance, only one sync adapter will be initialized within this application's context. + * + * <p>Note: The SyncService itself is not notified when a new sync occurs. It's role is to + * manage the lifecycle of our {@link SyncAdapter} and provide a handle to said SyncAdapter to the + * OS on request. + */ + + private static final String TAG = "SyncService"; + + private static final Object sSyncAdapterLock = new Object(); + private static SyncAdapter sSyncAdapter = null; + + /** + * Thread-safe constructor, creates static {@link SyncAdapter} instance. + */ + @Override + public void onCreate() { + super.onCreate(); + Log.i(TAG, "Service created"); + synchronized (sSyncAdapterLock) { + if (sSyncAdapter == null) { + sSyncAdapter = new SyncAdapter(getApplicationContext(), true); + } + } + } + + @Override + /** + * Logging-only destructor. + */ + public void onDestroy() { + super.onDestroy(); + Log.i(TAG, "Service destroyed"); + } + + /** + * Return Binder handle for IPC communication with {@link SyncAdapter}. + * + * <p>New sync requests will be sent directly to the SyncAdapter using this channel. + * + * @param intent Calling intent + * @return Binder handle for {@link SyncAdapter} + */ + @Override + public IBinder onBind(Intent intent) { + return sSyncAdapter.getSyncAdapterBinder(); + } + +} diff --git a/mobile/src/main/java/de/nordgedanken/rpicms/basicsyncadapter/SyncUtils.java b/mobile/src/main/java/de/nordgedanken/rpicms/basicsyncadapter/SyncUtils.java @@ -0,0 +1,88 @@ +package de.nordgedanken.rpicms.basicsyncadapter; + +/** + * Created by frank on 13.10.14. + */ +import android.accounts.Account; +import android.accounts.AccountManager; +import android.annotation.TargetApi; +import android.content.ContentResolver; +import android.content.Context; +import android.os.Build; +import android.os.Bundle; +import android.preference.PreferenceManager; + +import de.nordgedanken.rpicms.common.accounts.GenericAccountService; +import de.nordgedanken.rpicms.basicsyncadapter.provider.FeedContract; +public class SyncUtils { + + + /** + * Static helper methods for working with the sync framework. + */ + private static final long SYNC_FREQUENCY = 60 * 60; // 1 hour (in seconds) + private static final String CONTENT_AUTHORITY = FeedContract.CONTENT_AUTHORITY; + private static final String PREF_SETUP_COMPLETE = "setup_complete"; + // Value below must match the account type specified in res/xml/syncadapter.xml + public static final String ACCOUNT_TYPE = "de.nordgedanken.rpicms.basicsyncadapter.account"; + + /** + * Create an entry for this application in the system account list, if it isn't already there. + * + * @param context Context + */ + @TargetApi(Build.VERSION_CODES.FROYO) + public static void CreateSyncAccount(Context context) { + boolean newAccount = false; + boolean setupComplete = PreferenceManager + .getDefaultSharedPreferences(context).getBoolean(PREF_SETUP_COMPLETE, false); + + // Create account, if it's missing. (Either first run, or user has deleted account.) + Account account = GenericAccountService.GetAccount(ACCOUNT_TYPE); + AccountManager accountManager = + (AccountManager) context.getSystemService(Context.ACCOUNT_SERVICE); + if (accountManager.addAccountExplicitly(account, null, null)) { + // Inform the system that this account supports sync + ContentResolver.setIsSyncable(account, CONTENT_AUTHORITY, 1); + // Inform the system that this account is eligible for auto sync when the network is up + ContentResolver.setSyncAutomatically(account, CONTENT_AUTHORITY, true); + // Recommend a schedule for automatic synchronization. The system may modify this based + // on other scheduled syncs and network utilization. + ContentResolver.addPeriodicSync( + account, CONTENT_AUTHORITY, new Bundle(),SYNC_FREQUENCY); + newAccount = true; + } + + // Schedule an initial sync if we detect problems with either our account or our local + // data has been deleted. (Note that it's possible to clear app data WITHOUT affecting + // the account list, so wee need to check both.) + if (newAccount || !setupComplete) { + TriggerRefresh(); + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putBoolean(PREF_SETUP_COMPLETE, true).commit(); + } + } + + /** + * Helper method to trigger an immediate sync ("refresh"). + * + * <p>This should only be used when we need to preempt the normal sync schedule. Typically, this + * means the user has pressed the "refresh" button. + * + * Note that SYNC_EXTRAS_MANUAL will cause an immediate sync, without any optimization to + * preserve battery life. If you know new data is available (perhaps via a GCM notification), + * but the user is not actively waiting for that data, you should omit this flag; this will give + * the OS additional freedom in scheduling your sync request. + */ + public static void TriggerRefresh() { + Bundle b = new Bundle(); + // Disable sync backoff and ignore sync preferences. In other words...perform sync NOW! + b.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true); + b.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true); + ContentResolver.requestSync( + GenericAccountService.GetAccount(ACCOUNT_TYPE), // Sync account + FeedContract.CONTENT_AUTHORITY, // Content authority + b); // Extras + } + +} diff --git a/mobile/src/main/java/de/nordgedanken/rpicms/basicsyncadapter/net/FeedParser.java b/mobile/src/main/java/de/nordgedanken/rpicms/basicsyncadapter/net/FeedParser.java @@ -0,0 +1,267 @@ +package de.nordgedanken.rpicms.basicsyncadapter.net; + +/** + * Created by frank on 13.10.14. + */ +import android.text.format.Time; +import android.util.Xml; + +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.IOException; +import java.io.InputStream; +import java.text.ParseException; +import java.util.ArrayList; +import java.util.List; +public class FeedParser { + + + /** + * This class parses generic Atom feeds. + * + * <p>Given an InputStream representation of a feed, it returns a List of entries, + * where each list element represents a single entry (post) in the XML feed. + * + * <p>An example of an Atom feed can be found at: + * http://en.wikipedia.org/w/index.php?title=Atom_(standard)&oldid=560239173#Example_of_an_Atom_1.0_feed + */ + + // Constants indicting XML element names that we're interested in + private static final int TAG_ID = 1; + private static final int TAG_TITLE = 2; + private static final int TAG_PUBLISHED = 3; + private static final int TAG_LINK = 4; + + // We don't use XML namespaces + private static final String ns = null; + + /** Parse an Atom feed, returning a collection of Entry objects. + * + * @param in Atom feed, as a stream. + * @return List of {@link de.nordgedanken.rpicms.basicsyncadapter.net.FeedParser.Entry} objects. + * @throws org.xmlpull.v1.XmlPullParserException on error parsing feed. + * @throws java.io.IOException on I/O error. + */ + public List<Entry> parse(InputStream in) + throws XmlPullParserException, IOException, ParseException { + try { + XmlPullParser parser = Xml.newPullParser(); + parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false); + parser.setInput(in, null); + parser.nextTag(); + return readFeed(parser); + } finally { + in.close(); + } + } + + /** + * Decode a feed attached to an XmlPullParser. + * + * @param parser Incoming XMl + * @return List of {@link de.nordgedanken.rpicms.basicsyncadapter.net.FeedParser.Entry} objects. + * @throws org.xmlpull.v1.XmlPullParserException on error parsing feed. + * @throws java.io.IOException on I/O error. + */ + private List<Entry> readFeed(XmlPullParser parser) + throws XmlPullParserException, IOException, ParseException { + List<Entry> entries = new ArrayList<Entry>(); + + // Search for <feed> tags. These wrap the beginning/end of an Atom document. + // + // Example: + // <?xml version="1.0" encoding="utf-8"?> + // <feed xmlns="http://www.w3.org/2005/Atom"> + // ... + // </feed> + parser.require(XmlPullParser.START_TAG, ns, "feed"); + while (parser.next() != XmlPullParser.END_TAG) { + if (parser.getEventType() != XmlPullParser.START_TAG) { + continue; + } + String name = parser.getName(); + // Starts by looking for the <entry> tag. This tag repeates inside of <feed> for each + // article in the feed. + // + // Example: + // <entry> + // <title>Article title</title> + // <link rel="alternate" type="text/html" href="http://example.com/article/1234"/> + // <link rel="edit" href="http://example.com/admin/article/1234"/> + // <id>urn:uuid:218AC159-7F68-4CC6-873F-22AE6017390D</id> + // <published>2003-06-27T12:00:00Z</published> + // <updated>2003-06-28T12:00:00Z</updated> + // <summary>Article summary goes here.</summary> + // <author> + // <name>Rick Deckard</name> + // <email>deckard@example.com</email> + // </author> + // </entry> + if (name.equals("entry")) { + entries.add(readEntry(parser)); + } else { + skip(parser); + } + } + return entries; + } + + /** + * Parses the contents of an entry. If it encounters a title, summary, or link tag, hands them + * off to their respective "read" methods for processing. Otherwise, skips the tag. + */ + private Entry readEntry(XmlPullParser parser) + throws XmlPullParserException, IOException, ParseException { + parser.require(XmlPullParser.START_TAG, ns, "entry"); + String id = null; + String title = null; + String link = null; + long publishedOn = 0; + + while (parser.next() != XmlPullParser.END_TAG) { + if (parser.getEventType() != XmlPullParser.START_TAG) { + continue; + } + String name = parser.getName(); + if (name.equals("id")){ + // Example: <id>urn:uuid:218AC159-7F68-4CC6-873F-22AE6017390D</id> + id = readTag(parser, TAG_ID); + } else if (name.equals("title")) { + // Example: <title>Article title</title> + title = readTag(parser, TAG_TITLE); + } else if (name.equals("link")) { + // Example: <link rel="alternate" type="text/html" href="http://example.com/article/1234"/> + // + // Multiple link types can be included. readAlternateLink() will only return + // non-null when reading an "alternate"-type link. Ignore other responses. + String tempLink = readTag(parser, TAG_LINK); + if (tempLink != null) { + link = tempLink; + } + } else if (name.equals("published")) { + // Example: <published>2003-06-27T12:00:00Z</published> + Time t = new Time(); + t.parse3339(readTag(parser, TAG_PUBLISHED)); + publishedOn = t.toMillis(false); + } else { + skip(parser); + } + } + return new Entry(id, title, link, publishedOn); + } + + /** + * Process an incoming tag and read the selected value from it. + */ + private String readTag(XmlPullParser parser, int tagType) + throws IOException, XmlPullParserException { + String tag = null; + String endTag = null; + + switch (tagType) { + case TAG_ID: + return readBasicTag(parser, "id"); + case TAG_TITLE: + return readBasicTag(parser, "title"); + case TAG_PUBLISHED: + return readBasicTag(parser, "published"); + case TAG_LINK: + return readAlternateLink(parser); + default: + throw new IllegalArgumentException("Unknown tag type: " + tagType); + } + } + + /** + * Reads the body of a basic XML tag, which is guaranteed not to contain any nested elements. + * + * <p>You probably want to call readTag(). + * + * @param parser Current parser object + * @param tag XML element tag name to parse + * @return Body of the specified tag + * @throws java.io.IOException + * @throws org.xmlpull.v1.XmlPullParserException + */ + private String readBasicTag(XmlPullParser parser, String tag) + throws IOException, XmlPullParserException { + parser.require(XmlPullParser.START_TAG, ns, tag); + String result = readText(parser); + parser.require(XmlPullParser.END_TAG, ns, tag); + return result; + } + + /** + * Processes link tags in the feed. + */ + private String readAlternateLink(XmlPullParser parser) + throws IOException, XmlPullParserException { + String link = null; + parser.require(XmlPullParser.START_TAG, ns, "link"); + String tag = parser.getName(); + String relType = parser.getAttributeValue(null, "rel"); + if (relType.equals("alternate")) { + link = parser.getAttributeValue(null, "href"); + } + while (true) { + if (parser.nextTag() == XmlPullParser.END_TAG) break; + // Intentionally break; consumes any remaining sub-tags. + } + return link; + } + + /** + * For the tags title and summary, extracts their text values. + */ + private String readText(XmlPullParser parser) throws IOException, XmlPullParserException { + String result = null; + if (parser.next() == XmlPullParser.TEXT) { + result = parser.getText(); + parser.nextTag(); + } + return result; + } + + /** + * Skips tags the parser isn't interested in. Uses depth to handle nested tags. i.e., + * if the next tag after a START_TAG isn't a matching END_TAG, it keeps going until it + * finds the matching END_TAG (as indicated by the value of "depth" being 0). + */ + private void skip(XmlPullParser parser) throws XmlPullParserException, IOException { + if (parser.getEventType() != XmlPullParser.START_TAG) { + throw new IllegalStateException(); + } + int depth = 1; + while (depth != 0) { + switch (parser.next()) { + case XmlPullParser.END_TAG: + depth--; + break; + case XmlPullParser.START_TAG: + depth++; + break; + } + } + } + + /** + * This class represents a single entry (post) in the XML feed. + * + * <p>It includes the data members "title," "link," and "summary." + */ + public static class Entry { + public final String id; + public final String title; + public final String link; + public final long published; + + Entry(String id, String title, String link, long published) { + this.id = id; + this.title = title; + this.link = link; + this.published = published; + } + } + +} diff --git a/mobile/src/main/java/de/nordgedanken/rpicms/basicsyncadapter/provider/FeedContract.java b/mobile/src/main/java/de/nordgedanken/rpicms/basicsyncadapter/provider/FeedContract.java @@ -0,0 +1,80 @@ +package de.nordgedanken.rpicms.basicsyncadapter.provider; + +/** + * Created by frank on 13.10.14. + */ +import android.content.ContentResolver; +import android.net.Uri; +import android.provider.BaseColumns; +public class FeedContract { + + + /** + * Field and table name constants for + * {@link de.nordgedanken.rpicms.basicsyncadapter.provider.FeedProvider}. + */ + private FeedContract() { + } + + /** + * Content provider authority. + */ + public static final String CONTENT_AUTHORITY = "de.nordgedanken.rpicms.basicsyncadapter"; + + /** + * Base URI. (content://com.example.android.basicsyncadapter) + */ + public static final Uri BASE_CONTENT_URI = Uri.parse("content://" + CONTENT_AUTHORITY); + + /** + * Path component for "entry"-type resources.. + */ + private static final String PATH_ENTRIES = "entries"; + + /** + * Columns supported by "entries" records. + */ + public static class Entry implements BaseColumns { + /** + * MIME type for lists of entries. + */ + public static final String CONTENT_TYPE = + ContentResolver.CURSOR_DIR_BASE_TYPE + "/vnd.basicsyncadapter.entries"; + /** + * MIME type for individual entries. + */ + public static final String CONTENT_ITEM_TYPE = + ContentResolver.CURSOR_ITEM_BASE_TYPE + "/vnd.basicsyncadapter.entry"; + + /** + * Fully qualified URI for "entry" resources. + */ + public static final Uri CONTENT_URI = + BASE_CONTENT_URI.buildUpon().appendPath(PATH_ENTRIES).build(); + + /** + * Table name where records are stored for "entry" resources. + */ + public static final String TABLE_NAME = "entry"; + /** + * Atom ID. (Note: Not to be confused with the database primary key, which is _ID. + */ + public static final String COLUMN_NAME_ENTRY_ID = "entry_id"; + /** + * Article title + */ + public static final String COLUMN_NAME_TITLE = "title"; + /** + * Article hyperlink. Corresponds to the rel="alternate" link in the + * Atom spec. + */ + public static final String COLUMN_NAME_LINK = "link"; + /** + * Date article was published. + */ + public static final String COLUMN_NAME_PUBLISHED = "published"; + } + + + +} diff --git a/mobile/src/main/java/de/nordgedanken/rpicms/basicsyncadapter/provider/FeedProvider.java b/mobile/src/main/java/de/nordgedanken/rpicms/basicsyncadapter/provider/FeedProvider.java @@ -0,0 +1,242 @@ +package de.nordgedanken.rpicms.basicsyncadapter.provider; + +/** + * Created by frank on 13.10.14. + */ +import android.content.ContentProvider; +import android.content.ContentValues; +import android.content.Context; +import android.content.UriMatcher; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.net.Uri; + +import de.nordgedanken.rpicms.common.db.SelectionBuilder; + +public class FeedProvider extends ContentProvider { + + + FeedDatabase mDatabaseHelper; + + /** + * Content authority for this provider. + */ + private static final String AUTHORITY = FeedContract.CONTENT_AUTHORITY; + + // The constants below represent individual URI routes, as IDs. Every URI pattern recognized by + // this ContentProvider is defined using sUriMatcher.addURI(), and associated with one of these + // IDs. + // + // When a incoming URI is run through sUriMatcher, it will be tested against the defined + // URI patterns, and the corresponding route ID will be returned. + /** + * URI ID for route: /entries + */ + public static final int ROUTE_ENTRIES = 1; + + /** + * URI ID for route: /entries/{ID} + */ + public static final int ROUTE_ENTRIES_ID = 2; + + /** + * UriMatcher, used to decode incoming URIs. + */ + private static final UriMatcher sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH); + static { + sUriMatcher.addURI(AUTHORITY, "entries", ROUTE_ENTRIES); + sUriMatcher.addURI(AUTHORITY, "entries/*", ROUTE_ENTRIES_ID); + } + + @Override + public boolean onCreate() { + mDatabaseHelper = new FeedDatabase(getContext()); + return true; + } + + /** + * Determine the mime type for entries returned by a given URI. + */ + @Override + public String getType(Uri uri) { + final int match = sUriMatcher.match(uri); + switch (match) { + case ROUTE_ENTRIES: + return FeedContract.Entry.CONTENT_TYPE; + case ROUTE_ENTRIES_ID: + return FeedContract.Entry.CONTENT_ITEM_TYPE; + default: + throw new UnsupportedOperationException("Unknown uri: " + uri); + } + } + + /** + * Perform a database query by URI. + * + * <p>Currently supports returning all entries (/entries) and individual entries by ID + * (/entries/{ID}). + */ + @Override + public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, + String sortOrder) { + SQLiteDatabase db = mDatabaseHelper.getReadableDatabase(); + SelectionBuilder builder = new SelectionBuilder(); + int uriMatch = sUriMatcher.match(uri); + switch (uriMatch) { + case ROUTE_ENTRIES_ID: + // Return a single entry, by ID. + String id = uri.getLastPathSegment(); + builder.where(FeedContract.Entry._ID + "=?", id); + case ROUTE_ENTRIES: + // Return all known entries. + builder.table(FeedContract.Entry.TABLE_NAME) + .where(selection, selectionArgs); + Cursor c = builder.query(db, projection, sortOrder); + // Note: Notification URI must be manually set here for loaders to correctly + // register ContentObservers. + Context ctx = getContext(); + assert ctx != null; + c.setNotificationUri(ctx.getContentResolver(), uri); + return c; + default: + throw new UnsupportedOperationException("Unknown uri: " + uri); + } + } + + /** + * Insert a new entry into the database. + */ + @Override + public Uri insert(Uri uri, ContentValues values) { + final SQLiteDatabase db = mDatabaseHelper.getWritableDatabase(); + assert db != null; + final int match = sUriMatcher.match(uri); + Uri result; + switch (match) { + case ROUTE_ENTRIES: + long id = db.insertOrThrow(FeedContract.Entry.TABLE_NAME, null, values); + result = Uri.parse(FeedContract.Entry.CONTENT_URI + "/" + id); + break; + case ROUTE_ENTRIES_ID: + throw new UnsupportedOperationException("Insert not supported on URI: " + uri); + default: + throw new UnsupportedOperationException("Unknown uri: " + uri); + } + // Send broadcast to registered ContentObservers, to refresh UI. + Context ctx = getContext(); + assert ctx != null; + ctx.getContentResolver().notifyChange(uri, null, false); + return result; + } + + /** + * Delete an entry by database by URI. + */ + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) { + SelectionBuilder builder = new SelectionBuilder(); + final SQLiteDatabase db = mDatabaseHelper.getWritableDatabase(); + final int match = sUriMatcher.match(uri); + int count; + switch (match) { + case ROUTE_ENTRIES: + count = builder.table(FeedContract.Entry.TABLE_NAME) + .where(selection, selectionArgs) + .delete(db); + break; + case ROUTE_ENTRIES_ID: + String id = uri.getLastPathSegment(); + count = builder.table(FeedContract.Entry.TABLE_NAME) + .where(FeedContract.Entry._ID + "=?", id) + .where(selection, selectionArgs) + .delete(db); + break; + default: + throw new UnsupportedOperationException("Unknown uri: " + uri); + } + // Send broadcast to registered ContentObservers, to refresh UI. + Context ctx = getContext(); + assert ctx != null; + ctx.getContentResolver().notifyChange(uri, null, false); + return count; + } + + /** + * Update an etry in the database by URI. + */ + @Override + public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { + SelectionBuilder builder = new SelectionBuilder(); + final SQLiteDatabase db = mDatabaseHelper.getWritableDatabase(); + final int match = sUriMatcher.match(uri); + int count; + switch (match) { + case ROUTE_ENTRIES: + count = builder.table(FeedContract.Entry.TABLE_NAME) + .where(selection, selectionArgs) + .update(db, values); + break; + case ROUTE_ENTRIES_ID: + String id = uri.getLastPathSegment(); + count = builder.table(FeedContract.Entry.TABLE_NAME) + .where(FeedContract.Entry._ID + "=?", id) + .where(selection, selectionArgs) + .update(db, values); + break; + default: + throw new UnsupportedOperationException("Unknown uri: " + uri); + } + Context ctx = getContext(); + assert ctx != null; + ctx.getContentResolver().notifyChange(uri, null, false); + return count; + } + + /** + * SQLite backend for @{link FeedProvider}. + * + * Provides access to an disk-backed, SQLite datastore which is utilized by FeedProvider. This + * database should never be accessed by other parts of the application directly. + */ + static class FeedDatabase extends SQLiteOpenHelper { + /** Schema version. */ + public static final int DATABASE_VERSION = 1; + /** Filename for SQLite file. */ + public static final String DATABASE_NAME = "feed.db"; + + private static final String TYPE_TEXT = " TEXT"; + private static final String TYPE_INTEGER = " INTEGER"; + private static final String COMMA_SEP = ","; + /** SQL statement to create "entry" table. */ + private static final String SQL_CREATE_ENTRIES = + "CREATE TABLE " + FeedContract.Entry.TABLE_NAME + " (" + + FeedContract.Entry._ID + " INTEGER PRIMARY KEY," + + FeedContract.Entry.COLUMN_NAME_ENTRY_ID + TYPE_TEXT + COMMA_SEP + + FeedContract.Entry.COLUMN_NAME_TITLE + TYPE_TEXT + COMMA_SEP + + FeedContract.Entry.COLUMN_NAME_LINK + TYPE_TEXT + COMMA_SEP + + FeedContract.Entry.COLUMN_NAME_PUBLISHED + TYPE_INTEGER + ")"; + + /** SQL statement to drop "entry" table. */ + private static final String SQL_DELETE_ENTRIES = + "DROP TABLE IF EXISTS " + FeedContract.Entry.TABLE_NAME; + + public FeedDatabase(Context context) { + super(context, DATABASE_NAME, null, DATABASE_VERSION); + } + + @Override + public void onCreate(SQLiteDatabase db) { + db.execSQL(SQL_CREATE_ENTRIES); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + // This database is only a cache for online data, so its upgrade policy is + // to simply to discard the data and start over + db.execSQL(SQL_DELETE_ENTRIES); + onCreate(db); + } + } + +} diff --git a/mobile/src/main/java/de/nordgedanken/rpicms/common/accounts/GenericAccountService.java b/mobile/src/main/java/de/nordgedanken/rpicms/common/accounts/GenericAccountService.java @@ -0,0 +1,117 @@ +package de.nordgedanken.rpicms.common.accounts; + +/** + * Created by frank on 13.10.14. + */ +import android.accounts.AbstractAccountAuthenticator; +import android.accounts.Account; +import android.accounts.AccountAuthenticatorResponse; +import android.accounts.NetworkErrorException; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.os.IBinder; +import android.util.Log; +public class GenericAccountService extends Service { + + + + private static final String TAG = "GenericAccountService"; + public static final String ACCOUNT_NAME = "Account"; + private Authenticator mAuthenticator; + + /** + * Obtain a handle to the {@link android.accounts.Account} used for sync in this application. + * + * <p>It is important that the accountType specified here matches the value in your sync adapter + * configuration XML file for android.accounts.AccountAuthenticator (often saved in + * res/xml/syncadapter.xml). If this is not set correctly, you'll receive an error indicating + * that "caller uid XXXXX is different than the authenticator's uid". + * + * @param accountType AccountType defined in the configuration XML file for + * android.accounts.AccountAuthenticator (e.g. res/xml/syncadapter.xml). + * @return Handle to application's account (not guaranteed to resolve unless CreateSyncAccount() + * has been called) + */ + public static Account GetAccount(String accountType) { + // Note: Normally the account name is set to the user's identity (username or email + // address). However, since we aren't actually using any user accounts, it makes more sense + // to use a generic string in this case. + // + // This string should *not* be localized. If the user switches locale, we would not be + // able to locate the old account, and may erroneously register multiple accounts. + final String accountName = ACCOUNT_NAME; + return new Account(accountName, accountType); + } + + @Override + public void onCreate() { + Log.i(TAG, "Service created"); + mAuthenticator = new Authenticator(this); + } + + @Override + public void onDestroy() { + Log.i(TAG, "Service destroyed"); + } + + @Override + public IBinder onBind(Intent intent) { + return mAuthenticator.getIBinder(); + } + + public class Authenticator extends AbstractAccountAuthenticator { + public Authenticator(Context context) { + super(context); + } + + @Override + public Bundle editProperties(AccountAuthenticatorResponse accountAuthenticatorResponse, + String s) { + throw new UnsupportedOperationException(); + } + + @Override + public Bundle addAccount(AccountAuthenticatorResponse accountAuthenticatorResponse, + String s, String s2, String[] strings, Bundle bundle) + throws NetworkErrorException { + return null; + } + + @Override + public Bundle confirmCredentials(AccountAuthenticatorResponse accountAuthenticatorResponse, + Account account, Bundle bundle) + throws NetworkErrorException { + return null; + } + + @Override + public Bundle getAuthToken(AccountAuthenticatorResponse accountAuthenticatorResponse, + Account account, String s, Bundle bundle) + throws NetworkErrorException { + throw new UnsupportedOperationException(); + } + + @Override + public String getAuthTokenLabel(String s) { + throw new UnsupportedOperationException(); + } + + @Override + public Bundle updateCredentials(AccountAuthenticatorResponse accountAuthenticatorResponse, + Account account, String s, Bundle bundle) + throws NetworkErrorException { + throw new UnsupportedOperationException(); + } + + @Override + public Bundle hasFeatures(AccountAuthenticatorResponse accountAuthenticatorResponse, + Account account, String[] strings) + throws NetworkErrorException { + throw new UnsupportedOperationException(); + } + } + + +} diff --git a/mobile/src/main/java/de/nordgedanken/rpicms/common/db/SelectionBuilder.java b/mobile/src/main/java/de/nordgedanken/rpicms/common/db/SelectionBuilder.java @@ -0,0 +1,339 @@ +package de.nordgedanken.rpicms.common.db; + +/** + * Created by frank on 14.10.14. + */ +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.text.TextUtils; +import android.util.Log; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +public class SelectionBuilder { + + /** + * Helper for building selection clauses for {@link SQLiteDatabase}. + * + * <p>This class provides a convenient frontend for working with SQL. Instead of composing statements + * manually using string concatenation, method calls are used to construct the statement one + * clause at a time. These methods can be chained together. + * + * <p>If multiple where() statements are provided, they're combined using {@code AND}. + * + * <p>Example: + * + * <pre> + * SelectionBuilder builder = new SelectionBuilder(); + * Cursor c = builder.table(FeedContract.Entry.TABLE_NAME) // String TABLE_NAME = "entry" + * .where(FeedContract.Entry._ID + "=?", id); // String _ID = "_ID" + * .query(db, projection, sortOrder) + * + * </pre> + * + * <p>In this example, the table name and filters ({@code WHERE} clauses) are both explicitly + * specified via method call. SelectionBuilder takes care of issuing a "query" command to the + * database, and returns the resulting {@link Cursor} object. + * + * <p>Inner {@code JOIN}s can be accomplished using the mapToTable() function. The map() function + * can be used to create new columns based on arbitrary (SQL-based) criteria. In advanced usage, + * entire subqueries can be passed into the map() function. + * + * <p>Advanced example: + * + * <pre> + * // String SESSIONS_JOIN_BLOCKS_ROOMS = "sessions " + * // + "LEFT OUTER JOIN blocks ON sessions.block_id=blocks.block_id " + * // + "LEFT OUTER JOIN rooms ON sessions.room_id=rooms.room_id"; + * + * // String Subquery.BLOCK_NUM_STARRED_SESSIONS = + * // "(SELECT COUNT(1) FROM " + * // + Tables.SESSIONS + " WHERE " + Qualified.SESSIONS_BLOCK_ID + "=" + * // + Qualified.BLOCKS_BLOCK_ID + " AND " + Qualified.SESSIONS_STARRED + "=1)"; + * + * String Subqery.BLOCK_SESSIONS_COUNT = + * Cursor c = builder.table(Tables.SESSIONS_JOIN_BLOCKS_ROOMS) + * .map(Blocks.NUM_STARRED_SESSIONS, Subquery.BLOCK_NUM_STARRED_SESSIONS) + * .mapToTable(Sessions._ID, Tables.SESSIONS) + * .mapToTable(Sessions.SESSION_ID, Tables.SESSIONS) + * .mapToTable(Sessions.BLOCK_ID, Tables.SESSIONS) + * .mapToTable(Sessions.ROOM_ID, Tables.SESSIONS) + * .where(Qualified.SESSIONS_BLOCK_ID + "=?", blockId); + * </pre> + * + * <p>In this example, we have two different types of {@code JOIN}s: a left outer join using a + * modified table name (since this class doesn't directly support these), and an inner join using + * the mapToTable() function. The map() function is used to insert a count based on specific + * criteria, executed as a sub-query. + * + * This class is <em>not</em> thread safe. + */ + private static final String TAG = "basicsyncadapter"; + + private String mTable = null; + private Map<String, String> mProjectionMap = new HashMap<String, String>(); + private StringBuilder mSelection = new StringBuilder(); + private ArrayList<String> mSelectionArgs = new ArrayList<String>(); + + /** + * Reset any internal state, allowing this builder to be recycled. + * + * <p>Calling this method is more efficient than creating a new SelectionBuilder object. + * + * @return Fluent interface + */ + public SelectionBuilder reset() { + mTable = null; + mSelection.setLength(0); + mSelectionArgs.clear(); + return this; + } + + /** + * Append the given selection clause to the internal state. Each clause is + * surrounded with parenthesis and combined using {@code AND}. + * + * <p>In the most basic usage, simply provide a selection in SQL {@code WHERE} statement format. + * + * <p>Example: + * + * <pre> + * .where("blog_posts.category = 'PROGRAMMING'); + * </pre> + * + * <p>User input should never be directly supplied as as part of the selection statement. + * Instead, use positional parameters in your selection statement, then pass the user input + * in via the selectionArgs parameter. This prevents SQL escape characters in user input from + * causing unwanted side effects. (Failure to follow this convention may have security + * implications.) + * + * <p>Positional parameters are specified using the '?' character. + * + * <p>Example: + * <pre> + * .where("blog_posts.title contains ?, userSearchString); + * </pre> + * + * @param selection SQL where statement + * @param selectionArgs Values to substitute for positional parameters ('?' characters in + * {@code selection} statement. Will be automatically escaped. + * @return Fluent interface + */ + public SelectionBuilder where(String selection, String... selectionArgs) { + if (TextUtils.isEmpty(selection)) { + if (selectionArgs != null && selectionArgs.length > 0) { + throw new IllegalArgumentException( + "Valid selection required when including arguments="); + } + + // Shortcut when clause is empty + return this; + } + + if (mSelection.length() > 0) { + mSelection.append(" AND "); + } + + mSelection.append("(").append(selection).append(")"); + if (selectionArgs != null) { + Collections.addAll(mSelectionArgs, selectionArgs); + } + + return this; + } + + /** + * Table name to use for SQL {@code FROM} statement. + * + * <p>This method may only be called once. If multiple tables are required, concatenate them + * in SQL-format (typically comma-separated). + * + * <p>If you need to do advanced {@code JOIN}s, they can also be specified here. + * + * See also: mapToTable() + * + * @param table Table name + * @return Fluent interface + */ + public SelectionBuilder table(String table) { + mTable = table; + return this; + } + + /** + * Verify that a table name has been supplied using table(). + * + * @throws IllegalStateException if table not set + */ + private void assertTable() { + if (mTable == null) { + throw new IllegalStateException("Table not specified"); + } + } + + /** + * Perform an inner join. + * + * <p>Map columns from a secondary table onto the current result set. References to the column + * specified in {@code column} will be replaced with {@code table.column} in the SQL {@code + * SELECT} clause. + * + * @param column Column name to join on. Must be the same in both tables. + * @param table Secondary table to join. + * @return Fluent interface + */ + public SelectionBuilder mapToTable(String column, String table) { + mProjectionMap.put(column, table + "." + column); + return this; + } + + /** + * Create a new column based on custom criteria (such as aggregate functions). + * + * <p>This adds a new column to the result set, based upon custom criteria in SQL format. This + * is equivalent to the SQL statement: {@code SELECT toClause AS fromColumn} + * + * <p>This method is useful for executing SQL sub-queries. + * + * @param fromColumn Name of column for mapping + * @param toClause SQL string representing data to be mapped + * @return Fluent interface + */ + public SelectionBuilder map(String fromColumn, String toClause) { + mProjectionMap.put(fromColumn, toClause + " AS " + fromColumn); + return this; + } + + /** + * Return selection string based on current internal state. + * + * @return Current selection as a SQL statement + * @see #getSelectionArgs() + */ + public String getSelection() { + return mSelection.toString(); + + } + + /** + * Return selection arguments based on current internal state. + * + * @see #getSelection() + */ + public String[] getSelectionArgs() { + return mSelectionArgs.toArray(new String[mSelectionArgs.size()]); + } + + /** + * Process user-supplied projection (column list). + * + * <p>In cases where a column is mapped to another data source (either another table, or an + * SQL sub-query), the column name will be replaced with a more specific, SQL-compatible + * representation. + * + * Assumes that incoming columns are non-null. + * + * <p>See also: map(), mapToTable() + * + * @param columns User supplied projection (column list). + */ + private void mapColumns(String[] columns) { + for (int i = 0; i < columns.length; i++) { + final String target = mProjectionMap.get(columns[i]); + if (target != null) { + columns[i] = target; + } + } + } + + /** + * Return a description of this builder's state. Does NOT output SQL. + * + * @return Human-readable internal state + */ + @Override + public String toString() { + return "SelectionBuilder[table=" + mTable + ", selection=" + getSelection() + + ", selectionArgs=" + Arrays.toString(getSelectionArgs()) + "]"; + } + + /** + * Execute query (SQL {@code SELECT}) against specified database. + * + * <p>Using a null projection (column list) is not supported. + * + * @param db Database to query. + * @param columns Database projection (column list) to return, must be non-NULL. + * @param orderBy How to order the rows, formatted as an SQL ORDER BY clause (excluding the + * ORDER BY itself). Passing null will use the default sort order, which may be + * unordered. + * @return A {@link Cursor} object, which is positioned before the first entry. Note that + * {@link Cursor}s are not synchronized, see the documentation for more details. + */ + public Cursor query(SQLiteDatabase db, String[] columns, String orderBy) { + return query(db, columns, null, null, orderBy, null); + } + + /** + * Execute query ({@code SELECT}) against database. + * + * <p>Using a null projection (column list) is not supported. + * + * @param db Database to query. + * @param columns Database projection (column list) to return, must be non-null. + * @param groupBy A filter declaring how to group rows, formatted as an SQL GROUP BY clause + * (excluding the GROUP BY itself). Passing null will cause the rows to not be + * grouped. + * @param having A filter declare which row groups to include in the cursor, if row grouping is + * being used, formatted as an SQL HAVING clause (excluding the HAVING itself). + * Passing null will cause all row groups to be included, and is required when + * row grouping is not being used. + * @param orderBy How to order the rows, formatted as an SQL ORDER BY clause (excluding the + * ORDER BY itself). Passing null will use the default sort order, which may be + * unordered. + * @param limit Limits the number of rows returned by the query, formatted as LIMIT clause. + * Passing null denotes no LIMIT clause. + * @return A {@link Cursor} object, which is positioned before the first entry. Note that + * {@link Cursor}s are not synchronized, see the documentation for more details. + */ + public Cursor query(SQLiteDatabase db, String[] columns, String groupBy, + String having, String orderBy, String limit) { + assertTable(); + if (columns != null) mapColumns(columns); + Log.v(TAG, "query(columns=" + Arrays.toString(columns) + ") " + this); + return db.query(mTable, columns, getSelection(), getSelectionArgs(), groupBy, having, + orderBy, limit); + } + + /** + * Execute an {@code UPDATE} against database. + * + * @param db Database to query. + * @param values A map from column names to new column values. null is a valid value that will + * be translated to NULL + * @return The number of rows affected. + */ + public int update(SQLiteDatabase db, ContentValues values) { + assertTable(); + Log.v(TAG, "update() " + this); + return db.update(mTable, values, getSelection(), getSelectionArgs()); + } + + /** + * Execute {@code DELETE} against database. + * + * @param db Database to query. + * @return The number of rows affected. + */ + public int delete(SQLiteDatabase db) { + assertTable(); + Log.v(TAG, "delete() " + this); + return db.delete(mTable, getSelection(), getSelectionArgs()); + } + +} diff --git a/mobile/src/main/java/de/nordgedanken/rpicms/common/logger/Log.java b/mobile/src/main/java/de/nordgedanken/rpicms/common/logger/Log.java @@ -0,0 +1,217 @@ +package de.nordgedanken.rpicms.common.logger; + +/** + * Created by frank on 14.10.14. + */ +public class Log { + // Grabbing the native values from Android's native logging facilities, + // to make for easy migration and interop. + public static final int NONE = -1; + public static final int VERBOSE = android.util.Log.VERBOSE; + public static final int DEBUG = android.util.Log.DEBUG; + public static final int INFO = android.util.Log.INFO; + public static final int WARN = android.util.Log.WARN; + public static final int ERROR = android.util.Log.ERROR; + public static final int ASSERT = android.util.Log.ASSERT; + + // Stores the beginning of the LogNode topology. + private static LogNode mLogNode; + + /** + * Returns the next LogNode in the linked list. + */ + public static LogNode getLogNode() { + return mLogNode; + } + + /** + * Sets the LogNode data will be sent to. + */ + public static void setLogNode(LogNode node) { + mLogNode = node; + } + + /** + * Instructs the LogNode to print the log data provided. Other LogNodes can + * be chained to the end of the LogNode as desired. + * + * @param priority Log level of the data being logged. Verbose, Error, etc. + * @param tag Tag for for the log data. Can be used to organize log statements. + * @param msg The actual message to be logged. + * @param tr If an exception was thrown, this can be sent along for the logging facilities + * to extract and print useful information. + */ + public static void println(int priority, String tag, String msg, Throwable tr) { + if (mLogNode != null) { + mLogNode.println(priority, tag, msg, tr); + } + } + + /** + * Instructs the LogNode to print the log data provided. Other LogNodes can + * be chained to the end of the LogNode as desired. + * + * @param priority Log level of the data being logged. Verbose, Error, etc. + * @param tag Tag for for the log data. Can be used to organize log statements. + * @param msg The actual message to be logged. The actual message to be logged. + */ + public static void println(int priority, String tag, String msg) { + println(priority, tag, msg, null); + } + + /** + * Prints a message at VERBOSE priority. + * + * @param tag Tag for for the log data. Can be used to organize log statements. + * @param msg The actual message to be logged. + * @param tr If an exception was thrown, this can be sent along for the logging facilities + * to extract and print useful information. + */ + public static void v(String tag, String msg, Throwable tr) { + println(VERBOSE, tag, msg, tr); + } + + /** + * Prints a message at VERBOSE priority. + * + * @param tag Tag for for the log data. Can be used to organize log statements. + * @param msg The actual message to be logged. + */ + public static void v(String tag, String msg) { + v(tag, msg, null); + } + + + /** + * Prints a message at DEBUG priority. + * + * @param tag Tag for for the log data. Can be used to organize log statements. + * @param msg The actual message to be logged. + * @param tr If an exception was thrown, this can be sent along for the logging facilities + * to extract and print useful information. + */ + public static void d(String tag, String msg, Throwable tr) { + println(DEBUG, tag, msg, tr); + } + + /** + * Prints a message at DEBUG priority. + * + * @param tag Tag for for the log data. Can be used to organize log statements. + * @param msg The actual message to be logged. + */ + public static void d(String tag, String msg) { + d(tag, msg, null); + } + + /** + * Prints a message at INFO priority. + * + * @param tag Tag for for the log data. Can be used to organize log statements. + * @param msg The actual message to be logged. + * @param tr If an exception was thrown, this can be sent along for the logging facilities + * to extract and print useful information. + */ + public static void i(String tag, String msg, Throwable tr) { + println(INFO, tag, msg, tr); + } + + /** + * Prints a message at INFO priority. + * + * @param tag Tag for for the log data. Can be used to organize log statements. + * @param msg The actual message to be logged. + */ + public static void i(String tag, String msg) { + i(tag, msg, null); + } + + /** + * Prints a message at WARN priority. + * + * @param tag Tag for for the log data. Can be used to organize log statements. + * @param msg The actual message to be logged. + * @param tr If an exception was thrown, this can be sent along for the logging facilities + * to extract and print useful information. + */ + public static void w(String tag, String msg, Throwable tr) { + println(WARN, tag, msg, tr); + } + + /** + * Prints a message at WARN priority. + * + * @param tag Tag for for the log data. Can be used to organize log statements. + * @param msg The actual message to be logged. + */ + public static void w(String tag, String msg) { + w(tag, msg, null); + } + + /** + * Prints a message at WARN priority. + * + * @param tag Tag for for the log data. Can be used to organize log statements. + * @param tr If an exception was thrown, this can be sent along for the logging facilities + * to extract and print useful information. + */ + public static void w(String tag, Throwable tr) { + w(tag, null, tr); + } + + /** + * Prints a message at ERROR priority. + * + * @param tag Tag for for the log data. Can be used to organize log statements. + * @param msg The actual message to be logged. + * @param tr If an exception was thrown, this can be sent along for the logging facilities + * to extract and print useful information. + */ + public static void e(String tag, String msg, Throwable tr) { + println(ERROR, tag, msg, tr); + } + + /** + * Prints a message at ERROR priority. + * + * @param tag Tag for for the log data. Can be used to organize log statements. + * @param msg The actual message to be logged. + */ + public static void e(String tag, String msg) { + e(tag, msg, null); + } + + /** + * Prints a message at ASSERT priority. + * + * @param tag Tag for for the log data. Can be used to organize log statements. + * @param msg The actual message to be logged. + * @param tr If an exception was thrown, this can be sent along for the logging facilities + * to extract and print useful information. + */ + public static void wtf(String tag, String msg, Throwable tr) { + println(ASSERT, tag, msg, tr); + } + + /** + * Prints a message at ASSERT priority. + * + * @param tag Tag for for the log data. Can be used to organize log statements. + * @param msg The actual message to be logged. + */ + public static void wtf(String tag, String msg) { + wtf(tag, msg, null); + } + + /** + * Prints a message at ASSERT priority. + * + * @param tag Tag for for the log data. Can be used to organize log statements. + * @param tr If an exception was thrown, this can be sent along for the logging facilities + * to extract and print useful information. + */ + public static void wtf(String tag, Throwable tr) { + wtf(tag, null, tr); + } + +} diff --git a/mobile/src/main/java/de/nordgedanken/rpicms/common/logger/LogFragment.java b/mobile/src/main/java/de/nordgedanken/rpicms/common/logger/LogFragment.java @@ -0,0 +1,86 @@ +package de.nordgedanken.rpicms.common.logger; + +/** + * Created by frank on 14.10.14. + */ +import android.graphics.Typeface; +import android.os.Bundle; +import android.support.v4.app.Fragment; +import android.text.Editable; +import android.text.TextWatcher; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ScrollView; + +public class LogFragment extends Fragment { + + /** + * Simple fraggment which contains a LogView and uses is to output log data it receives + * through the LogNode interface. + */ + + + private LogView mLogView; + private ScrollView mScrollView; + + public LogFragment() {} + + public View inflateViews() { + mScrollView = new ScrollView(getActivity()); + ViewGroup.LayoutParams scrollParams = new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT); + mScrollView.setLayoutParams(scrollParams); + + mLogView = new LogView(getActivity()); + ViewGroup.LayoutParams logParams = new ViewGroup.LayoutParams(scrollParams); + logParams.height = ViewGroup.LayoutParams.WRAP_CONTENT; + mLogView.setLayoutParams(logParams); + mLogView.setClickable(true); + mLogView.setFocusable(true); + mLogView.setTypeface(Typeface.MONOSPACE); + + // Want to set padding as 16 dips, setPadding takes pixels. Hooray math! + int paddingDips = 16; + double scale = getResources().getDisplayMetrics().density; + int paddingPixels = (int) ((paddingDips * (scale)) + .5); + mLogView.setPadding(paddingPixels, paddingPixels, paddingPixels, paddingPixels); + mLogView.setCompoundDrawablePadding(paddingPixels); + + mLogView.setGravity(Gravity.BOTTOM); + mLogView.setTextAppearance(getActivity(), android.R.style.TextAppearance_Holo_Medium); + + mScrollView.addView(mLogView); + return mScrollView; + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + + View result = inflateViews(); + + mLogView.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) {} + + @Override + public void afterTextChanged(Editable s) { + mScrollView.fullScroll(ScrollView.FOCUS_DOWN); + } + }); + return result; + } + + public LogView getLogView() { + return mLogView; + } + + + +} diff --git a/mobile/src/main/java/de/nordgedanken/rpicms/common/logger/LogNode.java b/mobile/src/main/java/de/nordgedanken/rpicms/common/logger/LogNode.java @@ -0,0 +1,18 @@ +package de.nordgedanken.rpicms.common.logger; + +/** + * Created by frank on 14.10.14. + */ +public interface LogNode { + + /** + * Instructs first LogNode in the list to print the log data provided. + * @param priority Log level of the data being logged. Verbose, Error, etc. + * @param tag Tag for for the log data. Can be used to organize log statements. + * @param msg The actual message to be logged. The actual message to be logged. + * @param tr If an exception was thrown, this can be sent along for the logging facilities + * to extract and print useful information. + */ + public void println(int priority, String tag, String msg, Throwable tr); + +} +\ No newline at end of file diff --git a/mobile/src/main/java/de/nordgedanken/rpicms/common/logger/LogView.java b/mobile/src/main/java/de/nordgedanken/rpicms/common/logger/LogView.java @@ -0,0 +1,133 @@ +package de.nordgedanken.rpicms.common.logger; + +/** + * Created by frank on 14.10.14. + */ +import android.app.Activity; +import android.content.Context; +import android.util.*; +import android.widget.TextView; + +/** Simple TextView which is used to output log data received through the LogNode interface. + */ +public class LogView extends TextView implements LogNode { + + public LogView(Context context) { + super(context); + } + + public LogView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public LogView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + /** + * Formats the log data and prints it out to the LogView. + * @param priority Log level of the data being logged. Verbose, Error, etc. + * @param tag Tag for for the log data. Can be used to organize log statements. + * @param msg The actual message to be logged. The actual message to be logged. + * @param tr If an exception was thrown, this can be sent along for the logging facilities + * to extract and print useful information. + */ + @Override + public void println(int priority, String tag, String msg, Throwable tr) { + + + String priorityStr = null; + + // For the purposes of this View, we want to print the priority as readable text. + switch(priority) { + case android.util.Log.VERBOSE: + priorityStr = "VERBOSE"; + break; + case android.util.Log.DEBUG: + priorityStr = "DEBUG"; + break; + case android.util.Log.INFO: + priorityStr = "INFO"; + break; + case android.util.Log.WARN: + priorityStr = "WARN"; + break; + case android.util.Log.ERROR: + priorityStr = "ERROR"; + break; + case android.util.Log.ASSERT: + priorityStr = "ASSERT"; + break; + default: + break; + } + + // Handily, the Log class has a facility for converting a stack trace into a usable string. + String exceptionStr = null; + if (tr != null) { + exceptionStr = android.util.Log.getStackTraceString(tr); + } + + // Take the priority, tag, message, and exception, and concatenate as necessary + // into one usable line of text. + final StringBuilder outputBuilder = new StringBuilder(); + + String delimiter = "\t"; + appendIfNotNull(outputBuilder, priorityStr, delimiter); + appendIfNotNull(outputBuilder, tag, delimiter); + appendIfNotNull(outputBuilder, msg, delimiter); + appendIfNotNull(outputBuilder, exceptionStr, delimiter); + + // In case this was originally called from an AsyncTask or some other off-UI thread, + // make sure the update occurs within the UI thread. + ((Activity) getContext()).runOnUiThread( (new Thread(new Runnable() { + @Override + public void run() { + // Display the text we just generated within the LogView. + appendToLog(outputBuilder.toString()); + } + }))); + + if (mNext != null) { + mNext.println(priority, tag, msg, tr); + } + } + + public LogNode getNext() { + return mNext; + } + + public void setNext(LogNode node) { + mNext = node; + } + + /** Takes a string and adds to it, with a separator, if the bit to be added isn't null. Since + * the logger takes so many arguments that might be null, this method helps cut out some of the + * agonizing tedium of writing the same 3 lines over and over. + * @param source StringBuilder containing the text to append to. + * @param addStr The String to append + * @param delimiter The String to separate the source and appended strings. A tab or comma, + * for instance. + * @return The fully concatenated String as a StringBuilder + */ + private StringBuilder appendIfNotNull(StringBuilder source, String addStr, String delimiter) { + if (addStr != null) { + if (addStr.length() == 0) { + delimiter = ""; + } + + return source.append(addStr).append(delimiter); + } + return source; + } + + // The next LogNode in the chain. + LogNode mNext; + + /** Outputs the string as a new line of log data in the LogView. */ + public void appendToLog(String s) { + append("\n" + s); + } + + +} diff --git a/mobile/src/main/java/de/nordgedanken/rpicms/common/logger/LogWrapper.java b/mobile/src/main/java/de/nordgedanken/rpicms/common/logger/LogWrapper.java @@ -0,0 +1,64 @@ +package de.nordgedanken.rpicms.common.logger; + +/** + * Created by frank on 14.10.14. + */ +import android.util.Log; + +/** + * Helper class which wraps Android's native Log utility in the Logger interface. This way + * normal DDMS output can be one of the many targets receiving and outputting logs simultaneously. + */ +public class LogWrapper implements LogNode { + + // For piping: The next node to receive Log data after this one has done its work. + private LogNode mNext; + + /** + * Returns the next LogNode in the linked list. + */ + public LogNode getNext() { + return mNext; + } + + /** + * Sets the LogNode data will be sent to.. + */ + public void setNext(LogNode node) { + mNext = node; + } + + /** + * Prints data out to the console using Android's native log mechanism. + * @param priority Log level of the data being logged. Verbose, Error, etc. + * @param tag Tag for for the log data. Can be used to organize log statements. + * @param msg The actual message to be logged. The actual message to be logged. + * @param tr If an exception was thrown, this can be sent along for the logging facilities + * to extract and print useful information. + */ + @Override + public void println(int priority, String tag, String msg, Throwable tr) { + // There actually are log methods that don't take a msg parameter. For now, + // if that's the case, just convert null to the empty string and move on. + String useMsg = msg; + if (useMsg == null) { + useMsg = ""; + } + + // If an exeption was provided, convert that exception to a usable string and attach + // it to the end of the msg method. + if (tr != null) { + msg += "\n" + Log.getStackTraceString(tr); + } + + // This is functionally identical to Log.x(tag, useMsg); + // For instance, if priority were Log.VERBOSE, this would be the same as Log.v(tag, useMsg) + Log.println(priority, tag, useMsg); + + // If this isn't the last node in the chain, move things along. + if (mNext != null) { + mNext.println(priority, tag, msg, tr); + } + } +} + diff --git a/mobile/src/main/java/de/nordgedanken/rpicms/common/logger/MessageOnlyLogFilter.java b/mobile/src/main/java/de/nordgedanken/rpicms/common/logger/MessageOnlyLogFilter.java @@ -0,0 +1,45 @@ +package de.nordgedanken.rpicms.common.logger; + +/** + * Created by frank on 14.10.14. + */ +public class MessageOnlyLogFilter implements LogNode { + + LogNode mNext; + + /** + * Takes the "next" LogNode as a parameter, to simplify chaining. + * + * @param next The next LogNode in the pipeline. + */ + public MessageOnlyLogFilter(LogNode next) { + mNext = next; + } + + public MessageOnlyLogFilter() { + } + + @Override + public void println(int priority, String tag, String msg, Throwable tr) { + if (mNext != null) { + getNext().println(Log.NONE, null, msg, null); + } + } + + /** + * Returns the next LogNode in the chain. + */ + public LogNode getNext() { + return mNext; + } + + /** + * Sets the LogNode data will be sent to.. + */ + public void setNext(LogNode node) { + mNext = node; + } + +} + + diff --git a/mobile/src/main/res/drawable-hdpi/drawer_shadow.9.png b/mobile/src/main/res/drawable-hdpi/drawer_shadow.9.png Binary files differ. diff --git a/mobile/src/main/res/drawable-hdpi/ic_drawer.png b/mobile/src/main/res/drawable-hdpi/ic_drawer.png Binary files differ. diff --git a/mobile/src/main/res/drawable-hdpi/ic_launcher.png b/mobile/src/main/res/drawable-hdpi/ic_launcher.png Binary files differ. diff --git a/mobile/src/main/res/drawable-hdpi/tile.9.png b/mobile/src/main/res/drawable-hdpi/tile.9.png Binary files differ. diff --git a/mobile/src/main/res/drawable-mdpi/drawer_shadow.9.png b/mobile/src/main/res/drawable-mdpi/drawer_shadow.9.png Binary files differ. diff --git a/mobile/src/main/res/drawable-mdpi/ic_drawer.png b/mobile/src/main/res/drawable-mdpi/ic_drawer.png Binary files differ. diff --git a/mobile/src/main/res/drawable-mdpi/ic_launcher.png b/mobile/src/main/res/drawable-mdpi/ic_launcher.png Binary files differ. diff --git a/mobile/src/main/res/drawable-xhdpi/drawer_shadow.9.png b/mobile/src/main/res/drawable-xhdpi/drawer_shadow.9.png Binary files differ. diff --git a/mobile/src/main/res/drawable-xhdpi/ic_action_refresh.png b/mobile/src/main/res/drawable-xhdpi/ic_action_refresh.png Binary files differ. diff --git a/mobile/src/main/res/drawable-xhdpi/ic_drawer.png b/mobile/src/main/res/drawable-xhdpi/ic_drawer.png Binary files differ. diff --git a/mobile/src/main/res/drawable-xhdpi/ic_launcher.png b/mobile/src/main/res/drawable-xhdpi/ic_launcher.png Binary files differ. diff --git a/mobile/src/main/res/drawable-xxhdpi/drawer_shadow.9.png b/mobile/src/main/res/drawable-xxhdpi/drawer_shadow.9.png Binary files differ. diff --git a/mobile/src/main/res/drawable-xxhdpi/ic_drawer.png b/mobile/src/main/res/drawable-xxhdpi/ic_drawer.png Binary files differ. diff --git a/mobile/src/main/res/drawable-xxhdpi/ic_launcher.png b/mobile/src/main/res/drawable-xxhdpi/ic_launcher.png Binary files differ. diff --git a/mobile/src/main/res/layout/actionbar_indeterminate_progress.xml b/mobile/src/main/res/layout/actionbar_indeterminate_progress.xml @@ -0,0 +1,10 @@ +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_height="wrap_content" + android:layout_width="@dimen/action_button_min_width" + android:minWidth="@dimen/action_button_min_width"> + + <ProgressBar android:layout_width="@dimen/indeterminate_progress_size" + android:layout_height="@dimen/indeterminate_progress_size" + android:layout_gravity="center" + style="?indeterminateProgressStyle" /> +</FrameLayout> +\ No newline at end of file diff --git a/mobile/src/main/res/layout/activity_entry_list.xml b/mobile/src/main/res/layout/activity_entry_list.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="utf-8"?> + +<fragment xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/entry_list" + android:name="de.nordgedanken.rpicms.EntryListFragment" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_marginLeft="16dp" + android:layout_marginRight="16dp" + tools:context=".EntryListActivity" + tools:layout="@android:layout/list_content" /> +\ No newline at end of file diff --git a/mobile/src/main/res/layout/activity_main.xml b/mobile/src/main/res/layout/activity_main.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="utf-8"?> + +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + + <LinearLayout style="@style/Widget.SampleMessageTile" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical"> + + <TextView style="@style/Widget.SampleMessage" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginLeft="@dimen/horizontal_page_margin" + android:layout_marginRight="@dimen/horizontal_page_margin" + android:layout_marginTop="@dimen/vertical_page_margin" + android:layout_marginBottom="@dimen/vertical_page_margin" + android:text="@string/intro_message" /> + </LinearLayout> +</LinearLayout> +\ No newline at end of file diff --git a/mobile/src/main/res/layout/activity_start.xml b/mobile/src/main/res/layout/activity_start.xml @@ -0,0 +1,31 @@ +<!-- A DrawerLayout is intended to be used as the top-level content view using match_parent for both width and height to consume the full space available. --> +<android.support.v4.widget.DrawerLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/drawer_layout" + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:context=".Start"> + + <!-- As the main content view, the view below consumes the entire + space available using match_parent in both dimensions. --> + <FrameLayout + android:id="@+id/container" + android:layout_width="match_parent" + android:layout_height="match_parent" /> + + <!-- android:layout_gravity="start" tells DrawerLayout to treat + this as a sliding drawer on the left side for left-to-right + languages and on the right side for right-to-left languages. + If you're not building against API 17 or higher, use + android:layout_gravity="left" instead. --> + <!-- The drawer is given a fixed width in dp and extends the full height of + the container. --> + <fragment android:id="@+id/navigation_drawer" + android:layout_width="@dimen/navigation_drawer_width" + android:layout_height="match_parent" + android:layout_gravity="start" + android:name="de.nordgedanken.rpicms.NavigationDrawerFragment" + tools:layout="@layout/fragment_navigation_drawer" /> + +</android.support.v4.widget.DrawerLayout> diff --git a/mobile/src/main/res/layout/fragment_navigation_drawer.xml b/mobile/src/main/res/layout/fragment_navigation_drawer.xml @@ -0,0 +1,9 @@ +<ListView xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:choiceMode="singleChoice" + android:divider="@android:color/transparent" + android:dividerHeight="0dp" + android:background="#cccc" + tools:context=".NavigationDrawerFragment" /> diff --git a/mobile/src/main/res/layout/fragment_start.xml b/mobile/src/main/res/layout/fragment_start.xml @@ -0,0 +1,16 @@ +<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:paddingLeft="@dimen/activity_horizontal_margin" + android:paddingRight="@dimen/activity_horizontal_margin" + android:paddingTop="@dimen/activity_vertical_margin" + android:paddingBottom="@dimen/activity_vertical_margin" + tools:context=".Start$PlaceholderFragment"> + + <TextView + android:id="@+id/section_label" + android:layout_width="wrap_content" + android:layout_height="wrap_content" /> + +</RelativeLayout> diff --git a/mobile/src/main/res/menu/global.xml b/mobile/src/main/res/menu/global.xml @@ -0,0 +1,10 @@ +<menu xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:id="@+id/action_settings" + android:title="@string/action_settings" + android:orderInCategory="100" + android:showAsAction="never" /> + <item android:id="@+id/action_sync" + android:title="@string/action_sync" + android:orderInCategory="100" + android:showAsAction="withText" /> +</menu> diff --git a/mobile/src/main/res/menu/main.xml b/mobile/src/main/res/menu/main.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> + +<menu xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:id="@+id/menu_refresh" + android:icon="@drawable/ic_action_refresh" + android:title="@string/description_refresh" + android:orderInCategory="1" + android:showAsAction="always" /> +</menu> +\ No newline at end of file diff --git a/mobile/src/main/res/menu/start.xml b/mobile/src/main/res/menu/start.xml @@ -0,0 +1,18 @@ +<menu xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + tools:context=".Start" > + + <item android:id="@+id/menu_refresh" + android:icon="@drawable/ic_action_refresh" + android:title="@string/description_refresh" + android:orderInCategory="1" /> + <!--android:showAsAction="always"--> + <item android:id="@+id/action_settings" + android:title="@string/action_settings" + android:orderInCategory="100" + android:showAsAction="never" /> + <item android:id="@+id/action_synctab" + android:title="@string/action_sync" + android:orderInCategory="100" + android:showAsAction="never" /> +</menu> diff --git a/mobile/src/main/res/values-w820dp/dimens.xml b/mobile/src/main/res/values-w820dp/dimens.xml @@ -0,0 +1,6 @@ +<resources> + <!-- Example customization of dimensions originally defined in res/values/dimens.xml + (such as screen margins) for screens with more than 820dp of available width. This + would include 7" and 10" devices in landscape (~960dp and ~1280dp respectively). --> + <dimen name="activity_horizontal_margin">64dp</dimen> +</resources> diff --git a/mobile/src/main/res/values/attrs.xml b/mobile/src/main/res/values/attrs.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Specifies a style resource to use for an indeterminate progress spinner. --> + <attr name="indeterminateProgressStyle" format="reference"/> +</resources> +\ No newline at end of file diff --git a/mobile/src/main/res/values/base-strings.xml b/mobile/src/main/res/values/base-strings.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="app_name">RPICMS</string> + <string name="intro_message"> + <![CDATA[ + + + This sample demonstrates using SyncAdapter to fetch background data for an app that + doesn\'t require a user-visible account type or 2-way synchronization. + + \n\nThis sample periodically downloads the feed from the Android Developer Blog and + caches the data in a content provider. At runtime, the cached feed data is displayed + inside a ListView. + + + ]]> + </string> +</resources> +\ No newline at end of file diff --git a/mobile/src/main/res/values/dimens.xml b/mobile/src/main/res/values/dimens.xml @@ -0,0 +1,23 @@ +<resources> + <!-- Default screen margins, per the Android Design guidelines. --> + <dimen name="activity_horizontal_margin">16dp</dimen> + <dimen name="activity_vertical_margin">16dp</dimen> + <dimen name="action_button_min_width">56dp</dimen> + <dimen name="indeterminate_progress_size">32dp</dimen> + <!-- Per the design guidelines, navigation drawers should be between 240dp and 320dp: + https://developer.android.com/design/patterns/navigation-drawer.html --> + <dimen name="navigation_drawer_width">240dp</dimen> + <!-- Define standard dimensions to comply with Holo-style grids and rhythm. --> + + <dimen name="margin_tiny">4dp</dimen> + <dimen name="margin_small">8dp</dimen> + <dimen name="margin_medium">16dp</dimen> + <dimen name="margin_large">32dp</dimen> + <dimen name="margin_huge">64dp</dimen> + + <!-- Semantic definitions --> + + <dimen name="horizontal_page_margin">@dimen/margin_medium</dimen> + <dimen name="vertical_page_margin">@dimen/margin_medium</dimen> + +</resources> diff --git a/mobile/src/main/res/values/strings.xml b/mobile/src/main/res/values/strings.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- <string name="app_name">RPICMS</string>--> + <string name="title_section1">Beiträge</string> + <string name="title_section2">Kommentare</string> + <string name="title_section3">Blog Einstellungen</string> + <string name="navigation_drawer_open">Open navigation drawer</string> + <string name="navigation_drawer_close">Close navigation drawer</string> + <string name="action_sync">Sync</string> + <string name="action_settings">Settings</string> + <string name="account_name">FeedSync Service</string> + <string name="title_entry_detail">Entry Detail</string> + <string name="loading">Waiting for sync...</string> + <string name="description_refresh">Refresh</string> + +</resources> diff --git a/mobile/src/main/res/values/styles.xml b/mobile/src/main/res/values/styles.xml @@ -0,0 +1,27 @@ +<resources> + + <!-- Base application theme. --> + <style name="AppTheme" parent="android:Theme.Holo.Light.DarkActionBar"> + <!-- Customize your theme here. --> + </style> + <!-- Activity themes --> + + <style name="Theme.Base" parent="android:Theme.Holo.Light.DarkActionBar" /> + + <style name="Theme.Sample" parent="Theme.Base" /> + + + <style name="Widget" /> + + <style name="Widget.SampleMessage"> + <item name="android:textAppearance">?android:textAppearanceMedium</item> + <item name="android:lineSpacingMultiplier">1.1</item> + </style> + + <style name="Widget.SampleMessageTile"> + <item name="android:background">@drawable/tile</item> + <item name="android:shadowColor">#7F000000</item> + <item name="android:shadowDy">-3.5</item> + <item name="android:shadowRadius">2</item> + </style> +</resources> diff --git a/mobile/src/main/res/xml/authenticator.xml b/mobile/src/main/res/xml/authenticator.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +Copyright 2013 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +--> + +<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android" + android:accountType="de.nordgedanken.rpicms.basicsyncadapter.account" + android:icon="@drawable/ic_launcher" + android:smallIcon="@drawable/ic_launcher" + android:label="@string/app_name" + /> +\ No newline at end of file diff --git a/mobile/src/main/res/xml/syncadapter.xml b/mobile/src/main/res/xml/syncadapter.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +Copyright 2013 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +--> + +<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android" + android:contentAuthority="de.nordgedanken.rpicms.basicsyncadapter" + android:accountType="de.nordgedanken.rpicms.basicsyncadapter.account" + android:userVisible="true" + android:supportsUploading="false" + android:allowParallelSyncs="true" + android:isAlwaysSyncable="true" + /> +\ No newline at end of file diff --git a/settings.gradle b/settings.gradle @@ -0,0 +1 @@ +include ':mobile', ':tv' diff --git a/tv/.gitignore b/tv/.gitignore @@ -0,0 +1 @@ +/build diff --git a/tv/build.gradle b/tv/build.gradle @@ -0,0 +1,29 @@ +apply plugin: 'com.android.application' + + +android { + compileSdkVersion 'android-L' + buildToolsVersion "20.0.0" + + defaultConfig { + applicationId "de.nordgedanken.rpicms" + minSdkVersion 'L' + targetSdkVersion 'L' + versionCode 1 + versionName "1.0" + } + buildTypes { + release { + runProguard false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +dependencies { + compile fileTree(dir: 'libs', include: ['*.jar']) + compile 'com.android.support:recyclerview-v7:21.+' + compile 'com.android.support:leanback-v17:21.+' + compile 'com.android.support:appcompat-v7:21.+' + compile 'com.squareup.picasso:picasso:2.3.2' +} diff --git a/tv/proguard-rules.pro b/tv/proguard-rules.pro @@ -0,0 +1,17 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /home/frank/Arbeitsfläche/android-studio/sdk/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# 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/tv/src/androidTest/java/de/nordgedanken/rpicms/ApplicationTest.java b/tv/src/androidTest/java/de/nordgedanken/rpicms/ApplicationTest.java @@ -0,0 +1,13 @@ +package de.nordgedanken.rpicms; + +import android.app.Application; +import android.test.ApplicationTestCase; + +/** + * <a href="http://d.android.com/tools/testing/testing_android.html">Testing Fundamentals</a> + */ +public class ApplicationTest extends ApplicationTestCase<Application> { + public ApplicationTest() { + super(Application.class); + } +} +\ No newline at end of file diff --git a/tv/src/main/AndroidManifest.xml b/tv/src/main/AndroidManifest.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="de.nordgedanken.rpicms" > + + <application + android:allowBackup="true" + android:icon="@drawable/ic_launcher" + android:label="@string/app_name" + android:theme="@style/AppTheme" > + <activity + android:name=".Start" + android:label="@string/app_name" + android:logo="@drawable/app_icon_quantum" + android:screenOrientation="landscape" > + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + + <category android:name="android.intent.category.LEANBACK_LAUNCHER" /> + </intent-filter> + </activity> + <activity + android:name=".PlayerActivity" + android:theme="@android:style/Theme.NoTitleBar.Fullscreen" /> + <activity android:name=".DetailsActivity" /> + </application> + + <uses-permission android:name="android.permission.INTERNET" /> + + <uses-feature + android:name="android.hardware.touchscreen" + android:required="false" /> + +</manifest> diff --git a/tv/src/main/java/de/nordgedanken/rpicms/CardPresenter.java b/tv/src/main/java/de/nordgedanken/rpicms/CardPresenter.java @@ -0,0 +1,122 @@ +package de.nordgedanken.rpicms; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.support.v17.leanback.widget.ImageCardView; +import android.support.v17.leanback.widget.Presenter; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; + +import com.squareup.picasso.Picasso; +import com.squareup.picasso.Target; + +import java.net.URI; + +public class CardPresenter extends Presenter { + private static final String TAG = "CardPresenter"; + + private static Context mContext; + private static int CARD_WIDTH = 313; + private static int CARD_HEIGHT = 176; + + static class ViewHolder extends Presenter.ViewHolder { + private Movie mMovie; + private ImageCardView mCardView; + private Drawable mDefaultCardImage; + private PicassoImageCardViewTarget mImageCardViewTarget; + + public ViewHolder(View view) { + super(view); + mCardView = (ImageCardView) view; + mImageCardViewTarget = new PicassoImageCardViewTarget(mCardView); + mDefaultCardImage = mContext.getResources().getDrawable(R.drawable.movie); + } + + public void setMovie(Movie m) { + mMovie = m; + } + + public Movie getMovie() { + return mMovie; + } + + public ImageCardView getCardView() { + return mCardView; + } + + protected void updateCardViewImage(URI uri) { + Picasso.with(mContext) + .load(uri.toString()) + .resize(CARD_WIDTH, CARD_HEIGHT) + .centerCrop() + .error(mDefaultCardImage) + .into(mImageCardViewTarget); + } + } + + @Override + public ViewHolder onCreateViewHolder(ViewGroup parent) { + Log.d(TAG, "onCreateViewHolder"); + mContext = parent.getContext(); + + ImageCardView cardView = new ImageCardView(mContext); + cardView.setFocusable(true); + cardView.setFocusableInTouchMode(true); + cardView.setBackgroundColor(mContext.getResources().getColor(R.color.fastlane_background)); + return new ViewHolder(cardView); + } + + @Override + public void onBindViewHolder(Presenter.ViewHolder viewHolder, Object item) { + Movie movie = (Movie) item; + ((ViewHolder) viewHolder).setMovie(movie); + + Log.d(TAG, "onBindViewHolder"); + if (movie.getCardImageUrl() != null) { + ((ViewHolder) viewHolder).mCardView.setTitleText(movie.getTitle()); + ((ViewHolder) viewHolder).mCardView.setContentText(movie.getStudio()); + ((ViewHolder) viewHolder).mCardView.setMainImageDimensions(CARD_WIDTH, CARD_HEIGHT); + //((ViewHolder) viewHolder).mCardView.setBadgeImage(mContext.getResources().getDrawable( + // R.drawable.videos_by_google_icon)); + ((ViewHolder) viewHolder).updateCardViewImage(movie.getCardImageURI()); + } + } + + @Override + public void onUnbindViewHolder(Presenter.ViewHolder viewHolder) { + Log.d(TAG, "onUnbindViewHolder"); + } + + @Override + public void onViewAttachedToWindow(Presenter.ViewHolder viewHolder) { + Log.d(TAG, "onViewAttachedToWindow"); + } + + public static class PicassoImageCardViewTarget implements Target { + private ImageCardView mImageCardView; + + public PicassoImageCardViewTarget(ImageCardView mImageCardView) { + this.mImageCardView = mImageCardView; + } + + @Override + public void onBitmapLoaded(Bitmap bitmap, Picasso.LoadedFrom loadedFrom) { + Drawable bitmapDrawable = new BitmapDrawable(mContext.getResources(), bitmap); + mImageCardView.setMainImage(bitmapDrawable); + } + + @Override + public void onBitmapFailed(Drawable drawable) { + mImageCardView.setMainImage(drawable); + } + + @Override + public void onPrepareLoad(Drawable drawable) { + // Do nothing, default_background manager has its own transitions + } + } + +} diff --git a/tv/src/main/java/de/nordgedanken/rpicms/DetailsActivity.java b/tv/src/main/java/de/nordgedanken/rpicms/DetailsActivity.java @@ -0,0 +1,15 @@ +package de.nordgedanken.rpicms; + +import android.app.Activity; +import android.os.Bundle; + +public class DetailsActivity extends Activity +{ + /** Called when the activity is first created. */ + @Override + public void onCreate(Bundle savedInstanceState) + { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_details); + } +} diff --git a/tv/src/main/java/de/nordgedanken/rpicms/DetailsDescriptionPresenter.java b/tv/src/main/java/de/nordgedanken/rpicms/DetailsDescriptionPresenter.java @@ -0,0 +1,16 @@ +package de.nordgedanken.rpicms; + +import android.support.v17.leanback.widget.AbstractDetailsDescriptionPresenter; + +public class DetailsDescriptionPresenter extends AbstractDetailsDescriptionPresenter { + @Override + protected void onBindDescription(ViewHolder viewHolder, Object item) { + Movie movie = (Movie) item; + + if (movie != null) { + viewHolder.getTitle().setText(movie.getTitle()); + viewHolder.getSubtitle().setText(movie.getStudio()); + viewHolder.getBody().setText(movie.getDescription()); + } + } +} diff --git a/tv/src/main/java/de/nordgedanken/rpicms/MainFragment.java b/tv/src/main/java/de/nordgedanken/rpicms/MainFragment.java @@ -0,0 +1,239 @@ +package de.nordgedanken.rpicms; + +import java.net.URI; +import java.util.Collections; +import java.util.List; +import java.util.Timer; +import java.util.TimerTask; + +import android.content.Intent; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.os.Handler; +import android.support.v17.leanback.app.BackgroundManager; +import android.support.v17.leanback.app.BrowseFragment; +import android.support.v17.leanback.widget.ArrayObjectAdapter; +import android.support.v17.leanback.widget.HeaderItem; +import android.support.v17.leanback.widget.ListRow; +import android.support.v17.leanback.widget.ListRowPresenter; +import android.support.v17.leanback.widget.OnItemClickedListener; +import android.support.v17.leanback.widget.OnItemSelectedListener; +import android.support.v17.leanback.widget.Presenter; +import android.support.v17.leanback.widget.Row; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import android.widget.Toast; + +import com.squareup.picasso.Picasso; +import com.squareup.picasso.Target; + + +public class MainFragment extends BrowseFragment { + private static final String TAG = "MainFragment"; + + private static final int NUM_ROWS = 6; + private static final int NUM_COLS = 15; + + private ArrayObjectAdapter mRowsAdapter; + private Drawable mDefaultBackground; + private Target mBackgroundTarget; + private DisplayMetrics mMetrics; + private Timer mBackgroundTimer; + private final Handler mHandler = new Handler(); + private URI mBackgroundURI; + Movie mMovie; + CardPresenter mCardPresenter; + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + Log.i(TAG, "onCreate"); + super.onActivityCreated(savedInstanceState); + + prepareBackgroundManager(); + + setupUIElements(); + + loadRows(); + + setupEventListeners(); + } + + private void loadRows() { + List<Movie> list = MovieList.setupMovies(); + + mRowsAdapter = new ArrayObjectAdapter(new ListRowPresenter()); + mCardPresenter = new CardPresenter(); + + int i; + for (i = 0; i < NUM_ROWS; i++) { + if (i != 0) { + Collections.shuffle(list); + } + ArrayObjectAdapter listRowAdapter = new ArrayObjectAdapter(mCardPresenter); + for (int j = 0; j < NUM_COLS; j++) { + listRowAdapter.add(list.get(j % 5)); + } + HeaderItem header = new HeaderItem(i, MovieList.MOVIE_CATEGORY[i], null); + mRowsAdapter.add(new ListRow(header, listRowAdapter)); + } + + HeaderItem gridHeader = new HeaderItem(i, "PREFERENCES", null); + + GridItemPresenter mGridPresenter = new GridItemPresenter(); + ArrayObjectAdapter gridRowAdapter = new ArrayObjectAdapter(mGridPresenter); + gridRowAdapter.add(getResources().getString(R.string.grid_view)); + gridRowAdapter.add(getResources().getString(R.string.send_feeback)); + gridRowAdapter.add(getResources().getString(R.string.personal_settings)); + mRowsAdapter.add(new ListRow(gridHeader, gridRowAdapter)); + + setAdapter(mRowsAdapter); + + } + + private void prepareBackgroundManager() { + + BackgroundManager backgroundManager = BackgroundManager.getInstance(getActivity()); + backgroundManager.attach(getActivity().getWindow()); + mBackgroundTarget = new PicassoBackgroundManagerTarget(backgroundManager); + + mDefaultBackground = getResources().getDrawable(R.drawable.default_background); + + mMetrics = new DisplayMetrics(); + getActivity().getWindowManager().getDefaultDisplay().getMetrics(mMetrics); + } + + private void setupUIElements() { + // setBadgeDrawable(getActivity().getResources().getDrawable( + // R.drawable.videos_by_google_banner)); + setTitle(getString(R.string.browse_title)); // Badge, when set, takes precedent + // over title + setHeadersState(HEADERS_ENABLED); + setHeadersTransitionOnBackEnabled(true); + + // set fastLane (or headers) background color + setBrandColor(getResources().getColor(R.color.fastlane_background)); + // set search icon color + setSearchAffordanceColor(getResources().getColor(R.color.search_opaque)); + } + + private void setupEventListeners() { + setOnItemSelectedListener(getDefaultItemSelectedListener()); + setOnItemClickedListener(getDefaultItemClickedListener()); + setOnSearchClickedListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + Toast.makeText(getActivity(), "Implement your own in-app search", Toast.LENGTH_LONG) + .show(); + } + }); + } + + protected OnItemSelectedListener getDefaultItemSelectedListener() { + return new OnItemSelectedListener() { + @Override + public void onItemSelected(Object item, Row row) { + if (item instanceof Movie) { + mBackgroundURI = ((Movie) item).getBackgroundImageURI(); + startBackgroundTimer(); + } + } + }; + } + + protected OnItemClickedListener getDefaultItemClickedListener() { + return new OnItemClickedListener() { + @Override + public void onItemClicked(Object item, Row row) { + if (item instanceof Movie) { + Movie movie = (Movie) item; + Log.d(TAG, "Item: " + item.toString()); + Intent intent = new Intent(getActivity(), DetailsActivity.class); + intent.putExtra(getString(R.string.movie), movie); + startActivity(intent); + } + else if (item instanceof String) { + Toast.makeText(getActivity(), (String) item, Toast.LENGTH_SHORT) + .show(); + } + } + }; + } + + protected void setDefaultBackground(Drawable background) { + mDefaultBackground = background; + } + + protected void setDefaultBackground(int resourceId) { + mDefaultBackground = getResources().getDrawable(resourceId); + } + + protected void updateBackground(URI uri) { + Picasso.with(getActivity()) + .load(uri.toString()) + .resize(mMetrics.widthPixels, mMetrics.heightPixels) + .centerCrop() + .error(mDefaultBackground) + .into(mBackgroundTarget); + } + + protected void updateBackground(Drawable drawable) { + BackgroundManager.getInstance(getActivity()).setDrawable(drawable); + } + + protected void clearBackground() { + BackgroundManager.getInstance(getActivity()).setDrawable(mDefaultBackground); + } + + private void startBackgroundTimer() { + if (null != mBackgroundTimer) { + mBackgroundTimer.cancel(); + } + mBackgroundTimer = new Timer(); + mBackgroundTimer.schedule(new UpdateBackgroundTask(), 300); + } + + private class UpdateBackgroundTask extends TimerTask { + + @Override + public void run() { + mHandler.post(new Runnable() { + @Override + public void run() { + if (mBackgroundURI != null) { + updateBackground(mBackgroundURI); + } + } + }); + + } + } + + private class GridItemPresenter extends Presenter { + @Override + public ViewHolder onCreateViewHolder(ViewGroup parent) { + TextView view = new TextView(parent.getContext()); + view.setLayoutParams(new ViewGroup.LayoutParams(200, 200)); + view.setFocusable(true); + view.setFocusableInTouchMode(true); + view.setBackgroundColor(getResources().getColor(R.color.default_background)); + view.setTextColor(Color.WHITE); + view.setGravity(Gravity.CENTER); + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(ViewHolder viewHolder, Object item) { + ((TextView) viewHolder.view).setText((String) item); + } + + @Override + public void onUnbindViewHolder(ViewHolder viewHolder) { + } + } + +} diff --git a/tv/src/main/java/de/nordgedanken/rpicms/Movie.java b/tv/src/main/java/de/nordgedanken/rpicms/Movie.java @@ -0,0 +1,125 @@ +package de.nordgedanken.rpicms; + +import java.io.Serializable; +import java.net.URI; +import java.net.URISyntaxException; + +import android.util.Log; + +public class Movie implements Serializable { + static final long serialVersionUID = 727566175075960653L; + private static long count = 0; + private long id; + private String title; + private String description; + private String bgImageUrl; + private String cardImageUrl; + private String videoUrl; + private String studio; + private String category; + + public Movie() { + } + + public static long getCount() { + return count; + } + + public static void incCount() { + count++; + } + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getStudio() { + return studio; + } + + public void setStudio(String studio) { + this.studio = studio; + } + + public String getVideoUrl() { + return videoUrl; + } + + public void setVideoUrl(String videoUrl) { + this.videoUrl = videoUrl; + } + + public String getBackgroundImageUrl() { + return bgImageUrl; + } + + public void setBackgroundImageUrl(String bgImageUrl) { + this.bgImageUrl = bgImageUrl; + } + + public String getCardImageUrl() { + return cardImageUrl; + } + + public void setCardImageUrl(String cardImageUrl) { + this.cardImageUrl = cardImageUrl; + } + + public String getCategory() { + return category; + } + + public void setCategory(String category) { + this.category = category; + } + + public URI getBackgroundImageURI() { + try { + Log.d("BACK MOVIE: ", bgImageUrl); + return new URI(getBackgroundImageUrl()); + } catch (URISyntaxException e) { + Log.d("URI exception: ", bgImageUrl); + return null; + } + } + + public URI getCardImageURI() { + try { + return new URI(getCardImageUrl()); + } catch (URISyntaxException e) { + return null; + } + } + + @Override + public String toString() { + return "Movie{" + + "id=" + id + + ", title='" + title + '\'' + + ", videoUrl='" + videoUrl + '\'' + + ", backgroundImageUrl='" + bgImageUrl + '\'' + + ", backgroundImageURI='" + getBackgroundImageURI().toString() + '\'' + + ", cardImageUrl='" + cardImageUrl + '\'' + + '}'; + } +} diff --git a/tv/src/main/java/de/nordgedanken/rpicms/MovieList.java b/tv/src/main/java/de/nordgedanken/rpicms/MovieList.java @@ -0,0 +1,86 @@ +package de.nordgedanken.rpicms; + +import java.util.ArrayList; +import java.util.List; + +public final class MovieList { + public static final String MOVIE_CATEGORY[] = { + "Category Zero", + "Category One", + "Category Two", + "Category Three", + "Category Four", + "Category Five", + }; + + public static List<Movie> list; + + public static List<Movie> setupMovies() { + list = new ArrayList<Movie>(); + String title[] = { + "Zeitgeist 2010_ Year in Review", + "Google Demo Slam_ 20ft Search", + "Introducing Gmail Blue", + "Introducing Google Fiber to the Pole", + "Introducing Google Nose" + }; + + String description = "Fusce id nisi turpis. Praesent viverra bibendum semper. " + + "Donec tristique, orci sed semper lacinia, quam erat rhoncus massa, non congue tellus est " + + "quis tellus. Sed mollis orci venenatis quam scelerisque accumsan. Curabitur a massa sit " + + "amet mi accumsan mollis sed et magna. Vivamus sed aliquam risus. Nulla eget dolor in elit " + + "facilisis mattis. Ut aliquet luctus lacus. Phasellus nec commodo erat. Praesent tempus id " + + "lectus ac scelerisque. Maecenas pretium cursus lectus id volutpat."; + + String videoUrl[] = { + "http://commondatastorage.googleapis.com/android-tv/Sample%20videos/Zeitgeist/Zeitgeist%202010_%20Year%20in%20Review.mp4", + "http://commondatastorage.googleapis.com/android-tv/Sample%20videos/Demo%20Slam/Google%20Demo%20Slam_%2020ft%20Search.mp4", + "http://commondatastorage.googleapis.com/android-tv/Sample%20videos/April%20Fool's%202013/Introducing%20Gmail%20Blue.mp4", + "http://commondatastorage.googleapis.com/android-tv/Sample%20videos/April%20Fool's%202013/Introducing%20Google%20Fiber%20to%20the%20Pole.mp4", + "http://commondatastorage.googleapis.com/android-tv/Sample%20videos/April%20Fool's%202013/Introducing%20Google%20Nose.mp4" + }; + String bgImageUrl[] = { + "http://commondatastorage.googleapis.com/android-tv/Sample%20videos/Zeitgeist/Zeitgeist%202010_%20Year%20in%20Review/bg.jpg", + "http://commondatastorage.googleapis.com/android-tv/Sample%20videos/Demo%20Slam/Google%20Demo%20Slam_%2020ft%20Search/bg.jpg", + "http://commondatastorage.googleapis.com/android-tv/Sample%20videos/April%20Fool's%202013/Introducing%20Gmail%20Blue/bg.jpg", + "http://commondatastorage.googleapis.com/android-tv/Sample%20videos/April%20Fool's%202013/Introducing%20Google%20Fiber%20to%20the%20Pole/bg.jpg", + "http://commondatastorage.googleapis.com/android-tv/Sample%20videos/April%20Fool's%202013/Introducing%20Google%20Nose/bg.jpg", + }; + String cardImageUrl[] = { + "http://commondatastorage.googleapis.com/android-tv/Sample%20videos/Zeitgeist/Zeitgeist%202010_%20Year%20in%20Review/card.jpg", + "http://commondatastorage.googleapis.com/android-tv/Sample%20videos/Demo%20Slam/Google%20Demo%20Slam_%2020ft%20Search/card.jpg", + "http://commondatastorage.googleapis.com/android-tv/Sample%20videos/April%20Fool's%202013/Introducing%20Gmail%20Blue/card.jpg", + "http://commondatastorage.googleapis.com/android-tv/Sample%20videos/April%20Fool's%202013/Introducing%20Google%20Fiber%20to%20the%20Pole/card.jpg", + "http://commondatastorage.googleapis.com/android-tv/Sample%20videos/April%20Fool's%202013/Introducing%20Google%20Nose/card.jpg" + }; + + list.add(buildMovieInfo("category", title[0], + description, "Studio Zero", videoUrl[0], cardImageUrl[0], bgImageUrl[0])); + list.add(buildMovieInfo("category", title[1], + description, "Studio One", videoUrl[1], cardImageUrl[1], bgImageUrl[1])); + list.add(buildMovieInfo("category", title[2], + description, "Studio Two", videoUrl[2], cardImageUrl[2], bgImageUrl[2])); + list.add(buildMovieInfo("category", title[3], + description, "Studio Three", videoUrl[3], cardImageUrl[3], bgImageUrl[3])); + list.add(buildMovieInfo("category", title[4], + description, "Studio Four", videoUrl[4], cardImageUrl[4], bgImageUrl[4])); + + return list; + } + + private static Movie buildMovieInfo(String category, String title, + String description, String studio, String videoUrl, String cardImageUrl, + String bgImageUrl) { + Movie movie = new Movie(); + movie.setId(Movie.getCount()); + Movie.incCount(); + movie.setTitle(title); + movie.setDescription(description); + movie.setStudio(studio); + movie.setCategory(category); + movie.setCardImageUrl(cardImageUrl); + movie.setBackgroundImageUrl(bgImageUrl); + movie.setVideoUrl(videoUrl); + return movie; + } +} diff --git a/tv/src/main/java/de/nordgedanken/rpicms/PicassoBackgroundManagerTarget.java b/tv/src/main/java/de/nordgedanken/rpicms/PicassoBackgroundManagerTarget.java @@ -0,0 +1,53 @@ +package de.nordgedanken.rpicms; + +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.support.v17.leanback.app.BackgroundManager; +import com.squareup.picasso.Picasso; +import com.squareup.picasso.Target; + +/** + * Picasso target for updating default_background images + */ +public class PicassoBackgroundManagerTarget implements Target { + BackgroundManager mBackgroundManager; + + public PicassoBackgroundManagerTarget(BackgroundManager backgroundManager) { + this.mBackgroundManager = backgroundManager; + } + + @Override + public void onBitmapLoaded(Bitmap bitmap, Picasso.LoadedFrom loadedFrom) { + this.mBackgroundManager.setBitmap(bitmap); + } + + @Override + public void onBitmapFailed(Drawable drawable) { + this.mBackgroundManager.setDrawable(drawable); + } + + @Override + public void onPrepareLoad(Drawable drawable) { + // Do nothing, default_background manager has its own transitions + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + + PicassoBackgroundManagerTarget that = (PicassoBackgroundManagerTarget) o; + + if (!mBackgroundManager.equals(that.mBackgroundManager)) + return false; + + return true; + } + + @Override + public int hashCode() { + return mBackgroundManager.hashCode(); + } +} diff --git a/tv/src/main/java/de/nordgedanken/rpicms/PlayerActivity.java b/tv/src/main/java/de/nordgedanken/rpicms/PlayerActivity.java @@ -0,0 +1,420 @@ +package de.nordgedanken.rpicms; + +import android.app.Activity; +import android.content.Intent; +import android.media.MediaPlayer; +import android.media.MediaPlayer.OnCompletionListener; +import android.media.MediaPlayer.OnErrorListener; +import android.media.MediaPlayer.OnPreparedListener; +import android.os.Bundle; +import android.os.Handler; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.KeyEvent; +import android.view.MenuItem; +import android.view.View; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.RelativeLayout.LayoutParams; +import android.widget.SeekBar; +import android.widget.TextView; +import android.widget.VideoView; + +import java.util.Timer; +import java.util.TimerTask; + +public class PlayerActivity extends Activity { + + private static final String TAG = "PlayerActivity"; + + private static final int HIDE_CONTROLLER_TIME = 5000; + private static final int SEEKBAR_DELAY_TIME = 100; + private static final int SEEKBAR_INTERVAL_TIME = 1000; + private static final int MIN_SCRUB_TIME = 3000; + private static final int SCRUB_SEGMENT_DIVISOR = 30; + private static final double MEDIA_BAR_TOP_MARGIN = 0.8; + private static final double MEDIA_BAR_RIGHT_MARGIN = 0.2; + private static final double MEDIA_BAR_BOTTOM_MARGIN = 0.0; + private static final double MEDIA_BAR_LEFT_MARGIN = 0.2; + private static final double MEDIA_BAR_HEIGHT = 0.1; + private static final double MEDIA_BAR_WIDTH = 0.9; + + private VideoView mVideoView; + private TextView mStartText; + private TextView mEndText; + private SeekBar mSeekbar; + private ImageView mPlayPause; + private ProgressBar mLoading; + private View mControllers; + private View mContainer; + private Timer mSeekbarTimer; + private Timer mControllersTimer; + private PlaybackState mPlaybackState; + private final Handler mHandler = new Handler(); + private Movie mSelectedMovie; + private boolean mShouldStartPlayback; + private boolean mControlersVisible; + private int mDuration; + private DisplayMetrics mMetrics; + + /* + * List of various states that we can be in + */ + public static enum PlaybackState { + PLAYING, PAUSED, BUFFERING, IDLE; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_player); + + mMetrics = new DisplayMetrics(); + getWindowManager().getDefaultDisplay().getMetrics(mMetrics); + + loadViews(); + setupController(); + setupControlsCallbacks(); + startVideoPlayer(); + updateMetadata(true); + } + + private void startVideoPlayer() { + Bundle b = getIntent().getExtras(); + mSelectedMovie = (Movie) getIntent().getSerializableExtra(getResources().getString(R.string.movie)); + if (null != b) { + mShouldStartPlayback = b.getBoolean(getResources().getString(R.string.should_start)); + int startPosition = b.getInt(getResources().getString(R.string.start_position), 0); + mVideoView.setVideoPath(mSelectedMovie.getVideoUrl()); + if (mShouldStartPlayback) { + mPlaybackState = PlaybackState.PLAYING; + updatePlayButton(mPlaybackState); + if (startPosition > 0) { + mVideoView.seekTo(startPosition); + } + mVideoView.start(); + mPlayPause.requestFocus(); + startControllersTimer(); + } else { + updatePlaybackLocation(); + mPlaybackState = PlaybackState.PAUSED; + updatePlayButton(mPlaybackState); + } + } + } + + private void updatePlaybackLocation() { + if (mPlaybackState == PlaybackState.PLAYING || + mPlaybackState == PlaybackState.BUFFERING) { + startControllersTimer(); + } else { + stopControllersTimer(); + } + } + + private void play(int position) { + startControllersTimer(); + mVideoView.seekTo(position); + mVideoView.start(); + restartSeekBarTimer(); + } + + private void stopSeekBarTimer() { + Log.d(TAG, "Stopped TrickPlay Timer"); + if (null != mSeekbarTimer) { + mSeekbarTimer.cancel(); + } + } + + private void restartSeekBarTimer() { + stopSeekBarTimer(); + mSeekbarTimer = new Timer(); + mSeekbarTimer.scheduleAtFixedRate(new UpdateSeekbarTask(), SEEKBAR_DELAY_TIME, + SEEKBAR_INTERVAL_TIME); + } + + private void stopControllersTimer() { + if (null != mControllersTimer) { + mControllersTimer.cancel(); + } + } + + private void startControllersTimer() { + if (null != mControllersTimer) { + mControllersTimer.cancel(); + } + mControllersTimer = new Timer(); + mControllersTimer.schedule(new HideControllersTask(), HIDE_CONTROLLER_TIME); + } + + private void updateControlersVisibility(boolean show) { + if (show) { + mControllers.setVisibility(View.VISIBLE); + } else { + mControllers.setVisibility(View.INVISIBLE); + } + } + + @Override + protected void onPause() { + super.onPause(); + Log.d(TAG, "onPause() was called"); + if (null != mSeekbarTimer) { + mSeekbarTimer.cancel(); + mSeekbarTimer = null; + } + if (null != mControllersTimer) { + mControllersTimer.cancel(); + } + mVideoView.pause(); + mPlaybackState = PlaybackState.PAUSED; + updatePlayButton(PlaybackState.PAUSED); + } + + @Override + protected void onStop() { + Log.d(TAG, "onStop() was called"); + super.onStop(); + } + + @Override + protected void onDestroy() { + Log.d(TAG, "onDestroy() is called"); + stopControllersTimer(); + stopSeekBarTimer(); + super.onDestroy(); + } + + @Override + protected void onStart() { + Log.d(TAG, "onStart was called"); + super.onStart(); + } + + @Override + protected void onResume() { + Log.d(TAG, "onResume() was called"); + super.onResume(); + } + + private class HideControllersTask extends TimerTask { + @Override + public void run() { + mHandler.post(new Runnable() { + @Override + public void run() { + updateControlersVisibility(false); + mControlersVisible = false; + } + }); + + } + } + + private class UpdateSeekbarTask extends TimerTask { + + @Override + public void run() { + mHandler.post(new Runnable() { + + @Override + public void run() { + int currentPos = 0; + currentPos = mVideoView.getCurrentPosition(); + updateSeekbar(currentPos, mDuration); + } + }); + } + } + + private class BackToDetailTask extends TimerTask { + + @Override + public void run() { + mHandler.post(new Runnable() { + @Override + public void run() { + Intent intent = new Intent(PlayerActivity.this, DetailsActivity.class); + intent.putExtra(getResources().getString(R.string.movie), mSelectedMovie); + startActivity(intent); + } + }); + + } + } + + private void setupController() { + + int w = (int) (mMetrics.widthPixels * MEDIA_BAR_WIDTH); + int h = (int) (mMetrics.heightPixels * MEDIA_BAR_HEIGHT); + int marginLeft = (int) (mMetrics.widthPixels * MEDIA_BAR_LEFT_MARGIN); + int marginTop = (int) (mMetrics.heightPixels * MEDIA_BAR_TOP_MARGIN); + int marginRight = (int) (mMetrics.widthPixels * MEDIA_BAR_RIGHT_MARGIN); + int marginBottom = (int) (mMetrics.heightPixels * MEDIA_BAR_BOTTOM_MARGIN); + LayoutParams lp = new LayoutParams(w, h); + lp.setMargins(marginLeft, marginTop, marginRight, marginBottom); + mControllers.setLayoutParams(lp); + mStartText.setText(getResources().getString(R.string.init_text)); + mEndText.setText(getResources().getString(R.string.init_text)); + } + + private void setupControlsCallbacks() { + + mVideoView.setOnErrorListener(new OnErrorListener() { + + @Override + public boolean onError(MediaPlayer mp, int what, int extra) { + String msg = ""; + if (extra == MediaPlayer.MEDIA_ERROR_TIMED_OUT) { + msg = getString(R.string.video_error_media_load_timeout); + } else if (what == MediaPlayer.MEDIA_ERROR_SERVER_DIED) { + msg = getString(R.string.video_error_server_unaccessible); + } else { + msg = getString(R.string.video_error_unknown_error); + } + mVideoView.stopPlayback(); + mPlaybackState = PlaybackState.IDLE; + return false; + } + }); + + mVideoView.setOnPreparedListener(new OnPreparedListener() { + + @Override + public void onPrepared(MediaPlayer mp) { + Log.d(TAG, "onPrepared is reached"); + mDuration = mp.getDuration(); + mEndText.setText(Utils.formatMillis(mDuration)); + mSeekbar.setMax(mDuration); + restartSeekBarTimer(); + } + }); + + mVideoView.setOnCompletionListener(new OnCompletionListener() { + + @Override + public void onCompletion(MediaPlayer mp) { + stopSeekBarTimer(); + mPlaybackState = PlaybackState.IDLE; + updatePlayButton(PlaybackState.IDLE); + mControllersTimer = new Timer(); + mControllersTimer.schedule(new BackToDetailTask(), HIDE_CONTROLLER_TIME); + } + }); + } + + /* + * @Override public boolean onKeyDown(int keyCode, KeyEvent event) { return + * super.onKeyDown(keyCode, event); } + */ + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + int currentPos = 0; + int delta = (int) (mDuration / SCRUB_SEGMENT_DIVISOR); + if (delta < MIN_SCRUB_TIME) + delta = MIN_SCRUB_TIME; + + Log.v("keycode", "duration " + mDuration + " delta:" + delta); + if (!mControlersVisible) { + updateControlersVisibility(true); + } + switch (keyCode) { + case KeyEvent.KEYCODE_DPAD_CENTER: + return true; + case KeyEvent.KEYCODE_DPAD_DOWN: + return true; + case KeyEvent.KEYCODE_DPAD_LEFT: + currentPos = mVideoView.getCurrentPosition(); + currentPos -= delta; + if (currentPos > 0) + play(currentPos); + return true; + case KeyEvent.KEYCODE_DPAD_RIGHT: + currentPos = mVideoView.getCurrentPosition(); + currentPos += delta; + if (currentPos < mDuration) + play(currentPos); + return true; + case KeyEvent.KEYCODE_DPAD_UP: + return true; + } + + return super.onKeyDown(keyCode, event); + } + + private void updateSeekbar(int position, int duration) { + mSeekbar.setProgress(position); + mSeekbar.setMax(duration); + mStartText.setText(Utils.formatMillis(position)); + mEndText.setText(Utils.formatMillis(duration)); + } + + private void updatePlayButton(PlaybackState state) { + switch (state) { + case PLAYING: + mLoading.setVisibility(View.INVISIBLE); + mPlayPause.setVisibility(View.VISIBLE); + mPlayPause.setImageDrawable( + getResources().getDrawable(R.drawable.ic_pause_playcontrol_normal)); + break; + case PAUSED: + case IDLE: + mLoading.setVisibility(View.INVISIBLE); + mPlayPause.setVisibility(View.VISIBLE); + mPlayPause.setImageDrawable( + getResources().getDrawable(R.drawable.ic_play_playcontrol_normal)); + break; + case BUFFERING: + mPlayPause.setVisibility(View.INVISIBLE); + mLoading.setVisibility(View.VISIBLE); + break; + default: + break; + } + } + + private void updateMetadata(boolean visible) { + mVideoView.invalidate(); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + return true; + } + + private void loadViews() { + mVideoView = (VideoView) findViewById(R.id.videoView); + mStartText = (TextView) findViewById(R.id.startText); + mEndText = (TextView) findViewById(R.id.endText); + mSeekbar = (SeekBar) findViewById(R.id.seekBar); + mPlayPause = (ImageView) findViewById(R.id.playpause); + mLoading = (ProgressBar) findViewById(R.id.progressBar); + mControllers = findViewById(R.id.controllers); + mContainer = findViewById(R.id.container); + + mVideoView.setOnClickListener(mPlayPauseHandler); + } + + View.OnClickListener mPlayPauseHandler = new View.OnClickListener() { + public void onClick(View v) { + Log.d(TAG, "clicked play pause button"); + + if (!mControlersVisible) { + updateControlersVisibility(true); + } + + if (mPlaybackState == PlaybackState.PAUSED) { + mPlaybackState = PlaybackState.PLAYING; + updatePlayButton(mPlaybackState); + mVideoView.start(); + startControllersTimer(); + } else { + mVideoView.pause(); + mPlaybackState = PlaybackState.PAUSED; + updatePlayButton(PlaybackState.PAUSED); + stopControllersTimer(); + } + } + }; +} diff --git a/tv/src/main/java/de/nordgedanken/rpicms/Start.java b/tv/src/main/java/de/nordgedanken/rpicms/Start.java @@ -0,0 +1,13 @@ +package de.nordgedanken.rpicms; + +import android.app.Activity; +import android.os.Bundle; + +public class Start extends Activity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_start); + } +} diff --git a/tv/src/main/java/de/nordgedanken/rpicms/Utils.java b/tv/src/main/java/de/nordgedanken/rpicms/Utils.java @@ -0,0 +1,113 @@ +package de.nordgedanken.rpicms; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.graphics.Point; +import android.view.Display; +import android.view.WindowManager; +import android.widget.Toast; + +/** + * A collection of utility methods, all static. + */ +public class Utils { + + /* + * Making sure public utility methods remain static + */ + private Utils() { + } + + /** + * Returns the screen/display size + * + * @param context + * @return + */ + public static Point getDisplaySize(Context context) { + WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + Display display = wm.getDefaultDisplay(); + Point size = new Point(); + display.getSize(size); + int width = size.x; + int height = size.y; + return new Point(width, height); + } + + /** + * Shows an error dialog with a given text message. + * + * @param context + * @param errorString + */ + + public static final void showErrorDialog(Context context, String errorString) { + new AlertDialog.Builder(context).setTitle(R.string.error) + .setMessage(errorString) + .setPositiveButton(R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int id) { + dialog.cancel(); + } + }) + .create() + .show(); + } + + /** + * Shows a (long) toast + * + * @param context + * @param msg + */ + public static void showToast(Context context, String msg) { + Toast.makeText(context, msg, Toast.LENGTH_LONG).show(); + } + + /** + * Shows a (long) toast. + * + * @param context + * @param resourceId + */ + public static void showToast(Context context, int resourceId) { + Toast.makeText(context, context.getString(resourceId), Toast.LENGTH_LONG).show(); + } + + /** + * Formats time in milliseconds to hh:mm:ss string format. + * + * @param millis + * @return + */ + public static String formatMillis(int millis) { + String result = ""; + int hr = millis / 3600000; + millis %= 3600000; + int min = millis / 60000; + millis %= 60000; + int sec = millis / 1000; + if (hr > 0) { + result += hr + ":"; + } + if (min >= 0) { + if (min > 9) { + result += min + ":"; + } else { + result += "0" + min + ":"; + } + } + if (sec > 9) { + result += sec; + } else { + result += "0" + sec; + } + return result; + } + + public static int dpToPx(int dp, Context ctx) { + float density = ctx.getResources().getDisplayMetrics().density; + return Math.round((float) dp * density); + } +} diff --git a/tv/src/main/java/de/nordgedanken/rpicms/VideoDetailsFragment.java b/tv/src/main/java/de/nordgedanken/rpicms/VideoDetailsFragment.java @@ -0,0 +1,178 @@ +package de.nordgedanken.rpicms; + +import java.io.IOException; +import java.net.URI; +import java.util.Collections; +import java.util.List; + +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.os.AsyncTask; +import android.os.Bundle; +import android.support.v17.leanback.app.BackgroundManager; +import android.support.v17.leanback.app.DetailsFragment; +import android.support.v17.leanback.widget.Action; +import android.support.v17.leanback.widget.ArrayObjectAdapter; +import android.support.v17.leanback.widget.ClassPresenterSelector; +import android.support.v17.leanback.widget.DetailsOverviewRow; +import android.support.v17.leanback.widget.DetailsOverviewRowPresenter; +import android.support.v17.leanback.widget.HeaderItem; +import android.support.v17.leanback.widget.ListRow; +import android.support.v17.leanback.widget.ListRowPresenter; +import android.support.v17.leanback.widget.OnActionClickedListener; +import android.support.v17.leanback.widget.OnItemClickedListener; +import android.support.v17.leanback.widget.Row; +import android.util.DisplayMetrics; +import android.util.Log; +import android.widget.Toast; + +import com.squareup.picasso.Picasso; +import com.squareup.picasso.Target; + +public class VideoDetailsFragment extends DetailsFragment { + private static final String TAG = "VideoDetailsFragment"; + + private static final int ACTION_WATCH_TRAILER = 1; + private static final int ACTION_RENT = 2; + private static final int ACTION_BUY = 3; + + private static final int DETAIL_THUMB_WIDTH = 274; + private static final int DETAIL_THUMB_HEIGHT = 274; + + private static final int NUM_COLS = 10; + + private static final String MOVIE = "Movie"; + + private Movie selectedMovie; + + private Drawable mDefaultBackground; + private Target mBackgroundTarget; + private DisplayMetrics mMetrics; + + @Override + public void onCreate(Bundle savedInstanceState) { + Log.i(TAG, "onCreate DetailsFragment"); + super.onCreate(savedInstanceState); + + BackgroundManager backgroundManager = BackgroundManager.getInstance(getActivity()); + backgroundManager.attach(getActivity().getWindow()); + mBackgroundTarget = new PicassoBackgroundManagerTarget(backgroundManager); + + mDefaultBackground = getResources().getDrawable(R.drawable.default_background); + + mMetrics = new DisplayMetrics(); + getActivity().getWindowManager().getDefaultDisplay().getMetrics(mMetrics); + + selectedMovie = (Movie) getActivity().getIntent().getSerializableExtra(MOVIE); + new DetailRowBuilderTask().execute(selectedMovie); + + setOnItemClickedListener(getDefaultItemClickedListener()); + updateBackground(selectedMovie.getBackgroundImageURI()); + + } + + private class DetailRowBuilderTask extends AsyncTask<Movie, Integer, DetailsOverviewRow> { + @Override + protected DetailsOverviewRow doInBackground(Movie... movies) { + selectedMovie = movies[0]; + + DetailsOverviewRow row = new DetailsOverviewRow(selectedMovie); + try { + Bitmap poster = Picasso.with(getActivity()) + .load(selectedMovie.getCardImageUrl()) + .resize(dpToPx(DETAIL_THUMB_WIDTH, getActivity().getApplicationContext()), + dpToPx(DETAIL_THUMB_HEIGHT, getActivity().getApplicationContext())) + .centerCrop() + .get(); + row.setImageBitmap(getActivity(), poster); + } catch (IOException e) { + } + + row.addAction(new Action(ACTION_WATCH_TRAILER, getResources().getString( + R.string.watch_trailer_1), getResources().getString(R.string.watch_trailer_2))); + row.addAction(new Action(ACTION_RENT, getResources().getString(R.string.rent_1), + getResources().getString(R.string.rent_2))); + row.addAction(new Action(ACTION_BUY, getResources().getString(R.string.buy_1), + getResources().getString(R.string.buy_2))); + return row; + } + + @Override + protected void onPostExecute(DetailsOverviewRow detailRow) { + ClassPresenterSelector ps = new ClassPresenterSelector(); + DetailsOverviewRowPresenter dorPresenter = + new DetailsOverviewRowPresenter(new DetailsDescriptionPresenter()); + // set detail background and style + dorPresenter.setBackgroundColor(getResources().getColor(R.color.detail_background)); + dorPresenter.setStyleLarge(true); + dorPresenter.setOnActionClickedListener(new OnActionClickedListener() { + @Override + public void onActionClicked(Action action) { + if (action.getId() == ACTION_WATCH_TRAILER) { + Intent intent = new Intent(getActivity(), PlayerActivity.class); + intent.putExtra(getResources().getString(R.string.movie), selectedMovie); + intent.putExtra(getResources().getString(R.string.should_start), true); + startActivity(intent); + } + else { + Toast.makeText(getActivity(), action.toString(), Toast.LENGTH_SHORT).show(); + } + } + }); + + ps.addClassPresenter(DetailsOverviewRow.class, dorPresenter); + ps.addClassPresenter(ListRow.class, + new ListRowPresenter()); + + ArrayObjectAdapter adapter = new ArrayObjectAdapter(ps); + adapter.add(detailRow); + + String subcategories[] = { + getString(R.string.related_movies) + }; + List<Movie> list = MovieList.list; + Collections.shuffle(list); + ArrayObjectAdapter listRowAdapter = new ArrayObjectAdapter(new CardPresenter()); + for (int j = 0; j < NUM_COLS; j++) { + listRowAdapter.add(list.get(j % 5)); + } + + HeaderItem header = new HeaderItem(0, subcategories[0], null); + adapter.add(new ListRow(header, listRowAdapter)); + + setAdapter(adapter); + } + + } + + protected OnItemClickedListener getDefaultItemClickedListener() { + return new OnItemClickedListener() { + @Override + public void onItemClicked(Object item, Row row) { + if (item instanceof Movie) { + Movie movie = (Movie) item; + Intent intent = new Intent(getActivity(), DetailsActivity.class); + intent.putExtra(MOVIE, movie); + startActivity(intent); + } + } + }; + } + + protected void updateBackground(URI uri) { + Log.d(TAG, "uri" + uri); + Log.d(TAG, "metrics" + mMetrics.toString()); + Picasso.with(getActivity()) + .load(uri.toString()) + .resize(mMetrics.widthPixels, mMetrics.heightPixels) + .error(mDefaultBackground) + .into(mBackgroundTarget); + } + + public static int dpToPx(int dp, Context ctx) { + float density = ctx.getResources().getDisplayMetrics().density; + return Math.round((float) dp * density); + } +} diff --git a/tv/src/main/res/drawable-hdpi/app_icon_quantum.png b/tv/src/main/res/drawable-hdpi/app_icon_quantum.png Binary files differ. diff --git a/tv/src/main/res/drawable-hdpi/app_icon_quantum_card.png b/tv/src/main/res/drawable-hdpi/app_icon_quantum_card.png Binary files differ. diff --git a/tv/src/main/res/drawable-hdpi/card_background_default.9.png b/tv/src/main/res/drawable-hdpi/card_background_default.9.png Binary files differ. diff --git a/tv/src/main/res/drawable-hdpi/default_background.xml b/tv/src/main/res/drawable-hdpi/default_background.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> + +<shape xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="rectangle"> + <gradient + android:startColor="@color/background_gradient_start" + android:endColor="@color/background_gradient_end" + android:angle="-270" /> +</shape> +\ No newline at end of file diff --git a/tv/src/main/res/drawable-hdpi/grid_bg.png b/tv/src/main/res/drawable-hdpi/grid_bg.png Binary files differ. diff --git a/tv/src/main/res/drawable-hdpi/ic_launcher.png b/tv/src/main/res/drawable-hdpi/ic_launcher.png Binary files differ. diff --git a/tv/src/main/res/drawable-hdpi/movie.png b/tv/src/main/res/drawable-hdpi/movie.png Binary files differ. diff --git a/tv/src/main/res/drawable-hdpi/scrubber_disabled.png b/tv/src/main/res/drawable-hdpi/scrubber_disabled.png Binary files differ. diff --git a/tv/src/main/res/drawable-hdpi/scrubber_focussed.png b/tv/src/main/res/drawable-hdpi/scrubber_focussed.png Binary files differ. diff --git a/tv/src/main/res/drawable-hdpi/scrubber_normal.png b/tv/src/main/res/drawable-hdpi/scrubber_normal.png Binary files differ. diff --git a/tv/src/main/res/drawable-hdpi/scrubber_pressed.png b/tv/src/main/res/drawable-hdpi/scrubber_pressed.png Binary files differ. diff --git a/tv/src/main/res/drawable-hdpi/shadow7.9.png b/tv/src/main/res/drawable-hdpi/shadow7.9.png Binary files differ. diff --git a/tv/src/main/res/drawable-hdpi/star_icon.png b/tv/src/main/res/drawable-hdpi/star_icon.png Binary files differ. diff --git a/tv/src/main/res/drawable-hdpi/videos_by_google_banner.png b/tv/src/main/res/drawable-hdpi/videos_by_google_banner.png Binary files differ. diff --git a/tv/src/main/res/drawable-hdpi/videos_by_google_icon.png b/tv/src/main/res/drawable-hdpi/videos_by_google_icon.png Binary files differ. diff --git a/tv/src/main/res/drawable-mdpi/app_icon_quantum.png b/tv/src/main/res/drawable-mdpi/app_icon_quantum.png Binary files differ. diff --git a/tv/src/main/res/drawable-mdpi/app_icon_quantum_card.png b/tv/src/main/res/drawable-mdpi/app_icon_quantum_card.png Binary files differ. diff --git a/tv/src/main/res/drawable-mdpi/ic_launcher.png b/tv/src/main/res/drawable-mdpi/ic_launcher.png Binary files differ. diff --git a/tv/src/main/res/drawable-mdpi/videos_by_google_banner.png b/tv/src/main/res/drawable-mdpi/videos_by_google_banner.png Binary files differ. diff --git a/tv/src/main/res/drawable-mdpi/videos_by_google_icon.png b/tv/src/main/res/drawable-mdpi/videos_by_google_icon.png Binary files differ. diff --git a/tv/src/main/res/drawable-xhdpi/app_icon_quantum.png b/tv/src/main/res/drawable-xhdpi/app_icon_quantum.png Binary files differ. diff --git a/tv/src/main/res/drawable-xhdpi/app_icon_quantum_card.png b/tv/src/main/res/drawable-xhdpi/app_icon_quantum_card.png Binary files differ. diff --git a/tv/src/main/res/drawable-xhdpi/default_background.xml b/tv/src/main/res/drawable-xhdpi/default_background.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> + +<shape xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="rectangle"> + <gradient + android:startColor="@color/background_gradient_start" + android:endColor="@color/background_gradient_end" + android:angle="-270" /> +</shape> +\ No newline at end of file diff --git a/tv/src/main/res/drawable-xhdpi/grid_bg.png b/tv/src/main/res/drawable-xhdpi/grid_bg.png Binary files differ. diff --git a/tv/src/main/res/drawable-xhdpi/ic_launcher.png b/tv/src/main/res/drawable-xhdpi/ic_launcher.png Binary files differ. diff --git a/tv/src/main/res/drawable-xhdpi/ic_pause_playcontrol_focussed.png b/tv/src/main/res/drawable-xhdpi/ic_pause_playcontrol_focussed.png Binary files differ. diff --git a/tv/src/main/res/drawable-xhdpi/ic_pause_playcontrol_normal.png b/tv/src/main/res/drawable-xhdpi/ic_pause_playcontrol_normal.png Binary files differ. diff --git a/tv/src/main/res/drawable-xhdpi/ic_pause_playcontrol_pressed.png b/tv/src/main/res/drawable-xhdpi/ic_pause_playcontrol_pressed.png Binary files differ. diff --git a/tv/src/main/res/drawable-xhdpi/ic_play_action_focussed.png b/tv/src/main/res/drawable-xhdpi/ic_play_action_focussed.png Binary files differ. diff --git a/tv/src/main/res/drawable-xhdpi/ic_play_action_normal.png b/tv/src/main/res/drawable-xhdpi/ic_play_action_normal.png Binary files differ. diff --git a/tv/src/main/res/drawable-xhdpi/ic_play_action_pressed.png b/tv/src/main/res/drawable-xhdpi/ic_play_action_pressed.png Binary files differ. diff --git a/tv/src/main/res/drawable-xhdpi/ic_play_playcontrol_focussed.png b/tv/src/main/res/drawable-xhdpi/ic_play_playcontrol_focussed.png Binary files differ. diff --git a/tv/src/main/res/drawable-xhdpi/ic_play_playcontrol_normal.png b/tv/src/main/res/drawable-xhdpi/ic_play_playcontrol_normal.png Binary files differ. diff --git a/tv/src/main/res/drawable-xhdpi/ic_play_playcontrol_pressed.png b/tv/src/main/res/drawable-xhdpi/ic_play_playcontrol_pressed.png Binary files differ. diff --git a/tv/src/main/res/drawable-xhdpi/movie.png b/tv/src/main/res/drawable-xhdpi/movie.png Binary files differ. diff --git a/tv/src/main/res/drawable-xhdpi/star_icon.png b/tv/src/main/res/drawable-xhdpi/star_icon.png Binary files differ. diff --git a/tv/src/main/res/drawable-xhdpi/videos_by_google_banner.png b/tv/src/main/res/drawable-xhdpi/videos_by_google_banner.png Binary files differ. diff --git a/tv/src/main/res/drawable-xhdpi/videos_by_google_icon.png b/tv/src/main/res/drawable-xhdpi/videos_by_google_icon.png Binary files differ. diff --git a/tv/src/main/res/drawable-xxhdpi/app_icon_quantum.png b/tv/src/main/res/drawable-xxhdpi/app_icon_quantum.png Binary files differ. diff --git a/tv/src/main/res/drawable-xxhdpi/app_icon_quantum_card.png b/tv/src/main/res/drawable-xxhdpi/app_icon_quantum_card.png Binary files differ. diff --git a/tv/src/main/res/drawable-xxhdpi/ic_launcher.png b/tv/src/main/res/drawable-xxhdpi/ic_launcher.png Binary files differ. diff --git a/tv/src/main/res/drawable-xxhdpi/videos_by_google_banner.png b/tv/src/main/res/drawable-xxhdpi/videos_by_google_banner.png Binary files differ. diff --git a/tv/src/main/res/drawable-xxhdpi/videos_by_google_icon.png b/tv/src/main/res/drawable-xxhdpi/videos_by_google_icon.png Binary files differ. diff --git a/tv/src/main/res/drawable/app_icon_quantum.png b/tv/src/main/res/drawable/app_icon_quantum.png Binary files differ. diff --git a/tv/src/main/res/drawable/app_icon_quantum_card.png b/tv/src/main/res/drawable/app_icon_quantum_card.png Binary files differ. diff --git a/tv/src/main/res/drawable/details_img.png b/tv/src/main/res/drawable/details_img.png Binary files differ. diff --git a/tv/src/main/res/drawable/ic_action_a.png b/tv/src/main/res/drawable/ic_action_a.png Binary files differ. diff --git a/tv/src/main/res/drawable/ic_title.png b/tv/src/main/res/drawable/ic_title.png Binary files differ. diff --git a/tv/src/main/res/drawable/movie.png b/tv/src/main/res/drawable/movie.png Binary files differ. diff --git a/tv/src/main/res/drawable/player_bg_gradient_dark.xml b/tv/src/main/res/drawable/player_bg_gradient_dark.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android" > + + <gradient + android:angle="90" + android:centerColor="#00000000" + android:endColor="#B2000000" + android:startColor="#B2000000" + android:type="linear" /> + +</shape> +\ No newline at end of file diff --git a/tv/src/main/res/drawable/shadow7.9.png b/tv/src/main/res/drawable/shadow7.9.png Binary files differ. diff --git a/tv/src/main/res/drawable/videos_by_google_banner.png b/tv/src/main/res/drawable/videos_by_google_banner.png Binary files differ. diff --git a/tv/src/main/res/drawable/videos_by_google_icon.png b/tv/src/main/res/drawable/videos_by_google_icon.png Binary files differ. diff --git a/tv/src/main/res/layout/activity_details.xml b/tv/src/main/res/layout/activity_details.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<fragment xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/details_fragment" + android:name="de.nordgedanken.rpicms.VideoDetailsFragment" + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:context=".DetailsActivity" + tools:deviceIds="tv" /> diff --git a/tv/src/main/res/layout/activity_player.xml b/tv/src/main/res/layout/activity_player.xml @@ -0,0 +1,86 @@ +<?xml version="1.0" encoding="utf-8"?> +<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/container" + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:deviceIds="tv"> + + <VideoView + android:id="@+id/videoView" + android:layout_width="fill_parent" + android:layout_height="fill_parent" + android:layout_alignParentBottom="true" + android:layout_alignParentLeft="true" + android:layout_alignParentRight="true" + android:layout_alignParentTop="true" + android:layout_centerInParent="true" + android:layout_gravity="center" /> + + <RelativeLayout + android:id="@+id/controllers" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_alignBottom="@+id/videoView" + android:layout_alignLeft="@+id/videoView" + android:layout_alignRight="@+id/videoView" + android:layout_alignTop="@+id/videoView" + android:layout_centerInParent="true" + android:background="@drawable/player_bg_gradient_dark"> + + <ProgressBar + android:id="@+id/progressBar" + style="@android:style/Widget.ProgressBar.Horizontal" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_centerInParent="true" + android:visibility="gone" /> + + <RelativeLayout + android:layout_width="fill_parent" + android:layout_height="45dp" + android:layout_alignParentBottom="true"> + + <ImageView + android:id="@+id/playpause" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_alignParentLeft="true" + android:contentDescription="@+id/play_pause_description" + android:src="@drawable/ic_play_playcontrol_normal" /> + + <TextView + android:id="@+id/startText" + android:layout_width="wrap_content" + android:layout_height="fill_parent" + android:layout_marginLeft="5dp" + android:layout_toRightOf="@+id/playpause" + android:gravity="center_vertical" + android:maxLines="1" + android:text="@+id/init_text" + android:textColor="@color/white" /> + + <TextView + android:id="@+id/endText" + android:layout_width="wrap_content" + android:layout_height="fill_parent" + android:layout_alignParentRight="true" + android:layout_marginRight="16dp" + android:gravity="center_vertical" + android:maxLines="1" + android:text="@+id/init_text" + android:textColor="@color/white" /> + + <SeekBar + android:id="@+id/seekBar" + android:layout_width="fill_parent" + android:layout_height="wrap_content" + android:layout_centerVertical="true" + android:layout_gravity="center" + android:layout_marginLeft="5dp" + android:layout_marginRight="5dp" + android:layout_toLeftOf="@+id/endText" + android:layout_toRightOf="@+id/startText" /> + </RelativeLayout> + </RelativeLayout> +</RelativeLayout> diff --git a/tv/src/main/res/layout/activity_start.xml b/tv/src/main/res/layout/activity_start.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<fragment xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/main_browse_fragment" + android:name="de.nordgedanken.rpicms.MainFragment" + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:context=".Start" + tools:deviceIds="tv" + tools:ignore="MergeRootFrame" /> diff --git a/tv/src/main/res/values/colors.xml b/tv/src/main/res/values/colors.xml @@ -0,0 +1,17 @@ +<resources> + <color name="background_gradient_start">#000000</color> + <color name="background_gradient_end">#DDDDDD</color> + <color name="fastlane_background">#0096a6</color> + <color name="search_opaque">#ffaa3f</color> + <color name="detail_background">#0096a6</color> + <color name="soft_opaque">#30000000</color> + <color name="img_soft_opaque">#30FF0000</color> + <color name="img_full_opaque">#00000000</color> + <color name="black_opaque">#AA000000</color> + <color name="black">#59000000</color> + <color name="white">#FFFFFF</color> + <color name="orange_transparent">#AAFADCA7</color> + <color name="orange">#FADCA7</color> + <color name="yellow">#EEFF41</color> + <color name="default_background">#3d3d3d</color> +</resources> diff --git a/tv/src/main/res/values/strings.xml b/tv/src/main/res/values/strings.xml @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <string name="app_name">RPICMS</string> + <string name="browse_title"><![CDATA[Videos by Your Company]]></string> + <string name="related_movies">Related Videos</string> + <string name="vertical_grid_title"><![CDATA[Vertical Video Grid]]></string> + <string name="error">Error</string> + <string name="ok">OK</string> + <string name="pause">Pause</string> + <string name="play">Play</string> + <string name="stop">Stop</string> + <string name="init_text">00:00</string> + <string name="play_pause_description">Play Pause Button</string> + <string name="loading">Loading…</string> + <string name="no_video_found">No video was found</string> + <string name="about_app">About DemoCast Player</string> + <string name="version">Version: %1$s</string> + <string name="popular_header">Popular Videos</string> + <string name="preferences">PREFERENCES</string> + <string name="grid_view">Grid View</string> + <string name="send_feeback">Send Feedback</string> + <string name="personal_settings">Personal Settings</string> + <string name="watch_trailer_1">Watch trailer</string> + <string name="watch_trailer_2">FREE</string> + <string name="rent_1">Rent By Day</string> + <string name="rent_2">From $1.99</string> + <string name="buy_1">Buy and Own</string> + <string name="buy_2">AT $9.99</string> + <string name="movie">Movie</string> + <string name="should_start">shouldStart</string> + <string name="start_position">startPosition</string> + <string name="search_results">Search Results</string> + <string name="video_error_media_load_timeout">Media loading timed out</string> + <string name="video_error_server_unaccessible">Media server was not reachable</string> + <string name="video_error_unknown_error">Failed to load video</string> + <string name="oops">Oops</string> + +</resources> diff --git a/tv/src/main/res/values/styles.xml b/tv/src/main/res/values/styles.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <style name="AppTheme" parent="@style/Theme.Leanback"> + </style> +</resources> diff --git a/tv/tv.iml b/tv/tv.iml @@ -0,0 +1,90 @@ +<?xml version="1.0" encoding="UTF-8"?> +<module external.linked.project.path="$MODULE_DIR$" external.root.project.path="$MODULE_DIR$/.." external.system.id="GRADLE" external.system.module.group="RPICMS" external.system.module.version="unspecified" type="JAVA_MODULE" version="4"> + <component name="FacetManager"> + <facet type="android-gradle" name="Android-Gradle"> + <configuration> + <option name="GRADLE_PROJECT_PATH" value=":tv" /> + </configuration> + </facet> + <facet type="android" name="Android"> + <configuration> + <option name="SELECTED_BUILD_VARIANT" value="debug" /> + <option name="ASSEMBLE_TASK_NAME" value="assembleDebug" /> + <option name="COMPILE_JAVA_TASK_NAME" value="compileDebugJava" /> + <option name="ASSEMBLE_TEST_TASK_NAME" value="assembleDebugTest" /> + <option name="SOURCE_GEN_TASK_NAME" value="generateDebugSources" /> + <option name="ALLOW_USER_CONFIGURATION" value="false" /> + <option name="MANIFEST_FILE_RELATIVE_PATH" value="/src/main/AndroidManifest.xml" /> + <option name="RES_FOLDER_RELATIVE_PATH" value="/src/main/res" /> + <option name="RES_FOLDERS_RELATIVE_PATH" value="file://$MODULE_DIR$/src/main/res" /> + <option name="ASSETS_FOLDER_RELATIVE_PATH" value="/src/main/assets" /> + </configuration> + </facet> + </component> + <component name="NewModuleRootManager" inherit-compiler-output="false"> + <output url="file://$MODULE_DIR$/build/intermediates/classes/debug" /> + <exclude-output /> + <content url="file://$MODULE_DIR$"> + <sourceFolder url="file://$MODULE_DIR$/build/generated/source/r/debug" isTestSource="false" generated="true" /> + <sourceFolder url="file://$MODULE_DIR$/build/generated/source/aidl/debug" isTestSource="false" generated="true" /> + <sourceFolder url="file://$MODULE_DIR$/build/generated/source/buildConfig/debug" isTestSource="false" generated="true" /> + <sourceFolder url="file://$MODULE_DIR$/build/generated/source/rs/debug" isTestSource="false" generated="true" /> + <sourceFolder url="file://$MODULE_DIR$/build/generated/res/rs/debug" type="java-resource" /> + <sourceFolder url="file://$MODULE_DIR$/build/generated/source/r/test/debug" isTestSource="true" generated="true" /> + <sourceFolder url="file://$MODULE_DIR$/build/generated/source/aidl/test/debug" isTestSource="true" generated="true" /> + <sourceFolder url="file://$MODULE_DIR$/build/generated/source/buildConfig/test/debug" isTestSource="true" generated="true" /> + <sourceFolder url="file://$MODULE_DIR$/build/generated/source/rs/test/debug" isTestSource="true" generated="true" /> + <sourceFolder url="file://$MODULE_DIR$/build/generated/res/rs/test/debug" type="java-test-resource" /> + <sourceFolder url="file://$MODULE_DIR$/src/debug/res" type="java-resource" /> + <sourceFolder url="file://$MODULE_DIR$/src/debug/resources" type="java-resource" /> + <sourceFolder url="file://$MODULE_DIR$/src/debug/aidl" isTestSource="false" /> + <sourceFolder url="file://$MODULE_DIR$/src/debug/assets" isTestSource="false" /> + <sourceFolder url="file://$MODULE_DIR$/src/debug/java" isTestSource="false" /> + <sourceFolder url="file://$MODULE_DIR$/src/debug/jni" isTestSource="false" /> + <sourceFolder url="file://$MODULE_DIR$/src/debug/rs" isTestSource="false" /> + <sourceFolder url="file://$MODULE_DIR$/src/main/res" type="java-resource" /> + <sourceFolder url="file://$MODULE_DIR$/src/main/resources" type="java-resource" /> + <sourceFolder url="file://$MODULE_DIR$/src/main/aidl" isTestSource="false" /> + <sourceFolder url="file://$MODULE_DIR$/src/main/assets" isTestSource="false" /> + <sourceFolder url="file://$MODULE_DIR$/src/main/java" isTestSource="false" /> + <sourceFolder url="file://$MODULE_DIR$/src/main/jni" isTestSource="false" /> + <sourceFolder url="file://$MODULE_DIR$/src/main/rs" isTestSource="false" /> + <sourceFolder url="file://$MODULE_DIR$/src/androidTest/res" type="java-test-resource" /> + <sourceFolder url="file://$MODULE_DIR$/src/androidTest/resources" type="java-test-resource" /> + <sourceFolder url="file://$MODULE_DIR$/src/androidTest/aidl" isTestSource="true" /> + <sourceFolder url="file://$MODULE_DIR$/src/androidTest/assets" isTestSource="true" /> + <sourceFolder url="file://$MODULE_DIR$/src/androidTest/java" isTestSource="true" /> + <sourceFolder url="file://$MODULE_DIR$/src/androidTest/jni" isTestSource="true" /> + <sourceFolder url="file://$MODULE_DIR$/src/androidTest/rs" isTestSource="true" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/assets" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/bundles" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/classes" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/coverage-instrumented-classes" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/dependency-cache" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/dex" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/dex-cache" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/incremental" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/jacoco" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/javaResources" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/libs" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/lint" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/manifests" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/ndk" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/pre-dexed" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/proguard" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/res" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/rs" /> + <excludeFolder url="file://$MODULE_DIR$/build/intermediates/symbols" /> + <excludeFolder url="file://$MODULE_DIR$/build/outputs" /> + </content> + <orderEntry type="jdk" jdkName="Android API 20, L preview Platform" jdkType="Android SDK" /> + <orderEntry type="sourceFolder" forTests="false" /> + <orderEntry type="library" exported="" name="appcompat-v7-21.0.0-rc1" level="project" /> + <orderEntry type="library" exported="" name="support-annotations-21.0.0-rc1" level="project" /> + <orderEntry type="library" exported="" name="recyclerview-v7-21.0.0-rc1" level="project" /> + <orderEntry type="library" exported="" name="leanback-v17-21.0.0-rc1" level="project" /> + <orderEntry type="library" exported="" name="support-v4-21.0.0-rc1" level="project" /> + <orderEntry type="library" exported="" name="picasso-2.3.2" level="project" /> + </component> +</module> + diff --git a/upload.sh b/upload.sh @@ -0,0 +1,6 @@ +#!/bin/bash +git add . +git commit -m "auto commit" +git remote add origin git@github.com:RpicmsTeam/RPICMS.git +git push -f -u origin develop +sleep 30