diff --git a/.gitignore b/.gitignore index 39fb081..6b1e3b4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,6 @@ -*.iml -.gradle -/local.properties -/.idea/workspace.xml -/.idea/libraries -.DS_Store -/build -/captures -.externalNativeBuild +venv/ +__pycache__/ +*.pyc +.env +client_secret.json +token.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..53ea64e --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# Raspberry Pi Photo Frame + +A Python application that turns a Raspberry Pi 4B into a digital photo frame, fetching photos from a specific Google Photos album. + +## Features +- **Random Shuffle**: Randomly picks photos from a specified album. +- **Interval Rotation**: Changes photos every 30 seconds (configurable). +- **Night Mode**: Turns off the display (via `vcgencmd`) between 10 PM and 7 AM to save power. +- **Caching**: Caches downloaded images locally to reduce network usage. +- **PyGame**: Uses hardware-accelerated PyGame for smooth full-screen rendering. + +## Requirements +- Raspberry Pi (running Raspberry Pi OS) +- Python 3 +- Google Cloud Project with Photos Library API enabled. + +## Setup + +### 1. Google Cloud Setup +1. Go to [Google Cloud Console](https://console.cloud.google.com/). +2. Create a new project. +3. Enable **Google Photos Library API**. +4. Go to **APIs & Services > OAuth consent screen**. + - User Type: **External**. + - Add Test Users: Add your email address. + - Scopes: Add `.../auth/photoslibrary.readonly`. +5. Go to **Credentials**. + - Create Credentials -> **OAuth Client ID**. + - Application Type: **Desktop App**. + - Download the JSON file and save it as `client_secret.json` in the project root. + +### 2. Installation +1. Clone this repository. +2. Install dependencies: + ```bash + # If you need system dependencies for pygame/pillow: + sudo apt-get install libsdl2-2.0-0 libsdl2-image-2.0-0 libsdl2-mixer-2.0-0 libsdl2-ttf-2.0-0 libjpeg-dev zlib1g-dev + + # Install python deps + python3 -m venv venv + source venv/bin/activate + pip install -r requirements.txt + ``` + +### 3. Configuration +- Open `main.py` and set `ALBUM_TITLE` to the name of the album you want to display. +- Adjust `ROTATION_INTERVAL_MS` if desired. +- Adjust `START_HOUR` and `END_HOUR` in `scheduler.py` for night mode. + +### 4. First Run (Authentication) +Run the authentication script once to generate the token: +```bash +python3 auth.py +``` +Follow the instructions in the console to authorize the app. This will create `token.json`. + +### 5. Run the Frame +```bash +python3 main.py +``` + +### 6. Autostart on Boot (Raspberry Pi) +Create an autostart entry: +```bash +mkdir -p ~/.config/autostart +nano ~/.config/autostart/photoframe.desktop +``` +Content: +```ini +[Desktop Entry] +Type=Application +Name=PhotoFrame +Exec=/path/to/your/venv/bin/python3 /path/to/your/main.py +StartupNotify=false +Terminal=false +``` + +## Troubleshooting +- **Display not turning off**: Ensure `vcgencmd` works on your system. It is standard on Raspberry Pi OS. +- **No photos**: Check the album name match exactly in `main.py`. +- **PyGame video driver**: If running from SSH without a desktop, you might need to set `os.environ["SDL_VIDEODRIVER"] = "dummy"` for testing, or ensure you are running in the desktop environment. diff --git a/app/.gitignore b/app/.gitignore deleted file mode 100644 index 796b96d..0000000 --- a/app/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build diff --git a/app/build.gradle b/app/build.gradle deleted file mode 100644 index 435c224..0000000 --- a/app/build.gradle +++ /dev/null @@ -1,59 +0,0 @@ -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply plugin: 'kotlin-android-extensions' -apply plugin: 'kotlin-kapt' -android { - compileSdkVersion 26 - buildToolsVersion "$buildToolsVersion" - defaultConfig { - applicationId "ezjob.ghn.com.ezjob" - minSdkVersion 15 - targetSdkVersion 26 - versionCode 1 - versionName "1.0" - testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" - } - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' - } - } -} - -dependencies { - implementation fileTree(dir: 'libs', include: ['*.jar']) - androidTestImplementation('com.android.support.test.espresso:espresso-core:3.0.1', { - exclude group: 'com.android.support', module: 'support-annotations' - }) - implementation "com.android.support:appcompat-v7:$supportLibVersion" - testImplementation 'junit:junit:4.12' - implementation 'com.android.support.constraint:constraint-layout:1.0.2' - implementation "com.android.support:design:$supportLibVersion" - // kotlin - implementation "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version" - compile "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version" - compile "com.android.support:cardview-v7:$supportLibVersion" - - // retrofit - compile 'com.squareup.retrofit2:retrofit:2.3.0' - compile 'com.squareup.retrofit2:adapter-rxjava2:2.3.0' - compile 'com.squareup.retrofit2:converter-gson:2.3.0' - compile 'com.squareup.retrofit2:converter-moshi:2.3.0' - compile 'com.squareup.moshi:moshi:1.4.0' - compile 'com.squareup.moshi:moshi-kotlin:1.5.0' - compile 'com.squareup.okhttp3:logging-interceptor:3.4.1' - compile 'com.squareup.okhttp3:okhttp:3.4.1' - // rxandroid - compile 'io.reactivex.rxjava2:rxandroid:2.0.1' - - // Android component architecture - implementation 'android.arch.lifecycle:extensions:' + rootProject.archLifecycleVersion - - - // paper parcel - compile 'nz.bradcampbell:paperparcel:2.0.2' - compile 'nz.bradcampbell:paperparcel-kotlin:2.0.2' // Optional - kapt 'nz.bradcampbell:paperparcel-compiler:2.0.2' - -} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro deleted file mode 100644 index d2989bf..0000000 --- a/app/proguard-rules.pro +++ /dev/null @@ -1,25 +0,0 @@ -# Add project specific ProGuard rules here. -# By default, the flags in this file are appended to flags specified -# in C:\Users\Dell\AppData\Local\Android\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 *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile diff --git a/app/src/androidTest/java/ezjob/ghn/com/ezjob/ExampleInstrumentedTest.java b/app/src/androidTest/java/ezjob/ghn/com/ezjob/ExampleInstrumentedTest.java deleted file mode 100644 index 30e7d27..0000000 --- a/app/src/androidTest/java/ezjob/ghn/com/ezjob/ExampleInstrumentedTest.java +++ /dev/null @@ -1,26 +0,0 @@ -package ezjob.ghn.com.ezjob; - -import android.content.Context; -import android.support.test.InstrumentationRegistry; -import android.support.test.runner.AndroidJUnit4; - -import org.junit.Test; -import org.junit.runner.RunWith; - -import static org.junit.Assert.*; - -/** - * Instrumented test, which will execute on an Android device. - * - * @see Testing documentation - */ -@RunWith(AndroidJUnit4.class) -public class ExampleInstrumentedTest { - @Test - public void useAppContext() throws Exception { - // Context of the app under test. - Context appContext = InstrumentationRegistry.getTargetContext(); - - assertEquals("ezjob.ghn.com.ezjob", appContext.getPackageName()); - } -} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml deleted file mode 100644 index ce28efa..0000000 --- a/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/java/ezjob/ghn/com/ezjob/MainActivity.kt b/app/src/main/java/ezjob/ghn/com/ezjob/MainActivity.kt deleted file mode 100644 index 7db075a..0000000 --- a/app/src/main/java/ezjob/ghn/com/ezjob/MainActivity.kt +++ /dev/null @@ -1,12 +0,0 @@ -package ezjob.ghn.com.ezjob - -import android.support.v7.app.AppCompatActivity -import android.os.Bundle - -class MainActivity : AppCompatActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - } -} diff --git a/app/src/main/java/ezjob/ghn/com/ezjob/base/BaseAdapter.kt b/app/src/main/java/ezjob/ghn/com/ezjob/base/BaseAdapter.kt deleted file mode 100644 index 58354bb..0000000 --- a/app/src/main/java/ezjob/ghn/com/ezjob/base/BaseAdapter.kt +++ /dev/null @@ -1,35 +0,0 @@ -package ezjob.ghn.com.ezjob.base - -import android.support.v7.widget.RecyclerView -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup - -/** - * Created by Van T Tran on 08-Aug-17. - */ -abstract class BaseAdapter> : RecyclerView.Adapter() { - var dataSource: List = emptyList() - set(value) { - field = value - notifyDataSetChanged() - } - - override fun getItemCount() = dataSource.size - - override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): VH { - val inflater = LayoutInflater.from(parent?.context) - val view = inflater.inflate(getItemViewId(),parent,false) - return instantiateViewHolder(view) - } - - abstract fun getItemViewId(): Int - abstract fun instantiateViewHolder(view : View?) : VH - - override fun onBindViewHolder(holder: VH, position: Int) { - holder.onBind(getItem(position)) - } - - fun getItem(position: Int) = dataSource[position] - -} \ No newline at end of file diff --git a/app/src/main/java/ezjob/ghn/com/ezjob/base/BaseLifecycleActivity.kt b/app/src/main/java/ezjob/ghn/com/ezjob/base/BaseLifecycleActivity.kt deleted file mode 100644 index c34bfde..0000000 --- a/app/src/main/java/ezjob/ghn/com/ezjob/base/BaseLifecycleActivity.kt +++ /dev/null @@ -1,18 +0,0 @@ -package ezjob.ghn.com.ezjob.base - -import android.arch.lifecycle.AndroidViewModel -import android.arch.lifecycle.LifecycleRegistry -import android.arch.lifecycle.LifecycleRegistryOwner -import android.arch.lifecycle.ViewModelProviders -import android.support.v7.app.AppCompatActivity -import ezjob.ghn.com.ezjob.utils.unsafeLazy - -/** - * Created by Van T Tran on 08-Aug-17. - */ -abstract class BaseLifecycleActivity : AppCompatActivity(), LifecycleRegistryOwner { - abstract val viewModelClass: Class - protected val viewModel: T by unsafeLazy { ViewModelProviders.of(this).get(viewModelClass) } - private val registry = LifecycleRegistry(this) - override fun getLifecycle(): LifecycleRegistry = registry -} \ No newline at end of file diff --git a/app/src/main/java/ezjob/ghn/com/ezjob/base/BaseViewHolder.kt b/app/src/main/java/ezjob/ghn/com/ezjob/base/BaseViewHolder.kt deleted file mode 100644 index edc7630..0000000 --- a/app/src/main/java/ezjob/ghn/com/ezjob/base/BaseViewHolder.kt +++ /dev/null @@ -1,11 +0,0 @@ -package ezjob.ghn.com.ezjob.base - -import android.support.v7.widget.RecyclerView -import android.view.View - -/** - * Created by Van T Tran on 08-Aug-17. - */ -abstract class BaseViewHolder(itemView : View?) : RecyclerView.ViewHolder(itemView){ - abstract fun onBind(item : D) -} \ No newline at end of file diff --git a/app/src/main/java/ezjob/ghn/com/ezjob/data/AdvertisingIdClient.java b/app/src/main/java/ezjob/ghn/com/ezjob/data/AdvertisingIdClient.java deleted file mode 100644 index 2799e8d..0000000 --- a/app/src/main/java/ezjob/ghn/com/ezjob/data/AdvertisingIdClient.java +++ /dev/null @@ -1,134 +0,0 @@ -package ezjob.ghn.com.ezjob.data; - -/** - * Created by Dell on 5/25/2017. - */ - -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.ServiceConnection; -import android.content.pm.PackageManager; -import android.os.IBinder; -import android.os.IInterface; -import android.os.Looper; -import android.os.Parcel; -import android.os.RemoteException; - -import java.io.IOException; -import java.util.concurrent.LinkedBlockingQueue; - -public final class AdvertisingIdClient { - - public static AdInfo getAdvertisingIdInfo(Context context) throws Exception { - if (Looper.myLooper() == Looper.getMainLooper()) - throw new IllegalStateException("Cannot be called from the main thread"); - - try { - PackageManager pm = context.getPackageManager(); - pm.getPackageInfo("com.android.vending", 0); - } catch (Exception e) { - throw e; - } - - AdvertisingConnection connection = new AdvertisingConnection(); - Intent intent = new Intent("com.google.android.gms.ads.identifier.service.START"); - intent.setPackage("com.google.android.gms"); - if (context.bindService(intent, connection, Context.BIND_AUTO_CREATE)) { - try { - AdvertisingInterface adInterface = new AdvertisingInterface(connection.getBinder()); - AdInfo adInfo = new AdInfo(adInterface.getId(), adInterface.isLimitAdTrackingEnabled(true)); - return adInfo; - } catch (Exception exception) { - throw exception; - } finally { - context.unbindService(connection); - } - } - throw new IOException("Google Play connection failed"); - } - - public static final class AdInfo { - private final String advertisingId; - private final boolean limitAdTrackingEnabled; - - AdInfo(String advertisingId, boolean limitAdTrackingEnabled) { - this.advertisingId = advertisingId; - this.limitAdTrackingEnabled = limitAdTrackingEnabled; - } - - public String getId() { - return this.advertisingId; - } - - public boolean isLimitAdTrackingEnabled() { - return this.limitAdTrackingEnabled; - } - } - - private static final class AdvertisingConnection implements ServiceConnection { - private final LinkedBlockingQueue queue = new LinkedBlockingQueue(1); - boolean retrieved = false; - - public void onServiceConnected(ComponentName name, IBinder service) { - try { - this.queue.put(service); - } catch (InterruptedException localInterruptedException) { - } - } - - public void onServiceDisconnected(ComponentName name) { - } - - public IBinder getBinder() throws InterruptedException { - if (this.retrieved) throw new IllegalStateException(); - this.retrieved = true; - return this.queue.take(); - } - } - - private static final class AdvertisingInterface implements IInterface { - private IBinder binder; - - public AdvertisingInterface(IBinder pBinder) { - binder = pBinder; - } - - public IBinder asBinder() { - return binder; - } - - public String getId() throws RemoteException { - Parcel data = Parcel.obtain(); - Parcel reply = Parcel.obtain(); - String id; - try { - data.writeInterfaceToken("com.google.android.gms.ads.identifier.internal.IAdvertisingIdService"); - binder.transact(1, data, reply, 0); - reply.readException(); - id = reply.readString(); - } finally { - reply.recycle(); - data.recycle(); - } - return id; - } - - public boolean isLimitAdTrackingEnabled(boolean paramBoolean) throws RemoteException { - Parcel data = Parcel.obtain(); - Parcel reply = Parcel.obtain(); - boolean limitAdTracking; - try { - data.writeInterfaceToken("com.google.android.gms.ads.identifier.internal.IAdvertisingIdService"); - data.writeInt(paramBoolean ? 1 : 0); - binder.transact(2, data, reply, 0); - reply.readException(); - limitAdTracking = 0 != reply.readInt(); - } finally { - reply.recycle(); - data.recycle(); - } - return limitAdTracking; - } - } -} diff --git a/app/src/main/java/ezjob/ghn/com/ezjob/data/goody/ConnectivityInterceptor.kt b/app/src/main/java/ezjob/ghn/com/ezjob/data/goody/ConnectivityInterceptor.kt deleted file mode 100644 index ab1138b..0000000 --- a/app/src/main/java/ezjob/ghn/com/ezjob/data/goody/ConnectivityInterceptor.kt +++ /dev/null @@ -1,53 +0,0 @@ -package ezjob.ghn.com.ezjob.data.goody - -import android.content.Context -import android.util.Log -import ezjob.ghn.com.ezjob.data.model.ErrorResponse -import ezjob.ghn.com.ezjob.R -import ezjob.ghn.com.ezjob.utils.Constants -import ezjob.ghn.com.ezjob.utils.LiveNetworkMonitor -import ezjob.ghn.com.ezjob.utils.Singleton -import ezjob.ghn.com.ezjob.utils.unsafeLazy - -import java.io.IOException - -import okhttp3.Interceptor -import okhttp3.Response -import java.net.HttpURLConnection - -/** - * Created by Van T Tran on 09-Aug-17. - */ - - -class ConnectivityInterceptor(private val context: Context) : Interceptor { - - val networkMonitor: LiveNetworkMonitor by unsafeLazy { LiveNetworkMonitor(context) } - - @Throws(IOException::class) - override fun intercept(chain: Interceptor.Chain): Response { - if (!networkMonitor.isConnected) { - throw ErrorResponse(Constants.ERR_INTERNET_DISCONNECTED, context.resources.getString(R.string.error_disconnected)) - } - val response: Response - try { - response = chain.proceed(chain.request()) - - if (response.code() !== HttpURLConnection.HTTP_OK) { - - throw Singleton.parseError(response.body()?.string()!!)!! - } - return response - } catch (e: Exception) { - Log.d("API Err", "${e}") - if (e is ErrorResponse) - throw e - else - throw ErrorResponse(Constants.ERR_UNKNOWN, e.message) - } - - val builder = chain.request().newBuilder() - return chain.proceed(builder.build()) - } - -} \ No newline at end of file diff --git a/app/src/main/java/ezjob/ghn/com/ezjob/data/goody/GoodyRepository.kt b/app/src/main/java/ezjob/ghn/com/ezjob/data/goody/GoodyRepository.kt deleted file mode 100644 index 8f73a1c..0000000 --- a/app/src/main/java/ezjob/ghn/com/ezjob/data/goody/GoodyRepository.kt +++ /dev/null @@ -1,12 +0,0 @@ -package ezjob.ghn.com.ezjob.data.goody - -import ezjob.ghn.com.ezjob.data.model.Session - -/** - * Created by Van T Tran on 08-Aug-17. - */ -class GoodyRepository(val apiService: GoodyService) { - fun getSession(deviceId: String): io.reactivex.Single { - return apiService.getSession(deviceId = deviceId) - } -} \ No newline at end of file diff --git a/app/src/main/java/ezjob/ghn/com/ezjob/data/goody/GoodyRepositoryProvider.kt b/app/src/main/java/ezjob/ghn/com/ezjob/data/goody/GoodyRepositoryProvider.kt deleted file mode 100644 index 8c64c4a..0000000 --- a/app/src/main/java/ezjob/ghn/com/ezjob/data/goody/GoodyRepositoryProvider.kt +++ /dev/null @@ -1,12 +0,0 @@ -package ezjob.ghn.com.ezjob.data.goody - -import android.content.Context - -/** - * Created by Van T Tran on 08-Aug-17. - */ -object GoodyRepositoryProvider { - fun provideGoodyRepository(context : Context): GoodyRepository { - return GoodyRepository(GoodyService.create(context)) - } -} \ No newline at end of file diff --git a/app/src/main/java/ezjob/ghn/com/ezjob/data/goody/GoodyService.kt b/app/src/main/java/ezjob/ghn/com/ezjob/data/goody/GoodyService.kt deleted file mode 100644 index f113064..0000000 --- a/app/src/main/java/ezjob/ghn/com/ezjob/data/goody/GoodyService.kt +++ /dev/null @@ -1,42 +0,0 @@ -package ezjob.ghn.com.ezjob.data.goody - -import android.content.Context -import ezjob.ghn.com.ezjob.data.model.Session -import okhttp3.OkHttpClient -import okhttp3.logging.HttpLoggingInterceptor -import retrofit2.Retrofit -import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory -import retrofit2.converter.moshi.MoshiConverterFactory -import retrofit2.http.GET -import retrofit2.http.Query - - -/** - * Created by Van T Tran on 08-Aug-17. - */ - -interface GoodyService { - - @GET("checker_api/user/get_session") - fun getSession(@Query("device_id") deviceId: String): io.reactivex.Single - - - companion object Factory { - fun create(context : Context): GoodyService { - val interceptor = HttpLoggingInterceptor() - interceptor.setLevel(HttpLoggingInterceptor.Level.BODY) - val errorInterceptor = ConnectivityInterceptor(context) - val client = OkHttpClient.Builder() - .addInterceptor(interceptor) - .addInterceptor(errorInterceptor) - .build() - val retrofit = Retrofit.Builder() - .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) - .addConverterFactory(MoshiConverterFactory.create()) - .baseUrl("http://staging.goodyapp.vn") - .client(client) - .build() - return retrofit.create(GoodyService::class.java) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ezjob/ghn/com/ezjob/data/model/ErrorResponse.kt b/app/src/main/java/ezjob/ghn/com/ezjob/data/model/ErrorResponse.kt deleted file mode 100644 index 1ecc2cb..0000000 --- a/app/src/main/java/ezjob/ghn/com/ezjob/data/model/ErrorResponse.kt +++ /dev/null @@ -1,19 +0,0 @@ -package ezjob.ghn.com.ezjob.data.model - -import com.google.gson.annotations.SerializedName -import com.squareup.moshi.Json -import paperparcel.PaperParcel -import paperparcel.PaperParcelable -import java.net.ConnectException - -/** - * Created by Van T Tran on 09-Aug-17. - */ - -@PaperParcel -data class ErrorResponse(@SerializedName("errorCode") val error : Int, - @SerializedName("errorMessage") override val message: String? ) : PaperParcelable, Exception() { - companion object { - @JvmField val CREATOR = PaperParcelSession.CREATOR - } -} \ No newline at end of file diff --git a/app/src/main/java/ezjob/ghn/com/ezjob/data/model/Session.kt b/app/src/main/java/ezjob/ghn/com/ezjob/data/model/Session.kt deleted file mode 100644 index c3b7fa3..0000000 --- a/app/src/main/java/ezjob/ghn/com/ezjob/data/model/Session.kt +++ /dev/null @@ -1,37 +0,0 @@ -package ezjob.ghn.com.ezjob.data.model - -import android.os.Parcelable -import com.google.gson.annotations.SerializedName -import paperparcel.PaperParcel -import paperparcel.PaperParcelable - -@PaperParcel -data class Session( - - - @SerializedName("password") - val password: String? = null, - - @SerializedName("name") - val name: String? = null, - - @SerializedName("sessionToken") - val sessionToken: String? = null, - - @SerializedName("id") - val id: Int? = null, - - @SerializedName("deviceId") - val deviceId: String? = null, - - @SerializedName("status") - val status: Int? = null, - - @SerializedName("username") - val username: String? = null -) : PaperParcelable { - companion object { - @JvmField val CREATOR = PaperParcelSession.CREATOR - } - -} \ No newline at end of file diff --git a/app/src/main/java/ezjob/ghn/com/ezjob/data/model/Session/SessionLiveData.kt b/app/src/main/java/ezjob/ghn/com/ezjob/data/model/Session/SessionLiveData.kt deleted file mode 100644 index 112236c..0000000 --- a/app/src/main/java/ezjob/ghn/com/ezjob/data/model/Session/SessionLiveData.kt +++ /dev/null @@ -1,69 +0,0 @@ -package ezjob.ghn.com.ezjob.data.model.Session - -import android.arch.lifecycle.MediatorLiveData -import android.content.Context -import android.util.Log -import ezjob.ghn.com.ezjob.data.model.Session -import ezjob.ghn.com.ezjob.data.goody.GoodyRepositoryProvider -import ezjob.ghn.com.ezjob.utils.retrieveAdsInfo -import io.reactivex.Single -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.disposables.Disposable -import io.reactivex.schedulers.Schedulers - -/** - * Created by Van T Tran on 08-Aug-17. - */ -class SessionLiveData(val context: Context) : MediatorLiveData>() { - - private var disposal: Disposable? = null - - init { - getDeviceId() - } - - var deviceId: String? = null - set(value) { - field = value - value?.let { - getSession(it) - } - } - - fun getSession(deviceId: String) { - disposal = GoodyRepositoryProvider - .provideGoodyRepository(context) - .getSession(deviceId) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeOn(Schedulers.io()) - .subscribe { data, error -> - this@SessionLiveData.value = Pair(data, error) - } - } - - override fun onInactive() { - super.onInactive() - if (disposal?.isDisposed?.not() ?: false) { - disposal?.dispose() - } - } - - fun getDeviceId() { - disposal = Single.fromCallable { - retrieveAdsInfo(context) - }.observeOn(AndroidSchedulers.mainThread()) - .subscribeOn(Schedulers.io()) - .subscribe( - { deviceId -> - Log.d("DeviceId", "${deviceId}") - this@SessionLiveData.deviceId = deviceId - }, - { error -> - // do something - - Log.d("DeviceId", "${error}") - this@SessionLiveData.value = Pair(null!!, error) - } - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/ezjob/ghn/com/ezjob/data/model/Session/SessionViewModel.kt b/app/src/main/java/ezjob/ghn/com/ezjob/data/model/Session/SessionViewModel.kt deleted file mode 100644 index a9fee7e..0000000 --- a/app/src/main/java/ezjob/ghn/com/ezjob/data/model/Session/SessionViewModel.kt +++ /dev/null @@ -1,49 +0,0 @@ -package ezjob.ghn.com.ezjob.data.model.Session - -import android.app.Application -import android.arch.lifecycle.AndroidViewModel -import android.arch.lifecycle.MediatorLiveData -import android.arch.lifecycle.MutableLiveData -import ezjob.ghn.com.ezjob.data.model.Session - -/** - * Created by Van T Tran on 08-Aug-17. - */ -open class SessionViewModel(application: Application?) : AndroidViewModel(application) { - val deviceIdLiveData = MutableLiveData() - - val resultLiveData = SessionLiveData(application!!.applicationContext).apply { - this.addSource(deviceIdLiveData) { - it?.let { this.deviceId = it } - } - } - val isLoadingLiveData = MediatorLiveData().apply { - this.addSource(resultLiveData) { - this.value = false - } - } - val throwableLiveData = MediatorLiveData().apply { - this.addSource(resultLiveData) { - it?.second?.let { - this.value = it - } - } - } - val sessionLiveData = MediatorLiveData().apply { - this.addSource(resultLiveData) { - it?.first?.let { - this.value = it - } - } - } - - fun callAgain() { - if (resultLiveData.deviceId != null) { - resultLiveData.getSession(resultLiveData.deviceId!!) - } else { - resultLiveData.getDeviceId() - - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/ezjob/ghn/com/ezjob/data/model/UserSample/UserAdapter.kt b/app/src/main/java/ezjob/ghn/com/ezjob/data/model/UserSample/UserAdapter.kt deleted file mode 100644 index f3913e0..0000000 --- a/app/src/main/java/ezjob/ghn/com/ezjob/data/model/UserSample/UserAdapter.kt +++ /dev/null @@ -1,28 +0,0 @@ -package ezjob.ghn.com.ezjob.data.model.UserSample - -import android.view.View -import android.widget.TextView -import ezjob.ghn.com.ezjob.R -import ezjob.ghn.com.ezjob.base.BaseAdapter -import ezjob.ghn.com.ezjob.base.BaseViewHolder -import ezjob.ghn.com.ezjob.data.sample.User - -/** - * Created by Van T Tran on 08-Aug-17. - */ -class UserAdapter: BaseAdapter(){ - override fun getItemViewId() = R.layout.view_item - - override fun instantiateViewHolder(view: View?) = UserViewHolder(view) - - - class UserViewHolder(itemView : View?) : BaseViewHolder(itemView){ - val tvName by lazy { itemView?.findViewById(R.id.tvName) } - val tvDes by lazy { itemView?.findViewById(R.id.tvDescription) } - override fun onBind(item: User) { - tvName?.text = item.login - tvDes?.text = item.url - } - - } -} \ No newline at end of file diff --git a/app/src/main/java/ezjob/ghn/com/ezjob/data/model/UserSample/UserLiveData.kt b/app/src/main/java/ezjob/ghn/com/ezjob/data/model/UserSample/UserLiveData.kt deleted file mode 100644 index da6b373..0000000 --- a/app/src/main/java/ezjob/ghn/com/ezjob/data/model/UserSample/UserLiveData.kt +++ /dev/null @@ -1,39 +0,0 @@ -package ezjob.ghn.com.ezjob.data.model.UserSample - -import android.arch.lifecycle.MediatorLiveData -import android.util.Log -import ezjob.ghn.com.ezjob.data.sample.GithubRepositoryProvider -import ezjob.ghn.com.ezjob.data.sample.Result -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.disposables.Disposable -import io.reactivex.schedulers.Schedulers - -/** - * Created by Van T Tran on 08-Aug-17. - */ -class UserLiveData : MediatorLiveData>(){ - - private var disposal : Disposable? = null - - var location : String? = null - set(value) { - value?.let { - disposal = GithubRepositoryProvider - .provideGithubRepository() - .searchUsers(it,"Java") - .observeOn(AndroidSchedulers.mainThread()) - .subscribeOn(Schedulers.io()) - .subscribe { data,error -> - Log.d("Result", "There are ${data.items.size} users at ${location} using JAVA") - this@UserLiveData.value = Pair(data,error) - } - } - } - - override fun onInactive() { - super.onInactive() - if (disposal?.isDisposed?.not() ?:false){ - disposal?.dispose() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ezjob/ghn/com/ezjob/data/model/UserSample/UserViewModel.kt b/app/src/main/java/ezjob/ghn/com/ezjob/data/model/UserSample/UserViewModel.kt deleted file mode 100644 index c832261..0000000 --- a/app/src/main/java/ezjob/ghn/com/ezjob/data/model/UserSample/UserViewModel.kt +++ /dev/null @@ -1,44 +0,0 @@ -package ezjob.ghn.com.ezjob.data.model.UserSample - -import android.app.Application -import android.arch.lifecycle.AndroidViewModel -import android.arch.lifecycle.MediatorLiveData -import android.arch.lifecycle.MutableLiveData -import ezjob.ghn.com.ezjob.data.sample.Result - -/** - * Created by Van T Tran on 08-Aug-17. - */ -open class UserViewModel(application: Application?) : AndroidViewModel(application){ - private val locationLiveData = MutableLiveData() - - val resultLiveData = UserLiveData().apply { - this.addSource(locationLiveData){ - it?.let { this.location = it } - } - } - val isLoadingLiveData = MediatorLiveData().apply { - this.addSource(resultLiveData){ - this.value = false - } - } - val throwableLiveData = MediatorLiveData().apply { - this.addSource(resultLiveData){ - it?.second?.let { - this.value = it - } - } - } - val userLiveData = MediatorLiveData().apply { - this.addSource(resultLiveData){ - it?.first?.let { - this.value = it - } - } - } - - fun setLocation(location : String ){ - locationLiveData.value = location - isLoadingLiveData.value = true - } -} \ No newline at end of file diff --git a/app/src/main/java/ezjob/ghn/com/ezjob/data/sample/GithubApiService.kt b/app/src/main/java/ezjob/ghn/com/ezjob/data/sample/GithubApiService.kt deleted file mode 100644 index ad5af5f..0000000 --- a/app/src/main/java/ezjob/ghn/com/ezjob/data/sample/GithubApiService.kt +++ /dev/null @@ -1,33 +0,0 @@ -package ezjob.ghn.com.ezjob.data.sample - -import retrofit2.Retrofit -import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory -import retrofit2.converter.moshi.MoshiConverterFactory -import retrofit2.http.GET -import retrofit2.http.Query - -/** - * Created by Van T Tran on 08-Aug-17. - */ - -interface GithubApiService{ - - @GET("search/users") - fun search(@Query("q") query: String, - @Query("page") page : Int = 1, - @Query("per_page") perPage : Int = 20) : io.reactivex.Single - - - - - companion object Factory{ - fun create() : GithubApiService { - val retrofit = Retrofit.Builder() - .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) - .addConverterFactory(MoshiConverterFactory.create()) - .baseUrl("https://api.github.com") - .build() - return retrofit.create(GithubApiService::class.java) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ezjob/ghn/com/ezjob/data/sample/GithubRepository.kt b/app/src/main/java/ezjob/ghn/com/ezjob/data/sample/GithubRepository.kt deleted file mode 100644 index 3923621..0000000 --- a/app/src/main/java/ezjob/ghn/com/ezjob/data/sample/GithubRepository.kt +++ /dev/null @@ -1,10 +0,0 @@ -package ezjob.ghn.com.ezjob.data.sample - -/** - * Created by Van T Tran on 08-Aug-17. - */ -class GithubRepository(val apiService: GithubApiService) { - fun searchUsers(location: String, language: String): io.reactivex.Single { - return apiService.search(query = "deviceId:$location language:$language") - } -} \ No newline at end of file diff --git a/app/src/main/java/ezjob/ghn/com/ezjob/data/sample/GithubRepositoryProvider.kt b/app/src/main/java/ezjob/ghn/com/ezjob/data/sample/GithubRepositoryProvider.kt deleted file mode 100644 index b67b488..0000000 --- a/app/src/main/java/ezjob/ghn/com/ezjob/data/sample/GithubRepositoryProvider.kt +++ /dev/null @@ -1,13 +0,0 @@ -package ezjob.ghn.com.ezjob.data.sample - -import ezjob.ghn.com.ezjob.data.sample.GithubApiService -import ezjob.ghn.com.ezjob.data.sample.GithubRepository - -/** - * Created by Van T Tran on 08-Aug-17. - */ -object GithubRepositoryProvider { - fun provideGithubRepository(): GithubRepository { - return GithubRepository(GithubApiService.create()) - } -} \ No newline at end of file diff --git a/app/src/main/java/ezjob/ghn/com/ezjob/data/sample/User.kt b/app/src/main/java/ezjob/ghn/com/ezjob/data/sample/User.kt deleted file mode 100644 index 5d2587c..0000000 --- a/app/src/main/java/ezjob/ghn/com/ezjob/data/sample/User.kt +++ /dev/null @@ -1,25 +0,0 @@ -package ezjob.ghn.com.ezjob.data.sample - -import com.google.gson.annotations.SerializedName - -/** - * Created by Van T Tran on 08-Aug-17. - */ -data class User( - @SerializedName("login") val login : String, - @SerializedName("id") val id: Long, - @SerializedName("url") val url: String, - @SerializedName("html_url") val htmlUrl: String, - @SerializedName("followers_url") val followersUrl: String, - @SerializedName("following_url") val followingUrl: String, - @SerializedName("starred_url") val starredUrl: String, - @SerializedName("gists_url") val gistsUrl: String, - @SerializedName("type") val type: String, - @SerializedName("score") val score: Int -) - -data class Result( - val total_count: Int, - val incomplete_results : Boolean, - val items : List -) \ No newline at end of file diff --git a/app/src/main/java/ezjob/ghn/com/ezjob/screens/search/MainActivity.kt b/app/src/main/java/ezjob/ghn/com/ezjob/screens/search/MainActivity.kt deleted file mode 100644 index 97695a7..0000000 --- a/app/src/main/java/ezjob/ghn/com/ezjob/screens/search/MainActivity.kt +++ /dev/null @@ -1,54 +0,0 @@ -package ezjob.ghn.com.ezjob.screens.search - -import android.arch.lifecycle.Observer -import android.os.Bundle -import android.support.design.widget.Snackbar -import ezjob.ghn.com.ezjob.R -import ezjob.ghn.com.ezjob.base.BaseLifecycleActivity -import ezjob.ghn.com.ezjob.data.model.UserSample.UserAdapter -import ezjob.ghn.com.ezjob.data.model.UserSample.UserViewModel -import ezjob.ghn.com.ezjob.data.sample.Result - -import kotlinx.android.synthetic.main.activity_repos.* - -class MainActivity : BaseLifecycleActivity() { - - override val viewModelClass = UserViewModel::class.java - - - private val adapter = UserAdapter() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_repos) - - rv.setHasFixedSize(true) - rv.adapter = adapter - - - if (savedInstanceState == null) - viewModel.setLocation("Lagos") - observeLiveData(); - } - - private fun observeLiveData() { - viewModel.isLoadingLiveData.observe(this, Observer { - it?.let { lRefresh.isRefreshing = it } - }) - viewModel.resultLiveData.observe(this, Observer> { - it?.let { adapter.dataSource = it?.first?.items } - }) - viewModel.throwableLiveData.observe(this, Observer { - it?.let { Snackbar.make(rv, it.localizedMessage, Snackbar.LENGTH_LONG).show() } - }) - } - - -// private fun observerLiveData(){ -// viewModel.isLoadingLiveData.observe(this, Observer { -// -// }) -// } - - -} diff --git a/app/src/main/java/ezjob/ghn/com/ezjob/screens/welcome/WelcomeActivity.kt b/app/src/main/java/ezjob/ghn/com/ezjob/screens/welcome/WelcomeActivity.kt deleted file mode 100644 index e2eeb65..0000000 --- a/app/src/main/java/ezjob/ghn/com/ezjob/screens/welcome/WelcomeActivity.kt +++ /dev/null @@ -1,55 +0,0 @@ -package ezjob.ghn.com.ezjob.screens.welcome - -import android.arch.lifecycle.Observer -import android.os.Bundle -import android.widget.Toast -import ezjob.ghn.com.ezjob.R -import ezjob.ghn.com.ezjob.base.BaseLifecycleActivity -import ezjob.ghn.com.ezjob.data.model.ErrorResponse -import ezjob.ghn.com.ezjob.data.model.Session -import ezjob.ghn.com.ezjob.data.model.Session.SessionViewModel -import ezjob.ghn.com.ezjob.utils.Constants -import ezjob.ghn.com.ezjob.utils.DialogFactory - -class WelcomeActivity : BaseLifecycleActivity() { - - override val viewModelClass = SessionViewModel::class.java - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_welcome) - observeLiveData() - } - - - private fun observeLiveData() { - viewModel.isLoadingLiveData.observe(this, Observer { - // it?.let { lRefresh.isRefreshing = it } - }) - viewModel.resultLiveData.observe(this, Observer> { - it?.let { - //TODO add - Toast.makeText(this, it.first.name, Toast.LENGTH_LONG).show() - } - }) - viewModel.throwableLiveData.observe(this, Observer { - it?.let { - if (it is ErrorResponse) - when (it.error) { - Constants.ERR_SESSION_NOT_VALID -> { - DialogFactory.createSimpleOkErrorDialog( - this, - "", - getString(R.string.error_session_invalid).replace("_d", viewModel.resultLiveData.deviceId!!), - getString(R.string.dialog_try_again), - { - viewModel.callAgain() - }).show() - } - - } - } - }) - } - -} diff --git a/app/src/main/java/ezjob/ghn/com/ezjob/utils/LiveNetworkMonitor.kt b/app/src/main/java/ezjob/ghn/com/ezjob/utils/LiveNetworkMonitor.kt deleted file mode 100644 index ddc1fd3..0000000 --- a/app/src/main/java/ezjob/ghn/com/ezjob/utils/LiveNetworkMonitor.kt +++ /dev/null @@ -1,21 +0,0 @@ -package ezjob.ghn.com.ezjob.utils - -import android.content.Context -import android.net.ConnectivityManager -import ezjob.ghn.com.ezjob.utils.NetworkMonitor - -/** - * Created by Dell on 5/15/2017. - */ - -class LiveNetworkMonitor(override val context: Context) : NetworkMonitor { - - override val isConnected: Boolean - get() { - val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - - val activeNetwork = cm.activeNetworkInfo - return activeNetwork != null && activeNetwork.isConnectedOrConnecting - } - -} diff --git a/app/src/main/java/ezjob/ghn/com/ezjob/utils/NetworkMonitor.kt b/app/src/main/java/ezjob/ghn/com/ezjob/utils/NetworkMonitor.kt deleted file mode 100644 index 2f47a45..0000000 --- a/app/src/main/java/ezjob/ghn/com/ezjob/utils/NetworkMonitor.kt +++ /dev/null @@ -1,13 +0,0 @@ -package ezjob.ghn.com.ezjob.utils - -import android.content.Context - -/** - * Created by Dell on 5/15/2017. - */ - -interface NetworkMonitor { - val isConnected: Boolean - - val context: Context -} diff --git a/app/src/main/java/ezjob/ghn/com/ezjob/utils/Utils.kt b/app/src/main/java/ezjob/ghn/com/ezjob/utils/Utils.kt deleted file mode 100644 index f7ccdcc..0000000 --- a/app/src/main/java/ezjob/ghn/com/ezjob/utils/Utils.kt +++ /dev/null @@ -1,104 +0,0 @@ -package ezjob.ghn.com.ezjob.utils - -import android.app.Dialog -import android.content.Context -import android.view.View -import android.view.Window -import java.io.IOException -import android.widget.TextView -import ezjob.ghn.com.ezjob.data.model.ErrorResponse -import com.google.gson.Gson -import ezjob.ghn.com.ezjob.R -import ezjob.ghn.com.ezjob.data.AdvertisingIdClient - - -/** - * Created by Van T Tran on 08-Aug-17. - */ - -fun unsafeLazy(initializer: () -> T) = lazy(LazyThreadSafetyMode.NONE, initializer) - - -fun retrieveAdsInfo(context: Context): String { - var adInfo: AdvertisingIdClient.AdInfo? = null - - try { - adInfo = AdvertisingIdClient.getAdvertisingIdInfo(context) - return adInfo!!.getId() - } catch (e: IOException) { - e.printStackTrace() - } - - return "" -} - -object Constants { - val RESPONSE_OK = 1 - val ERR_SESSION_TIMEOUT = 440 - val ERR_INTERNET_DISCONNECTED = 441 - val ERR_UNKNOWN = 443 - val ERR_SESSION_NOT_VALID = 200 -} - -object Singleton { -//// val moshi : Moshi by lazy { Moshi.Builder() -//// .add(KotlinJsonAdapterFactory()) -//// .build() } -// // errorAdapter = JsonAdapter<>() -//// private val errorAdapter: JsonAdapter by lazy { -//// moshi.adapter(ErrorResponse::class.java) -//// } -// -// inline fun convertToJson(data: T): String { -// val jsonAdapter: JsonAdapter = moshi.adapter(T::class.java) -// -// return jsonAdapter.toJson(data) -// } -// -// fun convertFromJson(jsonStr: String, dataClass: Class): T? { -// val jsonAdapter: JsonAdapter = moshi.adapter(dataClass) -// -// return jsonAdapter.fromJson(jsonStr) -// } - - fun parseError(jsonStr: String): ErrorResponse? { -// val moshi = Moshi.Builder() -// .add(KotlinJsonAdapterFactory()) -// .build() -// var adapter = moshi.adapter(ErrorResponse::class.java) -// return adapter.fromJson(jsonStr) - val gson = Gson() - return gson.fromJson(jsonStr, ErrorResponse::class.java) - } - -// fun getGson(): Gson { -// var gson = GsonBuilder() -// .registerTypeAdapterFactory(AutoValueGsonFactory.create()) -// .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") -// .create() -// return gson -// } - -} - -object DialogFactory { - fun createSimpleOkErrorDialog(context: Context, title: String, message: String, buttonTextResId: String, callback: () -> Unit): Dialog { - val dialog = Dialog(context) - dialog.requestWindowFeature(Window.FEATURE_NO_TITLE) - dialog.setContentView(R.layout.dialog_confirm_layout) - - dialog.findViewById(R.id.confirm_dialog_message).text = message - val tvCancel = dialog.findViewById(R.id.dialog_confirm_negative_btn) as TextView - tvCancel.visibility = View.GONE - dialog.findViewById(R.id.confirm_dialog_divider).setVisibility(View.INVISIBLE) - val tvDelete = dialog.findViewById(R.id.dialog_confirm_positive_btn) as TextView - tvDelete.text = buttonTextResId - tvDelete.setOnClickListener(object : View.OnClickListener { - override fun onClick(v: View) { - dialog.dismiss() - callback() - } - }) - return dialog - } -} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml deleted file mode 100644 index 1cd2a36..0000000 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ /dev/null @@ -1,113 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 1440f5e..0000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - diff --git a/app/src/main/res/layout/activity_repos.xml b/app/src/main/res/layout/activity_repos.xml deleted file mode 100644 index 3e0431a..0000000 --- a/app/src/main/res/layout/activity_repos.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - diff --git a/app/src/main/res/layout/activity_welcome.xml b/app/src/main/res/layout/activity_welcome.xml deleted file mode 100644 index 1f0c1f3..0000000 --- a/app/src/main/res/layout/activity_welcome.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - diff --git a/app/src/main/res/layout/dialog_confirm_layout.xml b/app/src/main/res/layout/dialog_confirm_layout.xml deleted file mode 100644 index cf02363..0000000 --- a/app/src/main/res/layout/dialog_confirm_layout.xml +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/view_item.xml b/app/src/main/res/layout/view_item.xml deleted file mode 100644 index 13a4dfb..0000000 --- a/app/src/main/res/layout/view_item.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 00f9eaa..0000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml deleted file mode 100644 index 00f9eaa..0000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 5507303..0000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png deleted file mode 100644 index 4e526c9..0000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png deleted file mode 100644 index 8fab6a3..0000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 6bc7fcd..0000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png deleted file mode 100644 index 2c38c71..0000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png deleted file mode 100644 index 1eecc0e..0000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index ec87dce..0000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png deleted file mode 100644 index 072467e..0000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png deleted file mode 100644 index 05ca079..0000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index 6f67f21..0000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png deleted file mode 100644 index 78a6b7a..0000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png deleted file mode 100644 index 8bac0f2..0000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 0327e13..0000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png deleted file mode 100644 index 68ebe33..0000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png deleted file mode 100644 index bacd3e7..0000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml deleted file mode 100644 index e4c8f0c..0000000 --- a/app/src/main/res/values/colors.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - #3F51B5 - #303F9F - #FF4081 - - - #4a4a4a - #d9d9d9 - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml deleted file mode 100644 index b15349f..0000000 --- a/app/src/main/res/values/strings.xml +++ /dev/null @@ -1,7 +0,0 @@ - - EzJob - Không có kết nối đến dữ liệu! - Hãy gửi mã số _d cho quản lý để kích hoạt tài khoản - Thử lại - OK - diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml deleted file mode 100644 index 5885930..0000000 --- a/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - diff --git a/app/src/test/java/ezjob/ghn/com/ezjob/ExampleUnitTest.java b/app/src/test/java/ezjob/ghn/com/ezjob/ExampleUnitTest.java deleted file mode 100644 index f683531..0000000 --- a/app/src/test/java/ezjob/ghn/com/ezjob/ExampleUnitTest.java +++ /dev/null @@ -1,17 +0,0 @@ -package ezjob.ghn.com.ezjob; - -import org.junit.Test; - -import static org.junit.Assert.*; - -/** - * Example local unit test, which will execute on the development machine (host). - * - * @see Testing documentation - */ -public class ExampleUnitTest { - @Test - public void addition_isCorrect() throws Exception { - assertEquals(4, 2 + 2); - } -} \ No newline at end of file diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..6cd3b6f --- /dev/null +++ b/auth.py @@ -0,0 +1,61 @@ +import os +import json +import logging +import pickle +import time +from google_auth_oauthlib.flow import InstalledAppFlow +from google.auth.transport.requests import Request +from google.oauth2.credentials import Credentials + +# Scopes required to read photos +SCOPES = ['https://www.googleapis.com/auth/photoslibrary.readonly'] + +TOKEN_FILE = 'token.json' +CREDENTIALS_FILE = 'client_secret.json' + +def authenticate(): + creds = None + # The file token.json stores the user's access and refresh tokens, and is + # created automatically when the authorization flow completes for the first + # time. + if os.path.exists(TOKEN_FILE): + try: + creds = Credentials.from_authorized_user_file(TOKEN_FILE, SCOPES) + except Exception as e: + logging.error(f"Error loading credentials: {e}") + + # If there are no (valid) credentials available, let the user log in. + if not creds or not creds.valid: + if creds and creds.expired and creds.refresh_token: + try: + creds.refresh(Request()) + except Exception as e: + logging.error(f"Error refreshing token: {e}") + creds = None + + if not creds: + if not os.path.exists(CREDENTIALS_FILE): + logging.error(f"Credentials file '{CREDENTIALS_FILE}' not found. Please download it from Google Cloud Console.") + return None + + flow = InstalledAppFlow.from_client_secrets_file( + CREDENTIALS_FILE, SCOPES) + + # run_console() is deprecated/removed in newer versions. + # Using run_local_server() is standard. + # On a headless Pi, you can use SSH port forwarding: ssh -L 8080:localhost:8080 pi@ip + logging.info("Starting authentication flow...") + logging.info("If running headless, ensure you forward the port (e.g. 8080).") + + # Using a fixed port helps with port forwarding + creds = flow.run_local_server(port=8080, open_browser=False) + + # Save the credentials for the next run + with open(TOKEN_FILE, 'w') as token: + token.write(creds.to_json()) + + return creds + +if __name__ == '__main__': + logging.basicConfig(level=logging.INFO) + authenticate() diff --git a/build.gradle b/build.gradle deleted file mode 100644 index 0956b48..0000000 --- a/build.gradle +++ /dev/null @@ -1,35 +0,0 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. - -buildscript { - - ext.kotlin_version = '1.1.3-2' - repositories { - google() - jcenter() - } - dependencies { - classpath 'com.android.tools.build:gradle:3.0.0-alpha6' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - - - // NOTE: Do not place your application dependencies here; they belong - // in the individual module build.gradle files - } -} - -allprojects { - repositories { - google() - jcenter() - } -} - -task clean(type: Delete) { - delete rootProject.buildDir -} -ext { - buildToolsVersion = "26.0.0" - supportLibVersion = "26.1.0" - archLifecycleVersion = "1.0.0-alpha5" - archRoomVersion = "1.0.0-alpha5" -} \ No newline at end of file diff --git a/cache_manager.py b/cache_manager.py new file mode 100644 index 0000000..3954ce6 --- /dev/null +++ b/cache_manager.py @@ -0,0 +1,50 @@ +import os +import requests +import hashlib +import logging + +CACHE_DIR = "image_cache" + +if not os.path.exists(CACHE_DIR): + os.makedirs(CACHE_DIR) + +def get_cached_path(media_item_id): + """Returns the path where the image should be stored.""" + # Use a hash of the ID for filename safety, or just the ID if safe + safe_name = hashlib.md5(media_item_id.encode('utf-8')).hexdigest() + ".jpg" + return os.path.join(CACHE_DIR, safe_name) + +def download_image(url, media_item_id, width=1920, height=1080): + """ + Downloads an image from Google Photos API URL. + The URL accepts parameters to resize. + Appends =w{width}-h{height} to request specific size. + """ + target_path = get_cached_path(media_item_id) + + # If already cached, just return path + if os.path.exists(target_path): + return target_path + + # Construct URL with dimensions + download_url = f"{url}=w{width}-h{height}" + + try: + response = requests.get(download_url, stream=True) + if response.status_code == 200: + with open(target_path, 'wb') as f: + for chunk in response.iter_content(1024): + f.write(chunk) + logging.info(f"Downloaded {media_item_id}") + return target_path + else: + logging.error(f"Failed to download image: {response.status_code}") + return None + except Exception as e: + logging.error(f"Error downloading image: {e}") + return None + +def clear_cache(): + """Removes all files in cache directory.""" + for f in os.listdir(CACHE_DIR): + os.remove(os.path.join(CACHE_DIR, f)) diff --git a/gradle.properties b/gradle.properties deleted file mode 100644 index aac7c9b..0000000 --- a/gradle.properties +++ /dev/null @@ -1,17 +0,0 @@ -# Project-wide Gradle settings. - -# IDE (e.g. Android Studio) users: -# Gradle settings configured through the IDE *will override* -# any settings specified in this file. - -# 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. -org.gradle.jvmargs=-Xmx1536m - -# 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 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 13372ae..0000000 Binary files a/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index e0b3b61..0000000 --- a/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,6 +0,0 @@ -#Sun Sep 24 16:00:51 ICT 2017 -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.1-milestone-1-all.zip diff --git a/gradlew b/gradlew deleted file mode 100644 index 9d82f78..0000000 --- a/gradlew +++ /dev/null @@ -1,160 +0,0 @@ -#!/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 - -# 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\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null - -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"` - JAVACMD=`cygpath --unix "$JAVACMD"` - - # 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 deleted file mode 100644 index 8a0b282..0000000 --- a/gradlew.bat +++ /dev/null @@ -1,90 +0,0 @@ -@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/main.py b/main.py new file mode 100644 index 0000000..8a5c5c5 --- /dev/null +++ b/main.py @@ -0,0 +1,207 @@ +import os +import sys +import time +import random +import logging +import threading +import pygame + +from photos_api import PhotosService +import cache_manager +import scheduler + +# Config +ALBUM_TITLE = "Frame" +ROTATION_INTERVAL_MS = 30000 # 30 seconds +CHECK_INTERVAL_MS = 60000 # 1 minute +REFRESH_ITEMS_INTERVAL_MS = 3000000 # 50 minutes (to refresh baseUrls) + +# Colors +BLACK = (0, 0, 0) +WHITE = (255, 255, 255) + +class PhotoFrameApp: + def __init__(self): + # Init PyGame + pygame.init() + pygame.mouse.set_visible(False) + + # Setup Screen + self.display_info = pygame.display.Info() + self.screen_width = self.display_info.current_w + self.screen_height = self.display_info.current_h + + # Fullscreen + self.screen = pygame.display.set_mode((self.screen_width, self.screen_height), pygame.FULLSCREEN) + pygame.display.set_caption("Pi Photo Frame") + + # Font + self.font = pygame.font.SysFont('Arial', 40) + + # Logic Components + self.scheduler = scheduler.Scheduler() + self.photos_service = None + self.media_items = [] + self.current_image = None + self.next_image_path = None + + self.running = True + self.clock = pygame.time.Clock() + + # Events + self.EVT_NEXT_PHOTO = pygame.USEREVENT + 1 + self.EVT_CHECK_NIGHT = pygame.USEREVENT + 2 + self.EVT_REFRESH_ITEMS = pygame.USEREVENT + 3 + + # Start Init in background + threading.Thread(target=self.initialize_service, daemon=True).start() + + # Timers + pygame.time.set_timer(self.EVT_NEXT_PHOTO, ROTATION_INTERVAL_MS) + pygame.time.set_timer(self.EVT_CHECK_NIGHT, CHECK_INTERVAL_MS) + pygame.time.set_timer(self.EVT_REFRESH_ITEMS, REFRESH_ITEMS_INTERVAL_MS) + + def initialize_service(self): + try: + self.show_message("Authenticating...") + self.photos_service = PhotosService() + self.fetch_media_items() + + # Prepare first photo immediately + self.prepare_next_photo() + + except Exception as e: + logging.error(f"Init Error: {e}") + self.show_message(f"Error: {e}") + + def fetch_media_items(self): + """Fetches items from the album. Updates self.media_items thread-safely (mostly).""" + try: + logging.info("Refreshing media items list...") + self.show_message("Updating Library...") + albums = self.photos_service.list_albums() + target_album = next((a for a in albums if a.get('title') == ALBUM_TITLE), None) + + if not target_album: + if albums: + target_album = albums[0] + logging.warning(f"Album '{ALBUM_TITLE}' not found. Using '{target_album.get('title')}' instead.") + else: + self.show_message("No Albums found!") + return + + new_items = self.photos_service.get_media_items(target_album['id']) + # Filter images + new_items = [i for i in new_items if 'image' in i.get('mediaMetadata', {})] + + if not new_items: + self.show_message("No photos in album.") + return + + self.media_items = new_items + logging.info(f"Loaded {len(self.media_items)} photos.") + self.show_message(None) # Clear message if successful + + except Exception as e: + logging.error(f"Error fetching items: {e}") + + def show_message(self, text): + """Renders text to the screen.""" + self.status_text = text + + def prepare_next_photo(self): + """Pick random photo, download, and signal main thread.""" + if not self.media_items: + return + + item = random.choice(self.media_items) + path = cache_manager.download_image(item['baseUrl'], item['id'], self.screen_width, self.screen_height) + + if path: + self.next_image_path = path + else: + # If download fails, it might be due to expired baseUrl (if cache miss). + # We can trigger a refresh if we suspect that. + logging.warning("Download failed. BaseUrl might be expired.") + # We could post an event to refresh, but let's just log for now to avoid rapid loops. + + def load_image_surface(self, path): + try: + img = pygame.image.load(path) + + # Scale + img_rect = img.get_rect() + img_w, img_h = img_rect.width, img_rect.height + + ratio = min(self.screen_width / img_w, self.screen_height / img_h) + new_w = int(img_w * ratio) + new_h = int(img_h * ratio) + + img = pygame.transform.smoothscale(img, (new_w, new_h)) + return img + except Exception as e: + logging.error(f"Failed to load image surface: {e}") + return None + + def run(self): + self.status_text = "Initializing..." + + while self.running: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + self.running = False + elif event.type == pygame.KEYDOWN: + if event.key == pygame.K_ESCAPE: + self.running = False + + elif event.type == self.EVT_CHECK_NIGHT: + is_night = self.scheduler.check() + if is_night: + self.current_image = None + + elif event.type == self.EVT_NEXT_PHOTO: + if not self.scheduler.night_mode_active and self.media_items: + # Start fetch in thread + threading.Thread(target=self.prepare_next_photo, daemon=True).start() + + elif event.type == self.EVT_REFRESH_ITEMS: + if not self.scheduler.night_mode_active: + threading.Thread(target=self.fetch_media_items, daemon=True).start() + + # Check if a new image is ready from the thread + if self.next_image_path: + new_surf = self.load_image_surface(self.next_image_path) + if new_surf: + self.current_image = new_surf + self.status_text = None + self.next_image_path = None + + # Draw + self.screen.fill(BLACK) + + if self.scheduler.night_mode_active: + # Screen is "off", draw nothing (black) + pass + elif self.current_image: + # Center image + rect = self.current_image.get_rect(center=(self.screen_width//2, self.screen_height//2)) + self.screen.blit(self.current_image, rect) + elif self.status_text: + text_surf = self.font.render(self.status_text, True, WHITE) + rect = text_surf.get_rect(center=(self.screen_width//2, self.screen_height//2)) + self.screen.blit(text_surf, rect) + + pygame.display.flip() + self.clock.tick(10) # 10 FPS is enough for a static frame + + pygame.quit() + sys.exit() + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + # Check for display environment variable for headless run (optional) + if not os.environ.get('DISPLAY'): + os.environ['DISPLAY'] = ':0' + + app = PhotoFrameApp() + app.run() diff --git a/photos_api.py b/photos_api.py new file mode 100644 index 0000000..3b603f7 --- /dev/null +++ b/photos_api.py @@ -0,0 +1,64 @@ +import logging +from googleapiclient.discovery import build +import auth + +class PhotosService: + def __init__(self): + self.creds = auth.authenticate() + if not self.creds: + raise Exception("Authentication failed. Check credentials.") + self.service = build('photoslibrary', 'v1', credentials=self.creds, static_discovery=False) + + def list_albums(self, page_size=50): + """Lists albums in the user's account.""" + albums = [] + next_page_token = None + + while True: + results = self.service.albums().list( + pageSize=page_size, + pageToken=next_page_token + ).execute() + + items = results.get('albums', []) + albums.extend(items) + + next_page_token = results.get('nextPageToken') + if not next_page_token: + break + + return albums + + def get_media_items(self, album_id): + """Fetches all media items from a specific album.""" + media_items = [] + next_page_token = None + + body = { + 'albumId': album_id, + 'pageSize': 100 + } + + while True: + if next_page_token: + body['pageToken'] = next_page_token + + results = self.service.mediaItems().search(body=body).execute() + items = results.get('mediaItems', []) + media_items.extend(items) + + next_page_token = results.get('nextPageToken') + if not next_page_token: + break + + return media_items + +if __name__ == '__main__': + logging.basicConfig(level=logging.INFO) + try: + service = PhotosService() + albums = service.list_albums() + for album in albums: + print(f"ID: {album['id']}, Title: {album.get('title')}, Items: {album.get('mediaItemsCount')}") + except Exception as e: + print(e) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8b994da --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +google-api-python-client +google-auth-oauthlib +google-auth-httplib2 +requests +Pillow +pygame diff --git a/scheduler.py b/scheduler.py new file mode 100644 index 0000000..e2c2c49 --- /dev/null +++ b/scheduler.py @@ -0,0 +1,56 @@ +import datetime +import logging +import subprocess + +# Configurable Night Mode Times (24h format) +START_HOUR = 22 # 10 PM +END_HOUR = 7 # 7 AM + +def is_night_time(): + now = datetime.datetime.now().time() + if START_HOUR < END_HOUR: + return START_HOUR <= now.hour < END_HOUR + else: + # Crosses midnight (e.g., 22 to 7) + return now.hour >= START_HOUR or now.hour < END_HOUR + +def set_display_power(on): + """ + Controls the Raspberry Pi display power using vcgencmd. + Note: 'vcgencmd' is specific to Raspberry Pi OS. + If testing on non-Pi, this will log a warning but not crash. + """ + state = "1" if on else "0" + try: + # Check if vcgencmd exists + subprocess.run(['vcgencmd', 'display_power', state], check=True, stdout=subprocess.DEVNULL) + logging.info(f"Display power set to {'ON' if on else 'OFF'}") + except FileNotFoundError: + logging.warning("vcgencmd not found. Skipping display power control (not on Pi?).") + except subprocess.CalledProcessError as e: + logging.error(f"Failed to set display power: {e}") + +class Scheduler: + def __init__(self): + self.night_mode_active = False + + def check(self): + """ + Checks if the mode needs to change. + Returns 'active' boolean. + """ + currently_night = is_night_time() + + if currently_night and not self.night_mode_active: + # Transition to night + logging.info("Entering Night Mode") + set_display_power(False) + self.night_mode_active = True + + elif not currently_night and self.night_mode_active: + # Transition to day + logging.info("Exiting Night Mode") + set_display_power(True) + self.night_mode_active = False + + return self.night_mode_active diff --git a/settings.gradle b/settings.gradle deleted file mode 100644 index e7b4def..0000000 --- a/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -include ':app'