diff --git a/.dockerignore b/.dockerignore index 38d67d5..9a59c66 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,5 @@ bin/ venv/ -.git/ .buildozer/ .pytest_cache/ .tox/ diff --git a/.gitignore b/.gitignore index 5aa99e0..7951ec8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # Custom *.swp +.buildozer/ +bin/ # Byte-compiled / optimized / DLL files __pycache__/ @@ -31,7 +33,6 @@ var/ # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest -*.spec # Installer logs pip-log.txt diff --git a/.travis.yml b/.travis.yml index 763bf0c..2b9d4c6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,20 +6,17 @@ services: - docker env: - global: - - DISPLAY=:99.0 - matrix: - - TAG=zbarcam-linux DOCKERFILE=dockerfiles/Dockerfile-linux COMMAND='tox' + - DISPLAY=:99.0 before_install: - sudo apt update -qq > /dev/null - sudo apt install --yes --no-install-recommends xvfb install: - - docker build --tag=$TAG --file=$DOCKERFILE --build-arg CI . + - make docker/build before_script: - sh -e /etc/init.d/xvfb start script: - - travis_wait docker run -e DISPLAY -e CI -v /tmp/.X11-unix:/tmp/.X11-unix $TAG $COMMAND + - make docker/run/test diff --git a/CHANGELOG.md b/CHANGELOG.md index 8abc0fe..880838a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Change Log +## [2019.0928] + + - Setup coverage testing + - Runtime camera permission check, refs #5 + - Publish `xcamera` demo binary, refs #9 + - Add Python3.7 support, refs #13 + ## [2019.0911] - Move to new garden layout diff --git a/Makefile b/Makefile index d1bf10a..54a8883 100644 --- a/Makefile +++ b/Makefile @@ -1,60 +1,77 @@ -VENV_NAME=venv -PIP=$(VENV_NAME)/bin/pip +VIRTUAL_ENV ?= venv +PIP=$(VIRTUAL_ENV)/bin/pip TOX=`which tox` -GARDEN=$(VENV_NAME)/bin/garden -PYTHON=$(VENV_NAME)/bin/python -ISORT=$(VENV_NAME)/bin/isort -FLAKE8=$(VENV_NAME)/bin/flake8 +PYTHON=$(VIRTUAL_ENV)/bin/python +ISORT=$(VIRTUAL_ENV)/bin/isort +FLAKE8=$(VIRTUAL_ENV)/bin/flake8 +PYTEST=$(VIRTUAL_ENV)/bin/pytest TWINE=`which twine` SOURCES=src/ tests/ setup.py setup_meta.py # using full path so it can be used outside the root dir SPHINXBUILD=$(shell realpath venv/bin/sphinx-build) DOCS_DIR=doc SYSTEM_DEPENDENCIES= \ - libpython$(PYTHON_VERSION)-dev \ + build-essential \ + ccache \ + cmake \ + curl \ + git \ libsdl2-dev \ + libsdl2-image-dev \ + libsdl2-mixer-dev \ + libsdl2-ttf-dev \ + libpython3.6-dev \ + libpython$(PYTHON_VERSION)-dev \ libzbar-dev \ + pkg-config \ + python3.6 \ + python3.6-dev \ + python3.7 \ + python3.7-dev \ tox \ virtualenv OS=$(shell lsb_release -si) PYTHON_MAJOR_VERSION=3 -PYTHON_MINOR_VERSION=6 +PYTHON_MINOR_VERSION=7 PYTHON_VERSION=$(PYTHON_MAJOR_VERSION).$(PYTHON_MINOR_VERSION) +PYTHON_MAJOR_MINOR=$(PYTHON_MAJOR_VERSION)$(PYTHON_MINOR_VERSION) PYTHON_WITH_VERSION=python$(PYTHON_VERSION) all: system_dependencies virtualenv -venv: - test -d venv || virtualenv -p $(PYTHON_WITH_VERSION) venv +system_dependencies: +ifeq ($(OS), Ubuntu) + sudo apt install --yes --no-install-recommends $(SYSTEM_DEPENDENCIES) +endif -virtualenv: venv +$(VIRTUAL_ENV): + virtualenv -p $(PYTHON_WITH_VERSION) $(VIRTUAL_ENV) $(PIP) install Cython==0.28.6 $(PIP) install -r requirements.txt +virtualenv: $(VIRTUAL_ENV) + virtualenv/test: virtualenv $(PIP) install -r requirements/requirements-test.txt -system_dependencies: -ifeq ($(OS), Ubuntu) - sudo apt install --yes --no-install-recommends $(SYSTEM_DEPENDENCIES) -endif - -run/linux: virtualenv +run: virtualenv $(PYTHON) src/main.py --debug -run: run/linux - test: $(TOX) + @if test -n "$$CI"; then .tox/py$(PYTHON_MAJOR_MINOR)/bin/coveralls; fi; \ + +pytest: virtualenv/test + PYTHONPATH=src $(PYTEST) --cov src/ --cov-report html tests/ -lint/isort-check: virtualenv +lint/isort-check: virtualenv/test $(ISORT) --check-only --recursive --diff $(SOURCES) -lint/isort-fix: virtualenv +lint/isort-fix: virtualenv/test $(ISORT) --recursive $(SOURCES) -lint/flake8: virtualenv +lint/flake8: virtualenv/test $(FLAKE8) $(SOURCES) lint: lint/isort-check lint/flake8 @@ -68,7 +85,7 @@ docs: release/clean: rm -rf dist/ build/ -release/build: release/clean +release/build: release/clean virtualenv $(PYTHON) setup.py sdist bdist_wheel $(PYTHON) setup_meta.py sdist bdist_wheel $(TWINE) check dist/* @@ -80,6 +97,19 @@ clean: release/clean docs/clean py3clean src/ find . -type d -name "__pycache__" -exec rm -r {} + find . -type d -name "*.egg-info" -exec rm -r {} + + rm -rf htmlcov/ clean/all: clean - rm -rf $(VENV_NAME) .tox/ + rm -rf $(VIRTUAL_ENV) .tox/ + +docker/build: + docker build --tag=xcamera-linux --file=dockerfiles/Dockerfile-linux . + +docker/run/test: + docker run --env-file dockerfiles/env.list -v /tmp/.X11-unix:/tmp/.X11-unix xcamera-linux 'make test' + +docker/run/app: + docker run --env-file dockerfiles/env.list -v /tmp/.X11-unix:/tmp/.X11-unix --device=/dev/video0:/dev/video0 xcamera-linux 'make run' + +docker/run/shell: + docker run --env-file dockerfiles/env.list -v /tmp/.X11-unix:/tmp/.X11-unix --device=/dev/video0:/dev/video0 -it --rm xcamera-linux diff --git a/README.md b/README.md index fdf81e2..635e1cf 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # XCamera: Android-optimized camera widget [![Build Status](https://travis-ci.com/kivy-garden/xcamera.svg?branch=develop)](https://travis-ci.com/kivy-garden/xcamera) +[![Coverage Status](https://coveralls.io/repos/github/kivy-garden/xcamera/badge.svg?branch=develop)](https://coveralls.io/github/kivy-garden/xcamera?branch=develop) [![PyPI version](https://badge.fury.io/py/xcamera.svg)](https://badge.fury.io/py/xcamera) XCamera is a widget which extends the standard Kivy Camera widget with more @@ -19,7 +20,7 @@ functionality. In particular: Screenshot: -![screenshot](/screenshot.png?raw=True "Screenshot") +![screenshot](https://raw.githubusercontent.com/kivy-garden/xcamera/develop/screenshot.png?raw=True "Screenshot") Notes: @@ -35,19 +36,29 @@ Notes: new button to allow the user to manually select the preferred size. Pull requests are welcome :) -## Install +## Install & Usage +[xcamera is available on PyPI](https://pypi.org/project/xcamera/). +Therefore it can be installed via `pip`. ```sh -pip install xcamera +pip3 install --user xcamera +``` +Once installed, the demo should be available in your `PATH` and can be ran from the command line. +```sh +xcamera +``` +And the widget can be imported via: +```python +from kivy_garden.xcamera import XCamera ``` ## Demo -A full working demo is available in [src/main.py](https://github.com/kivy-garden/xcamera/blob/master/src/main.py). +A full working demo is available in [src/kivy_garden/xcamera/main.py](https://github.com/kivy-garden/xcamera/blob/develop/src/main.py). You can run it via: ```sh make run ``` -## Contribute +## Develop & Contribute To play with the project, install system dependencies and Python requirements using the [Makefile](Makefile). ```sh make @@ -56,3 +67,16 @@ Then verify everything is OK by running tests. ```sh make test ``` +If you're familiar with `Docker`, the project can also run in a fully isolated container. +First build the image. +```sh +make docker/build +``` +Then you can run tests within the container. +```sh +make docker/run/test +``` +Or the application itself. +```sh +make docker/run/app +``` diff --git a/buildozer.spec b/buildozer.spec new file mode 100644 index 0000000..f58f3c8 --- /dev/null +++ b/buildozer.spec @@ -0,0 +1,308 @@ +[app] + +# (str) Title of your application +title = XCamera + +# (str) Package name +package.name = xcamera + +# (str) Package domain (needed for android/ios packaging) +package.domain = com.github.kivy + +# (str) Source code where the main.py live +source.dir = src + +# (list) Source files to include (let empty to include all the files) +source.include_exts = py,png,jpg,kv,atlas,ttf,wav + +# (list) List of inclusions using pattern matching +#source.include_patterns = assets/*,images/*.png + +# (list) Source files to exclude (let empty to not exclude anything) +#source.exclude_exts = spec + +# (list) List of directory to exclude (let empty to not exclude anything) +#source.exclude_dirs = tests, bin + +# (list) List of exclusions using pattern matching +#source.exclude_patterns = license,images/*/*.jpg + +# (str) Application versioning (method 1) +# version = 0.1 + +# (str) Application versioning (method 2) +version.regex = __version__ = ['"](.*)['"] +version.filename = %(source.dir)s/kivy_garden/xcamera/version.py + +# (list) Application requirements +# comma separated e.g. requirements = sqlite3,kivy +requirements = + android, + kivy==1.11.1, + python3 + +# (str) Custom source folders for requirements +# Sets custom source for any requirements with recipes +# requirements.source.kivy = ../../kivy + +# (list) Garden requirements +#garden_requirements = + +# (str) Presplash of the application +#presplash.filename = %(source.dir)s/data/presplash.png + +# (str) Icon of the application +#icon.filename = %(source.dir)s/data/icon.png + +# (str) Supported orientation (one of landscape, sensorLandscape, portrait or all) +orientation = portrait + +# (list) List of service to declare +#services = NAME:ENTRYPOINT_TO_PY,NAME2:ENTRYPOINT2_TO_PY + +# +# OSX Specific +# + +# +# author = © Copyright Info + +# change the major version of python used by the app +osx.python_version = 3 + +# Kivy version to use +osx.kivy_version = 1.9.1 + +# +# Android specific +# + +# (bool) Indicate if the application should be fullscreen or not +fullscreen = 0 + +# (string) Presplash background color (for new android toolchain) +# Supported formats are: #RRGGBB #AARRGGBB or one of the following names: +# red, blue, green, black, white, gray, cyan, magenta, yellow, lightgray, +# darkgray, grey, lightgrey, darkgrey, aqua, fuchsia, lime, maroon, navy, +# olive, purple, silver, teal. +#android.presplash_color = #FFFFFF + +# (list) Permissions +android.permissions = CAMERA + +# (int) Target Android API, should be as high as possible. +android.api = 27 + +# (int) Minimum API your APK will support. +android.minapi = 21 + +# (int) Android SDK version to use +android.sdk = 20 + +# (str) Android NDK version to use +android.ndk = 19b + +# (int) Android NDK API to use. This is the minimum API your app will support, it should usually match android.minapi. +android.ndk_api = 21 + +# (bool) Use --private data storage (True) or --dir public storage (False) +#android.private_storage = True + +# (str) Android NDK directory (if empty, it will be automatically downloaded.) +#android.ndk_path = + +# (str) Android SDK directory (if empty, it will be automatically downloaded.) +#android.sdk_path = + +# (str) ANT directory (if empty, it will be automatically downloaded.) +#android.ant_path = + +# (bool) If True, then skip trying to update the Android sdk +# This can be useful to avoid excess Internet downloads or save time +# when an update is due and you just want to test/build your package +# android.skip_update = False + +# (bool) If True, then automatically accept SDK license +# agreements. This is intended for automation only. If set to False, +# the default, you will be shown the license when first running +# buildozer. +android.accept_sdk_license = True + +# (str) Android entry point, default is ok for Kivy-based app +#android.entrypoint = org.renpy.android.PythonActivity + +# (list) Pattern to whitelist for the whole project +#android.whitelist = + +# (str) Path to a custom whitelist file +#android.whitelist_src = + +# (str) Path to a custom blacklist file +#android.blacklist_src = + +# (list) List of Java .jar files to add to the libs so that pyjnius can access +# their classes. Don't add jars that you do not need, since extra jars can slow +# down the build process. Allows wildcards matching, for example: +# OUYA-ODK/libs/*.jar +#android.add_jars = foo.jar,bar.jar,path/to/more/*.jar + +# (list) List of Java files to add to the android project (can be java or a +# directory containing the files) +#android.add_src = + +# (list) Android AAR archives to add (currently works only with sdl2_gradle +# bootstrap) +#android.add_aars = + +# (list) Gradle dependencies to add (currently works only with sdl2_gradle +# bootstrap) +#android.gradle_dependencies = + +# (list) Java classes to add as activities to the manifest. +#android.add_activites = com.example.ExampleActivity + +# (str) OUYA Console category. Should be one of GAME or APP +# If you leave this blank, OUYA support will not be enabled +#android.ouya.category = GAME + +# (str) Filename of OUYA Console icon. It must be a 732x412 png image. +#android.ouya.icon.filename = %(source.dir)s/data/ouya_icon.png + +# (str) XML file to include as an intent filters in tag +#android.manifest.intent_filters = + +# (str) launchMode to set for the main activity +#android.manifest.launch_mode = standard + +# (list) Android additional libraries to copy into libs/armeabi +#android.add_libs_armeabi = libs/android/*.so +#android.add_libs_armeabi_v7a = libs/android-v7/*.so +#android.add_libs_arm64_v8a = libs/android-v8/*.so +#android.add_libs_x86 = libs/android-x86/*.so +#android.add_libs_mips = libs/android-mips/*.so + +# (bool) Indicate whether the screen should stay on +# Don't forget to add the WAKE_LOCK permission if you set this to True +#android.wakelock = False + +# (list) Android application meta-data to set (key=value format) +#android.meta_data = + +# (list) Android library project to add (will be added in the +# project.properties automatically.) +#android.library_references = + +# (list) Android shared libraries which will be added to AndroidManifest.xml using tag +#android.uses_library = + +# (str) Android logcat filters to use +#android.logcat_filters = *:S python:D + +# (bool) Copy library instead of making a libpymodules.so +#android.copy_libs = 1 + +# (str) The Android arch to build for, choices: armeabi-v7a, arm64-v8a, x86, x86_64 +android.arch = armeabi-v7a + +# +# Python for android (p4a) specific +# + +# (str) python-for-android fork to use, defaults to upstream (kivy) +#p4a.fork = kivy + +# (str) python-for-android branch to use, defaults to master +p4a.branch = develop + +# (str) python-for-android git clone directory (if empty, it will be automatically cloned from github) +#p4a.source_dir = + +# (str) The directory in which python-for-android should look for your own build recipes (if any) +#p4a.local_recipes = + +# (str) Filename to the hook for p4a +#p4a.hook = + +# (str) Bootstrap to use for android builds +# p4a.bootstrap = sdl2 + +# (int) port number to specify an explicit --port= p4a argument (eg for bootstrap flask) +#p4a.port = + + +# +# iOS specific +# + +# (str) Path to a custom kivy-ios folder +#ios.kivy_ios_dir = ../kivy-ios +# Alternately, specify the URL and branch of a git checkout: +ios.kivy_ios_url = https://github.com/kivy/kivy-ios +ios.kivy_ios_branch = master + +# Another platform dependency: ios-deploy +# Uncomment to use a custom checkout +#ios.ios_deploy_dir = ../ios_deploy +# Or specify URL and branch +ios.ios_deploy_url = https://github.com/phonegap/ios-deploy +ios.ios_deploy_branch = 1.7.0 + +# (str) Name of the certificate to use for signing the debug version +# Get a list of available identities: buildozer ios list_identities +#ios.codesign.debug = "iPhone Developer: ()" + +# (str) Name of the certificate to use for signing the release version +#ios.codesign.release = %(ios.codesign.debug)s + + +[buildozer] + +# (int) Log level (0 = error only, 1 = info, 2 = debug (with command output)) +log_level = 2 + +# (int) Display warning if buildozer is run as root (0 = False, 1 = True) +warn_on_root = 1 + +# (str) Path to build artifact storage, absolute or relative to spec file +# build_dir = ./.buildozer + +# (str) Path to build output (i.e. .apk, .ipa) storage +# bin_dir = ./bin + +# ----------------------------------------------------------------------------- +# List as sections +# +# You can define all the "list" as [section:key]. +# Each line will be considered as a option to the list. +# Let's take [app] / source.exclude_patterns. +# Instead of doing: +# +#[app] +#source.exclude_patterns = license,data/audio/*.wav,data/images/original/* +# +# This can be translated into: +# +#[app:source.exclude_patterns] +#license +#data/audio/*.wav +#data/images/original/* +# + + +# ----------------------------------------------------------------------------- +# Profiles +# +# You can extend section / key with a profile +# For example, you want to deploy a demo version of your application without +# HD content. You could first change the title to add "(demo)" in the name +# and extend the excluded directories to remove the HD content. +# +#[app@demo] +#title = My Application (demo) +# +#[app:source.exclude_patterns@demo] +#images/hd/* +# +# Then, invoke the command line with the "demo" profile: +# +#buildozer --profile demo android debug diff --git a/dockerfiles/Dockerfile-linux b/dockerfiles/Dockerfile-linux index b8cef3e..886d202 100644 --- a/dockerfiles/Dockerfile-linux +++ b/dockerfiles/Dockerfile-linux @@ -7,7 +7,7 @@ # docker run xcamera-linux 'make test' # For running UI: # xhost +"local:docker@" -# docker run -e DISPLAY=$DISPLAY -v /tmp/.X11-unix:/tmp/.X11-unix xcamera-linux 'make uitest' +# docker run -e DISPLAY -v /tmp/.X11-unix:/tmp/.X11-unix --device=/dev/video0:/dev/video0 xcamera-linux 'make run' # Or for interactive shell: # docker run -it --rm xcamera-linux FROM ubuntu:18.04 @@ -26,37 +26,20 @@ ENV LANG="en_US.UTF-8" \ # install system dependencies RUN apt install --yes --no-install-recommends \ - build-essential \ - ccache \ - cmake \ - curl \ - libsdl2-dev \ - libsdl2-image-dev \ - libsdl2-mixer-dev \ - libsdl2-ttf-dev \ - libpython3.6-dev \ - libpython3.7-dev \ - libzbar-dev \ lsb-release \ make \ - pkg-config \ - python3.6 \ - python3.6-dev \ - python3.7 \ - python3.7-dev \ - sudo \ - tox \ - virtualenv + sudo # prepare non root env RUN useradd --create-home --shell /bin/bash ${USER} # with sudo access and no password RUN usermod -append --groups sudo ${USER} RUN echo "%sudo ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers +# gives access to video so the camera can be accessed within the container +RUN gpasswd --add ${USER} video USER ${USER} WORKDIR ${WORK_DIR} COPY . ${WORK_DIR} - -# RUN make +RUN sudo make system_dependencies && make virtualenv ENTRYPOINT ["./dockerfiles/start.sh"] diff --git a/dockerfiles/env.list b/dockerfiles/env.list new file mode 100644 index 0000000..732d46e --- /dev/null +++ b/dockerfiles/env.list @@ -0,0 +1,9 @@ +# used by coveralls.io, refs: +# https://coveralls-python.readthedocs.io/en/latest/usage/tox.html#travisci +CI +TRAVIS +TRAVIS_BRANCH +TRAVIS_JOB_ID +TRAVIS_PULL_REQUEST +# used for running UI tests +DISPLAY diff --git a/requirements/requirements-test.txt b/requirements/requirements-test.txt index 7424ff6..09ff3bd 100644 --- a/requirements/requirements-test.txt +++ b/requirements/requirements-test.txt @@ -1,3 +1,5 @@ +coveralls flake8 isort pytest +pytest-cov diff --git a/setup.py b/setup.py index 64d7e41..0c27560 100644 --- a/setup.py +++ b/setup.py @@ -25,8 +25,13 @@ def read(fname): 'kivy_garden.xcamera.data': ['*.ttf', '*.wav'], }, 'package_dir': {'': 'src'}, + 'entry_points': { + 'console_scripts': ['xcamera=kivy_garden.xcamera.main:main'], + }, + 'python_requires': '>=3', 'install_requires': [ 'kivy', + 'opencv-python', ], } diff --git a/src/kivy_garden/xcamera/main.py b/src/kivy_garden/xcamera/main.py new file mode 100644 index 0000000..c522662 --- /dev/null +++ b/src/kivy_garden/xcamera/main.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python +from kivy.app import App +from kivy.lang import Builder + +kv = """ +#:import XCamera kivy_garden.xcamera.XCamera + +FloatLayout: + orientation: 'vertical' + + XCamera: + id: xcamera + on_picture_taken: app.picture_taken(*args) + + BoxLayout: + orientation: 'horizontal' + size_hint: 1, None + height: sp(50) + + Button: + text: 'Set landscape' + on_release: xcamera.force_landscape() + + Button: + text: 'Restore orientation' + on_release: xcamera.restore_orientation() +""" + + +class CameraApp(App): + def build(self): + return Builder.load_string(kv) + + def picture_taken(self, obj, filename): + print('Picture taken and saved to {}'.format(filename)) + + +def main(): + CameraApp().run() + + +if __name__ == '__main__': + main() diff --git a/src/kivy_garden/xcamera/version.py b/src/kivy_garden/xcamera/version.py index 76f8958..c33baeb 100644 --- a/src/kivy_garden/xcamera/version.py +++ b/src/kivy_garden/xcamera/version.py @@ -1 +1 @@ -__version__ = '2019.0911' +__version__ = '2019.0928' diff --git a/src/kivy_garden/xcamera/xcamera.py b/src/kivy_garden/xcamera/xcamera.py index c4362a1..f08871d 100644 --- a/src/kivy_garden/xcamera/xcamera.py +++ b/src/kivy_garden/xcamera/xcamera.py @@ -1,12 +1,14 @@ import datetime import os +from kivy.clock import mainthread from kivy.lang import Builder from kivy.properties import ObjectProperty from kivy.resources import resource_add_path from kivy.uix.behaviors import ButtonBehavior from kivy.uix.camera import Camera from kivy.uix.label import Label +from kivy.utils import platform from .platform_api import LANDSCAPE, set_orientation, take_picture @@ -22,6 +24,37 @@ def darker(color, factor=0.5): return r, g, b, a +def get_filename(): + return datetime.datetime.now().strftime('%Y-%m-%d %H.%M.%S.jpg') + + +def is_android(): + return platform == 'android' + + +def check_camera_permission(): + """ + Android runtime `CAMERA` permission check. + """ + if not is_android(): + return True + from android.permissions import Permission, check_permission + permission = Permission.CAMERA + return check_permission(permission) + + +def check_request_camera_permission(callback=None): + """ + Android runtime `CAMERA` permission check & request. + """ + had_permission = check_camera_permission() + if not had_permission: + from android.permissions import Permission, request_permissions + permissions = [Permission.CAMERA] + request_permissions(permissions, callback) + return had_permission + + class XCameraIconButton(ButtonBehavior, Label): pass @@ -29,24 +62,48 @@ class XCameraIconButton(ButtonBehavior, Label): class XCamera(Camera): directory = ObjectProperty(None) _previous_orientation = None - __events__ = ('on_picture_taken',) + __events__ = ('on_picture_taken', 'on_camera_ready') def __init__(self, **kwargs): Builder.load_file(os.path.join(ROOT, "xcamera.kv")) super().__init__(**kwargs) + def _on_index(self, *largs): + """ + Overrides `kivy.uix.camera.Camera._on_index()` to make sure + `camera.open()` is not called unless Android `CAMERA` permission is + granted, refs #5. + """ + @mainthread + def on_permissions_callback(permissions, grant_results): + """ + On camera permission callback calls parent `_on_index()` method. + """ + if all(grant_results): + self._on_index_dispatch(*largs) + if check_request_camera_permission(callback=on_permissions_callback): + self._on_index_dispatch(*largs) + + def _on_index_dispatch(self, *largs): + super()._on_index(*largs) + self.dispatch('on_camera_ready') + def on_picture_taken(self, filename): """ - This event is fired every time a picture has been taken + This event is fired every time a picture has been taken. """ + pass - def get_filename(self): - return datetime.datetime.now().strftime('%Y-%m-%d %H.%M.%S.jpg') + def on_camera_ready(self): + """ + Fired when the camera is ready. + """ + pass def shoot(self): def on_success(filename): self.dispatch('on_picture_taken', filename) - filename = self.get_filename() + filename = get_filename() if self.directory: filename = os.path.join(self.directory, filename) take_picture(self, filename, on_success) diff --git a/src/main.py b/src/main.py index e192666..61c7612 100755 --- a/src/main.py +++ b/src/main.py @@ -1,38 +1,8 @@ -from kivy.app import App -from kivy.lang import Builder - -kv = """ -#:import XCamera kivy_garden.xcamera.XCamera - -FloatLayout: - orientation: 'vertical' - - XCamera: - id: xcamera - on_picture_taken: app.picture_taken(*args) - - BoxLayout: - orientation: 'horizontal' - size_hint: 1, None - height: sp(50) - - Button: - text: 'Set landscape' - on_release: xcamera.force_landscape() - - Button: - text: 'Restore orientation' - on_release: xcamera.restore_orientation() """ - - -class CameraApp(App): - def build(self): - return Builder.load_string(kv) - - def picture_taken(self, obj, filename): - print('Picture taken and saved to {}'.format(filename)) - +This is the file being picked up by `buildozer` as it's expecting a `main.py` +in the source directory. +""" +from kivy_garden.xcamera.main import main if __name__ == '__main__': - CameraApp().run() + main() diff --git a/tests/kivy_garden/xcamera/test_xcamera.py b/tests/kivy_garden/xcamera/test_xcamera.py new file mode 100644 index 0000000..94037e7 --- /dev/null +++ b/tests/kivy_garden/xcamera/test_xcamera.py @@ -0,0 +1,115 @@ +import os +from unittest import mock + +from kivy_garden.xcamera import platform_api, xcamera + + +def get_camera_class(): + """ + Continuous integration providers don't have a camera available. + """ + if os.environ.get('CI', False): + Camera = None + else: + from kivy.core.camera import Camera + return Camera + + +def get_xcamera(): + """ + Helper function to return a (potentially patched) XCamera instance. + """ + Camera = get_camera_class() + # overrides to `None` as initializing it twice seems to freeze tests + Camera = None + # uses the `wraps` parameter to conditionally enable/disable mock + with mock.patch('kivy.uix.camera.CoreCamera', wraps=Camera): + camera = xcamera.XCamera() + return camera + + +def patch_is_android(): + return mock.patch('kivy_garden.xcamera.xcamera.is_android') + + +def patch_android_permissions(m_android_permissions): + return mock.patch.dict( + 'sys.modules', {'android.permissions': m_android_permissions}) + + +def patch_check_camera_permission(): + return mock.patch('kivy_garden.xcamera.xcamera.check_camera_permission') + + +class TestBase: + + def test_darker(self): + red = 0.1 + green = 0.2 + blue = 0.3 + alpha = 0.4 + color = (red, green, blue, alpha) + new_color = xcamera.darker(color) + assert new_color == (0.05, 0.1, 0.15, 0.4) + + def test_getfilename(self): + assert xcamera.get_filename().endswith('.jpg') + + def test_check_camera_permission(self): + """ + Makes sure `check_permission()` is called if `is_android()` is `True`. + """ + m_android_permissions = mock.Mock() + with patch_is_android() as m_is_android, \ + patch_android_permissions(m_android_permissions): + m_is_android.return_value = True + m_android_permissions.check_permission.return_value = True + assert xcamera.check_camera_permission() is True + assert m_is_android.mock_calls == [mock.call()] + assert m_android_permissions.method_calls == [ + mock.call.check_permission( + m_android_permissions.Permission.CAMERA), + ] + + def test_check_request_camera_permission(self): + """ + Checks if `request_permissions()` is called when + `check_camera_permission()` is `False`. + """ + m_android_permissions = mock.Mock() + callback = mock.Mock() + with patch_check_camera_permission() as m_check_camera_permission, \ + patch_android_permissions(m_android_permissions): + m_check_camera_permission.return_value = False + xcamera.check_request_camera_permission(callback) + assert m_android_permissions.method_calls == [ + mock.call.request_permissions( + [m_android_permissions.Permission.CAMERA], callback), + ] + + +class TestXCamera: + + def test_shoot(self): + camera = get_xcamera() + with mock.patch( + 'kivy_garden.xcamera.xcamera.take_picture') as m_take_picture: + camera.shoot() + assert m_take_picture.mock_calls == [ + mock.call(camera, mock.ANY, mock.ANY)] + + def test_force_landscape(self): + camera = get_xcamera() + assert camera._previous_orientation is None + camera.force_landscape() + assert camera._previous_orientation == platform_api.LANDSCAPE + + def test_restore_orientation(self): + camera = get_xcamera() + assert camera._previous_orientation is None + camera.restore_orientation() + assert camera._previous_orientation is None + camera.force_landscape() + assert camera._previous_orientation == platform_api.LANDSCAPE + camera.restore_orientation() + assert camera._previous_orientation == platform_api.LANDSCAPE diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..f27fcd9 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,14 @@ +from unittest import mock + +from main import main + + +class TestMain: + """ + Tests the `main` module. + """ + + def test_main(self): + with mock.patch('kivy_garden.xcamera.main.CameraApp.run') as m_play: + main() + assert m_play.mock_calls == [mock.call()] diff --git a/tox.ini b/tox.ini index 7622c6f..ffc84d2 100644 --- a/tox.ini +++ b/tox.ini @@ -1,24 +1,23 @@ [tox] -envlist = pep8,isort-check,py36 +envlist = pep8,isort-check,py36,py37 # no setup.py to be ran skipsdist = True [testenv] setenv = PYTHONPATH = {toxinidir}/src/ + SOURCES = src/ tests/ setup.py setup_meta.py +passenv = + CI + DISPLAY deps = -r{toxinidir}/requirements.txt -r{toxinidir}/requirements/requirements-test.txt -commands = pytest tests/ +commands = + pytest --cov src/ tests/ [testenv:pep8] -commands = flake8 src/ tests/ setup.py setup_meta.py +commands = flake8 {env:SOURCES} [testenv:isort-check] -commands = - isort --check-only --recursive --diff src/ tests/ setup.py setup_meta.py - - -[flake8] -ignore = - E501, # Line too long (82 > 79 characters) +commands = isort --check-only --recursive --diff {env:SOURCES}